Skip to content

task: generate typed protocols for test schemas and validate them in CI #1132

Description

@ogenstad

Component: Python SDK, CI/CD

Why this is needed

pyproject.toml carries a growing list of [[tool.ty.overrides]] blocks that disable type checking rules for test files. These are documented as tech debt ("Fix these incrementally by addressing violations and removing the override"), and nearly all of them exist for the same two reasons:

  1. Dynamic node attribute access. Tests read node.name.value / node.primary_tag.peer on plain InfrahubNode objects. That access goes through InfrahubNode.__getattr__, which is typed as returning Attribute | RelationshipManager | RelatedNode, so every such expression raises possibly-missing-attribute. Affected overrides: test_node.py, test_hierarchical_nodes.py, test_schema.py, test_store_branch.py, test_repository.py, tests/integration/**.
  2. Untyped schema construction. Test schemas are defined as inline dicts in tests/unit/sdk/conftest.py and built with NodeSchema(**data), which loses all types at the constructor boundary (invalid-argument-type, ~434 violations in the conftest.py override alone).

The SDK already has the intended answer to problem 1: generated protocol classes plus the kind= overloads on client.get() / store.get(), which return a typed SchemaType. Tests can't use it today because the conftest test schemas (BuiltinLocation, InfraLocation, ...) have no protocol definitions - only kinds in the real core schema exist in infrahub_sdk/protocols.py.

tests/unit/sdk/test_store_merge.py shows the workaround: hand-written protocol classes for its fixture schemas. It type-checks clean with no override, proving the approach, but hand-written protocols duplicate the schema and will drift silently when a fixture changes. They also can't express everything the generator can (see RelationshipAttribute below).

Prior art: how the main Infrahub repo solves this

The main repo generates protocols for its test schemas and gates them in CI:

  • Test schemas are defined once, as schema model objects, in backend/tests/helpers/schema/ (e.g. CAR, PERSON, TAG), and the package exports a test_models dict.
  • invoke backend.generate renders backend/tests/protocols.py from those models with the same Jinja template as the production protocols, after extending the test models with the core models so every referenced peer kind is defined.
  • invoke backend.validate-generated runs the generator and then git diff --exit-code backend/infrahub/core/protocols.py backend/tests/protocols.py - CI fails if a schema changed without regenerating.

Recommended approach for the SDK

The SDK already ships the generator: infrahub_sdk/protocols_generator/generator.py (CodeGenerator), used by infrahubctl protocols. It accepts exactly what the conftest fixtures produce (dict[str, MainSchemaTypesAll], e.g. NodeSchema(...).convert_api()). A prototype run against the location_schema fixture produced:

class BuiltinLocation(CoreNode):
    description: StringOptional
    name: String
    type: String
    primary_tag: RelationshipAttribute[BuiltinTag]
    tags: RelationshipManager[BuiltinTag]

(plus the CoreNodeSync twin from render(sync=True)). Note RelationshipAttribute[BuiltinTag]: the generated form keeps the peer type through .peer and accepts node.primary_tag = "some-id" assignment - hand-written primary_tag: RelatedNode annotations can express neither, which is the same family of problem as #1062 / #1063 / #1064.

Suggested steps:

  1. Centralize the test schemas (the bulk of the work). Lift the ~30 inline schema dicts out of tests/unit/sdk/conftest.py into a module such as tests/helpers/schema/, defined as NodeSchema objects. The existing fixtures become thin wrappers returning .convert_api() of the shared objects, so no test changes are needed beyond imports.
  2. Add an invoke task (e.g. generate-test-protocols, wired into the existing generate/validate tasks in tasks.py) that feeds those schemas through CodeGenerator and writes a committed tests/helpers/protocols.py (async + sync variants, ruff-formatted, with the standard "generated, do not edit" header).
  3. Gate it in CI the same way docs-validate works: a validate step that regenerates and runs git diff --exit-code tests/helpers/protocols.py, so any schema fixture change that isn't regenerated fails the pipeline.
  4. Adopt in tests incrementally: replace the hand-written protocols in test_store_merge.py with the generated ones, then migrate the other override-listed test files to typed store.get(key=..., kind=<Protocol>) / client.get(kind=<Protocol>) lookups and delete their [[tool.ty.overrides]] blocks one file at a time.

Known wrinkles (found while prototyping)

  • Deprecated attribute kind. The conftest schemas use kind="String", which ATTRIBUTE_KIND_MAP in infrahub_sdk/protocols_generator/constants.py does not map - the generator raises KeyError: 'String'. The centralized schemas must migrate to "Text" (consistent with the AttributeKind.STRING deprecation). This is invisible to the tests themselves (schema.kind only drives the IP-type value mapper).
  • Peer kinds must be in the generation set. The template renders a self-contained module, so peers referenced by relationships (BuiltinTag, CoreGroup, ...) must be included, mirroring the main repo's test_models["nodes"].extend(core_models["nodes"]), or the annotations become undefined names.
  • Protocol attribute gaps. protocols_base.Attribute doesn't declare SDK internals such as is_fetched, value_has_been_mutated, source/owner. Tests asserting on internals should keep going through node._attribute_data["name"] (typed as the real Attribute), as test_store_merge.py does; extending the base protocol is a separate decision.
  • Schema fixture construction. While centralizing, build schemas with explicit keywords / AttributeSchema(...) objects rather than NodeSchema(**data) - that alone removes the invalid-argument-type half of the conftest override.

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    type/taskBody of work related to an epictype/tech-debtItem we know we need to improve way it is implemented

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions