Skip to content

feat: generate user-facing write/read schema models + offline validation#1135

Open
dgarros wants to merge 18 commits into
infrahub-developfrom
dga/user-schema-infp-234
Open

feat: generate user-facing write/read schema models + offline validation#1135
dgarros wants to merge 18 commits into
infrahub-developfrom
dga/user-schema-infp-234

Conversation

@dgarros

@dgarros dgarros commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds generated, user-facing write and read schema models to the SDK plus an offline validate_schema() so a schema can be checked for correctness with no running Infrahub server. Part of the user-facing schema separation (opsmill/infrahub, INFP-234).

Key Changes

  • New generated models infrahub_sdk/schema/generated/{write,read}.py — the write model is exactly what /api/schema/load accepts (constrained fields carry their allowed values as Literal[...]; extra="forbid"); read = write + visible-but-not-settable fields.
  • validate_schema() (infrahub_sdk/schema/validate.py) — field-level offline validation with dotted error locations; also gates schema extensions.
  • Drift/presence guard test so the generated models can't silently go stale.
  • Regenerated protocols.py (pre-existing drift vs current backend schema).

Related Context

Generated from the backend's schema definitions; consumed by the backend PR (opsmill/infrahub). Depends on nothing else in this repo.

Test Plan

uv run pytest tests/unit/test_schema_offline_validation.py tests/unit/test_schema_generated_models.py

14 offline-validation cases + generated-model guard pass, pydantic-only (no server).

Notes

Draft: the backend still ships a parallel hand-written model set; full consolidation of the SDK's hand-written schema models is tracked as a follow-up.

🤖 Generated with Claude Code


Summary by cubic

Generates user-facing write/read schema models and an offline validate_schema() for full-document checks without a server, and backs public SDK schema types with the generated contract while separating read/write and tightening defaults and constraints (INFP-234).

  • New Features

    • Committed generated models in infrahub_sdk/schema/generated/{enums,write,read}.py; export InfrahubSchema{Write,Read} and enums; read includes profile/template models; write keeps extra="forbid" and covers extensions at the root.
    • Attribute and computed-attribute use kind-discriminated unions; parameters, choices, and computed_attribute are typed models.
    • Offline validate_schema() validates nodes, generics, and extensions via InfrahubSchemaWrite, returns SchemaValidationResult with dotted field errors; includes a drift guard.
  • Refactors

    • Public SDK schema classes wrap the generated write/read models; enums are re-exported; CoreNodeBase._schema retyped to MainSchemaTypesAPI.
    • Read-only fields moved to generated read models (kind as a computed field, hash stored); write nodes expose a non-serializing .kind property for local access.
    • Reverted client-side normalization of schema.load()/schema.check() payloads; we no longer strip read-only/internal fields before sending (server enforces rejection).
    • Breaking: AttributeKind.STRING removed; write models reject unknown fields; defaults aligned with the server (relationship min_count/max_count=0; node branch="aware", generate_profile=True, generate_template=False).

Written for commit d15ce11. Summary will update on new commits.

Review in cubic

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 4, 2026

Copy link
Copy Markdown

Deploying infrahub-sdk-python with  Cloudflare Pages  Cloudflare Pages

Latest commit: d15ce11
Status: ✅  Deploy successful!
Preview URL: https://06974a59.infrahub-sdk-python.pages.dev
Branch Preview URL: https://dga-user-schema-infp-234.infrahub-sdk-python.pages.dev

View logs

@codecov

codecov Bot commented Jul 4, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.04031% with 5 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
infrahub_sdk/schema/validate.py 95.34% 1 Missing and 1 partial ⚠️
infrahub_sdk/schema/generated/write.py 99.37% 1 Missing ⚠️
infrahub_sdk/schema/main.py 98.52% 1 Missing ⚠️
infrahub_sdk/testing/schemas/car_person.py 0.00% 1 Missing ⚠️

❗ There is a different number of reports uploaded between BASE (432a99d) and HEAD (d15ce11). Click for more details.

HEAD has 1 upload less than BASE
Flag BASE (432a99d) HEAD (d15ce11)
integration-tests 1 0
@@                 Coverage Diff                  @@
##           infrahub-develop    #1135      +/-   ##
====================================================
- Coverage             83.03%   77.18%   -5.85%     
====================================================
  Files                   139      144       +5     
  Lines                 12365    12693     +328     
  Branches               1846     1852       +6     
====================================================
- Hits                  10267     9797     -470     
- Misses                 1531     2339     +808     
+ Partials                567      557      -10     
Flag Coverage Δ
integration-tests ?
python-3.10 55.77% <9.21%> (-1.18%) ⬇️
python-3.11 55.79% <9.21%> (-1.16%) ⬇️
python-3.12 55.79% <9.21%> (-1.16%) ⬇️
python-3.13 55.77% <9.21%> (-1.18%) ⬇️
python-3.14 55.79% <9.21%> (-1.15%) ⬇️
python-filler-3.12 24.01% <89.82%> (+1.70%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
infrahub_sdk/protocols.py 100.00% <100.00%> (ø)
infrahub_sdk/protocols_base.py 77.60% <100.00%> (ø)
infrahub_sdk/schema/__init__.py 71.42% <100.00%> (-2.14%) ⬇️
infrahub_sdk/schema/generated/__init__.py 100.00% <100.00%> (ø)
infrahub_sdk/schema/generated/enums.py 100.00% <100.00%> (ø)
infrahub_sdk/schema/generated/read.py 100.00% <100.00%> (ø)
infrahub_sdk/spec/object.py 59.34% <100.00%> (-25.91%) ⬇️
infrahub_sdk/testing/schemas/animal.py 0.00% <ø> (-97.11%) ⬇️
infrahub_sdk/schema/generated/write.py 99.37% <99.37%> (ø)
infrahub_sdk/schema/main.py 84.02% <98.52%> (-7.07%) ⬇️
... and 2 more

... and 29 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 11 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread infrahub_sdk/schema/validate.py Outdated
Comment thread infrahub_sdk/schema/validate.py Outdated
@dgarros dgarros force-pushed the dga/user-schema-infp-234 branch from ce6e067 to aa401e0 Compare July 5, 2026 06:26
@dgarros dgarros changed the base branch from develop to infrahub-develop July 5, 2026 06:26

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 5 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread infrahub_sdk/schema/validate.py Outdated

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3 issues found across 20 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="tests/unit/test_schema_generated_models.py">

<violation number="1" location="tests/unit/test_schema_generated_models.py:224">
P2: The test `test_use_enum_values_keeps_runtime_field_values_as_plain_strings` claims to verify that `use_enum_values=True` keeps runtime values as plain strings, but all three assertions (equality with raw string, equality with enum member, and `isinstance(str)`) pass identically whether `use_enum_values` is `True` or `False`. This is because the generated enums are `str`-backed (`str, Enum`), so their members always compare equal to their string values and are always instances of `str`. The test cannot detect if `use_enum_values` were accidentally removed in a future regeneration. Consider adding an assertion that discriminates between the two modes, such as `assert not isinstance(relationship.cardinality, enums_module.RelationshipCardinality)` or `assert type(relationship.cardinality) is str`.</violation>
</file>

<file name="infrahub_sdk/schema/main.py">

<violation number="1" location="infrahub_sdk/schema/main.py:266">
P1: `AttributeSchema` claims to relax `extra="forbid"` inherited from `AttributeSchemaBaseWrite`, but its `model_config = ConfigDict(use_enum_values=True)` does not actually override the parent setting. In Pydantic v2, subclass `model_config` is merged with the parent's, so `extra="forbid"` remains in effect unless explicitly overridden. This means historical construction patterns that passed unrecognized keyword arguments — which the old `AttributeSchema` silently accepted — will now raise validation errors. The docstring explicitly says `extra="forbid"` is relaxed, but the implementation contradicts that intent.

To fix this, add `extra="ignore"` (or `"allow"`) to the subclass `ConfigDict` so it truly restores the permissive behavior the docstring promises.</violation>

<violation number="2" location="infrahub_sdk/schema/main.py:315">
P1: The public `SchemaRoot` model is missing the new `extensions` field that the generated write model (`InfrahubSchemaWrite`) now requires for `/api/schema/load`. It still serializes the legacy `node_extensions` key, so schema payloads constructed or validated through the public `SchemaRoot` entry point will not match the new backend contract. Consider adding `extensions: SchemaExtensionWrite | None = None` and aligning `to_schema_dict()` with the generated write shape, or documenting that `SchemaRoot` is deprecated in favor of the generated write model for load payloads.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

version: str
generics: list[GenericSchema] = Field(default_factory=list)
nodes: list[NodeSchema] = Field(default_factory=list)
node_extensions: list[NodeExtensionSchema] = Field(default_factory=list)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: The public SchemaRoot model is missing the new extensions field that the generated write model (InfrahubSchemaWrite) now requires for /api/schema/load. It still serializes the legacy node_extensions key, so schema payloads constructed or validated through the public SchemaRoot entry point will not match the new backend contract. Consider adding extensions: SchemaExtensionWrite | None = None and aligning to_schema_dict() with the generated write shape, or documenting that SchemaRoot is deprecated in favor of the generated write model for load payloads.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At infrahub_sdk/schema/main.py, line 315:

<comment>The public `SchemaRoot` model is missing the new `extensions` field that the generated write model (`InfrahubSchemaWrite`) now requires for `/api/schema/load`. It still serializes the legacy `node_extensions` key, so schema payloads constructed or validated through the public `SchemaRoot` entry point will not match the new backend contract. Consider adding `extensions: SchemaExtensionWrite | None = None` and aligning `to_schema_dict()` with the generated write shape, or documenting that `SchemaRoot` is deprecated in favor of the generated write model for load payloads.</comment>

<file context>
@@ -264,100 +249,105 @@ def unique_attributes(self) -> list[AttributeSchemaAPI]:
+    version: str
+    generics: list[GenericSchema] = Field(default_factory=list)
+    nodes: list[NodeSchema] = Field(default_factory=list)
+    node_extensions: list[NodeExtensionSchema] = Field(default_factory=list)
+
+    def to_schema_dict(self) -> dict[str, Any]:
</file context>

@property
def supports_artifacts(self) -> bool:
"""Return True if this schema supports artifact operations via CoreArtifactTarget inheritance.
model_config = ConfigDict(use_enum_values=True)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: AttributeSchema claims to relax extra="forbid" inherited from AttributeSchemaBaseWrite, but its model_config = ConfigDict(use_enum_values=True) does not actually override the parent setting. In Pydantic v2, subclass model_config is merged with the parent's, so extra="forbid" remains in effect unless explicitly overridden. This means historical construction patterns that passed unrecognized keyword arguments — which the old AttributeSchema silently accepted — will now raise validation errors. The docstring explicitly says extra="forbid" is relaxed, but the implementation contradicts that intent.

To fix this, add extra="ignore" (or "allow") to the subclass ConfigDict so it truly restores the permissive behavior the docstring promises.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At infrahub_sdk/schema/main.py, line 266:

<comment>`AttributeSchema` claims to relax `extra="forbid"` inherited from `AttributeSchemaBaseWrite`, but its `model_config = ConfigDict(use_enum_values=True)` does not actually override the parent setting. In Pydantic v2, subclass `model_config` is merged with the parent's, so `extra="forbid"` remains in effect unless explicitly overridden. This means historical construction patterns that passed unrecognized keyword arguments — which the old `AttributeSchema` silently accepted — will now raise validation errors. The docstring explicitly says `extra="forbid"` is relaxed, but the implementation contradicts that intent.

To fix this, add `extra="ignore"` (or `"allow"`) to the subclass `ConfigDict` so it truly restores the permissive behavior the docstring promises.</comment>

<file context>
@@ -264,100 +249,105 @@ def unique_attributes(self) -> list[AttributeSchemaAPI]:
-    @property
-    def supports_artifacts(self) -> bool:
-        """Return True if this schema supports artifact operations via CoreArtifactTarget inheritance.
+    model_config = ConfigDict(use_enum_values=True)
 
-        Only NodeSchemaAPI overrides this; all other schema types return False by design because
</file context>
Suggested change
model_config = ConfigDict(use_enum_values=True)
model_config = ConfigDict(extra="ignore", use_enum_values=True)

relationship = write_module.RelationshipSchemaWrite(name="interfaces", peer="InfraInterface", cardinality="one")
assert relationship.cardinality == "one"
assert relationship.cardinality == enums_module.RelationshipCardinality.ONE
assert isinstance(relationship.cardinality, str)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The test test_use_enum_values_keeps_runtime_field_values_as_plain_strings claims to verify that use_enum_values=True keeps runtime values as plain strings, but all three assertions (equality with raw string, equality with enum member, and isinstance(str)) pass identically whether use_enum_values is True or False. This is because the generated enums are str-backed (str, Enum), so their members always compare equal to their string values and are always instances of str. The test cannot detect if use_enum_values were accidentally removed in a future regeneration. Consider adding an assertion that discriminates between the two modes, such as assert not isinstance(relationship.cardinality, enums_module.RelationshipCardinality) or assert type(relationship.cardinality) is str.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At tests/unit/test_schema_generated_models.py, line 224:

<comment>The test `test_use_enum_values_keeps_runtime_field_values_as_plain_strings` claims to verify that `use_enum_values=True` keeps runtime values as plain strings, but all three assertions (equality with raw string, equality with enum member, and `isinstance(str)`) pass identically whether `use_enum_values` is `True` or `False`. This is because the generated enums are `str`-backed (`str, Enum`), so their members always compare equal to their string values and are always instances of `str`. The test cannot detect if `use_enum_values` were accidentally removed in a future regeneration. Consider adding an assertion that discriminates between the two modes, such as `assert not isinstance(relationship.cardinality, enums_module.RelationshipCardinality)` or `assert type(relationship.cardinality) is str`.</comment>

<file context>
@@ -167,6 +169,61 @@ def test_extension_models_forbid_extra_fields(name: str) -> None:
+    relationship = write_module.RelationshipSchemaWrite(name="interfaces", peer="InfraInterface", cardinality="one")
+    assert relationship.cardinality == "one"
+    assert relationship.cardinality == enums_module.RelationshipCardinality.ONE
+    assert isinstance(relationship.cardinality, str)
+
+
</file context>
Suggested change
assert isinstance(relationship.cardinality, str)
assert isinstance(relationship.cardinality, str)
assert not isinstance(relationship.cardinality, enums_module.RelationshipCardinality)

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 4 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="infrahub_sdk/schema/main.py">

<violation number="1" location="infrahub_sdk/schema/main.py:281">
P1: The public constructible write class `NodeSchema` no longer inherits `_SchemaKindMixin`, so it loses the runtime `.kind` property and all capability helpers (`supports_artifact_definition`, `supports_hierarchy`, etc.) that were previously available. The generated `NodeSchemaWrite` base does not provide a replacement `kind`, so existing code that constructs `NodeSchema` and accesses `.kind` will now raise `AttributeError`. Consider either restoring the `_SchemaKindMixin` inheritance to `NodeSchema` and `GenericSchema`, adding a `kind` computed field to the generated write base, or documenting this as an intentional breaking change if write models are no longer meant to expose these helpers.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread infrahub_sdk/schema/main.py

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 2 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="tests/unit/test_schema_generated_models.py">

<violation number="1" location="tests/unit/test_schema_generated_models.py:224">
P2: The test `test_use_enum_values_keeps_runtime_field_values_as_plain_strings` claims to verify that `use_enum_values=True` keeps runtime values as plain strings, but all three assertions (equality with raw string, equality with enum member, and `isinstance(str)`) pass identically whether `use_enum_values` is `True` or `False`. This is because the generated enums are `str`-backed (`str, Enum`), so their members always compare equal to their string values and are always instances of `str`. The test cannot detect if `use_enum_values` were accidentally removed in a future regeneration. Consider adding an assertion that discriminates between the two modes, such as `assert not isinstance(relationship.cardinality, enums_module.RelationshipCardinality)` or `assert type(relationship.cardinality) is str`.</violation>
</file>

<file name="infrahub_sdk/schema/main.py">

<violation number="1" location="infrahub_sdk/schema/main.py:266">
P1: `AttributeSchema` claims to relax `extra="forbid"` inherited from `AttributeSchemaBaseWrite`, but its `model_config = ConfigDict(use_enum_values=True)` does not actually override the parent setting. In Pydantic v2, subclass `model_config` is merged with the parent's, so `extra="forbid"` remains in effect unless explicitly overridden. This means historical construction patterns that passed unrecognized keyword arguments — which the old `AttributeSchema` silently accepted — will now raise validation errors. The docstring explicitly says `extra="forbid"` is relaxed, but the implementation contradicts that intent.

To fix this, add `extra="ignore"` (or `"allow"`) to the subclass `ConfigDict` so it truly restores the permissive behavior the docstring promises.</violation>

<violation number="2" location="infrahub_sdk/schema/main.py:315">
P1: The public `SchemaRoot` model is missing the new `extensions` field that the generated write model (`InfrahubSchemaWrite`) now requires for `/api/schema/load`. It still serializes the legacy `node_extensions` key, so schema payloads constructed or validated through the public `SchemaRoot` entry point will not match the new backend contract. Consider adding `extensions: SchemaExtensionWrite | None = None` and aligning `to_schema_dict()` with the generated write shape, or documenting that `SchemaRoot` is deprecated in favor of the generated write model for load payloads.</violation>
</file>

<file name="infrahub_sdk/ctl/cli_commands.py">

<violation number="1" location="infrahub_sdk/ctl/cli_commands.py:384">
P2: The `api_items` list may fail static type checking. When initialized from `[node.convert_api() for node in schema_root.nodes]`, type checkers infer `list[NodeSchemaAPI]`. Adding `list[GenericSchemaAPI]` via `+=` then fails because `list` is invariant. Since the project uses mypy, annotate the list with `list[MainSchemaTypesAPI]` (or `list[NodeSchemaAPI | GenericSchemaAPI]`) so mixing node and generic API items is accepted by the type checker.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread infrahub_sdk/ctl/cli_commands.py Outdated
schema.update({item.kind: item for item in schema_root.nodes + schema_root.generics})
# `kind` is a read-model concept, so convert the write models loaded from disk to their
# API form to key them by kind consistently with the branch-fetched path below.
api_items = [node.convert_api() for node in schema_root.nodes]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The api_items list may fail static type checking. When initialized from [node.convert_api() for node in schema_root.nodes], type checkers infer list[NodeSchemaAPI]. Adding list[GenericSchemaAPI] via += then fails because list is invariant. Since the project uses mypy, annotate the list with list[MainSchemaTypesAPI] (or list[NodeSchemaAPI | GenericSchemaAPI]) so mixing node and generic API items is accepted by the type checker.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At infrahub_sdk/ctl/cli_commands.py, line 384:

<comment>The `api_items` list may fail static type checking. When initialized from `[node.convert_api() for node in schema_root.nodes]`, type checkers infer `list[NodeSchemaAPI]`. Adding `list[GenericSchemaAPI]` via `+=` then fails because `list` is invariant. Since the project uses mypy, annotate the list with `list[MainSchemaTypesAPI]` (or `list[NodeSchemaAPI | GenericSchemaAPI]`) so mixing node and generic API items is accepted by the type checker.</comment>

<file context>
@@ -379,7 +379,11 @@ def protocols(
-            schema.update({item.kind: item for item in schema_root.nodes + schema_root.generics})
+            # `kind` is a read-model concept, so convert the write models loaded from disk to their
+            # API form to key them by kind consistently with the branch-fetched path below.
+            api_items = [node.convert_api() for node in schema_root.nodes]
+            api_items += [generic.convert_api() for generic in schema_root.generics]
+            schema.update({item.kind: item for item in api_items})
</file context>

@dgarros dgarros force-pushed the dga/user-schema-infp-234 branch 2 times, most recently from 45502c5 to 622c56f Compare July 9, 2026 06:44
@dgarros dgarros marked this pull request as ready for review July 11, 2026 13:48
@dgarros dgarros requested a review from a team as a code owner July 11, 2026 13:48

@ajtmccarty ajtmccarty left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the main outstanding issue for me is finding some way to create the SchemaAPI classes without Mixins that have TYPE_CHECKING conditionals in them. there are also some comments from cubic that might need addressing

Comment thread infrahub_sdk/schema/generated/write.py
the derived capability helpers and declares ``kind`` for the type checker.
"""

if TYPE_CHECKING:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm surprised this is necessary. if _SchemaKindMixin is only used in classes inheriting from BaseNodeReadSchema / BaseNodeWriteSchema, then I would think that name, namespace, etc. fields on the BaseNode... class would cover this case.
A Mixin with a TYPE_CHECKING conditional inside of it feels like a kludge. is there some other way to handle this?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey — I was surprised by this too, so I had Claude dig into it. Sharing what it found, because I actually think the current form is the right call.

Why the block is there: at runtime this is fine — every class the mixin lands on (NodeSchemaAPI, GenericSchemaAPI, ProfileSchemaAPI, TemplateSchemaAPI) inherits from a generated read model that extends BaseNodeSchemaRead, where name / namespace / kind / attributes / … actually live, so MRO resolves everything. The issue is purely static: the type checker analyzes _SchemaKindMixin in isolation at its definition, where it has no base that provides those attributes, so self.kind etc. come back "unresolved." The TYPE_CHECKING block is just the contract — "my host supplies these" — and emits zero runtime code. It's the documented idiom for typing mixins whose attributes come from the eventual host class.

(Detail: kind is declared as a @property, not kind: str, because on the generated base it's a read-only @computed_field — a plain annotation would model it as a settable field and clash in the MRO.)

Why not just rely on the fields from BaseNodeSchemaRead: that only works at runtime. The whole point of a mixin here is to share these helpers across four distinct generated read classes; the mixin has no single base to inherit the fields from at its own definition site, which is exactly what the checker looks at.

Why the alternatives are worse:

  • Make each mixin subclass its generated base (so fields are inherited): this removes the block, but turns the plain, field-less mixins into full pydantic models with diamond inheritance, and couples each mixin to one specific generated base — losing the "compose onto any of them" property that motivated the mixin. It also drags back the field-collection subtleties (and the kind computed-field clash) we deliberately kept out. Net negative.
  • Protocol-typed self on every method: doesn't actually remove the contract, just relocates it — and now every helper (including the @property getters) carries a self: SomeProtocol annotation. More noise, not less.
  • Generate the helpers into the read models: this behavior is SDK-specific (e.g. kind == "CoreArtifactDefinition"), not derivable from the schema, so it would leak hand-written logic into files that are supposed to be pure generated schema.

So the TYPE_CHECKING block is the least-intrusive option and a recognized pattern rather than a one-off hack — I'd like to keep it as-is.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This sounds like a contract without any verification. the _SchemaKindMixin class seems like it indicates that any class using this Mixin needs to define name, namespace, inherit_from, and kind instance variables or properties, but I don't think there is any checking that actually verifies this. for example if NodeSchemaAPI does not define name, then I don't think it would be identified by type-checking. if I am wrong about that, then I will approve this pattern, but I can't approve something that has 3 strikes against it

  • it's a Mixin, which I consider a code smell
  • it uses TYPE_CHECKING, which I consider a crutch that hides import errors regardless of where it is used
  • it isn't actually type checked

I think the right approach is to make _SchemaKindMixin and _SchemaRelAttrMixin one base class for the Node/Generic/Profile/TemplateSchemaAPI classes, even if it uses "diamond inheritance"

Comment on lines +60 to +62
assert any("inherited" in message for message in result.messages), result.messages
# The message must locate the offending field within the payload.
assert any("nodes[0].attributes[0]" in message for message in result.messages), result.messages

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think these error assertions could be a little more restrictive. I think there should only be 1 error and it should be possible to assert more, or all, of its text
same for other error message assertions in this test file

Comment on lines +79 to +80
# cardinality is typed with the RelationshipCardinality enum (use_enum_values keeps the runtime
# value a plain string); a valid enum value must still validate.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

# test string for RelationshipCardinality is valid

assert any("not_a_field" in message for message in result.messages), result.messages


def test_raise_on_error_raises_value_error_naming_field() -> None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks very similar to test_out_of_enum_value_is_rejected_naming_field_and_value

@dgarros dgarros force-pushed the dga/user-schema-infp-234 branch from 622c56f to d40aa55 Compare July 14, 2026 16:12

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 4 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread infrahub_sdk/schema/__init__.py Outdated
dgarros and others added 12 commits July 16, 2026 05:48
Add committed, generated write and read schema model variants under
infrahub_sdk/schema/generated/. They are produced by the backend generator
from the single source of truth in internal.py, filtered by a new field
visibility axis (write ⊆ read ⊆ internal). The models are self-contained
(pydantic + typing only) so they import with only the SDK installed, the
write model retains extra="forbid", and constrained fields carry their
allowed-value set as Literal[...] so the emitted JSON-schema is complete.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ft guard [INFP-234]

Add validate_schema() to validate a schema payload against the generated
write models with only the SDK installed (no server): it returns a
field-level verdict rejecting non-settable/unknown fields and out-of-enum
values. Add SDK unit tests for offline validation and a drift guard that
asserts the generated write/read models are present, carry the do-not-edit
header, and satisfy the write(extra=forbid)/read-superset invariants.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extend the offline write-contract validation so the attributes and
relationships nested under extensions.nodes[*] are held to the same
generated write models as node/generic-level ones. Previously only
top-level nodes and generics were gated, letting read-level, unknown,
and out-of-enum fields slip through on extension payloads.

Add SDK offline tests covering extension attribute/relationship
rejection with dotted error locations, plus breadth coverage for
out-of-enum relationship cardinality/kind and read-level fields on
relationships, generics, and nodes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…per-family Write/Read

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…f per-collection map

Validate nodes and generics in one pass against the generated write
document model; keep the separate extension gating (extension nodes are
kind-only). Field-level dotted errors unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…t[str, Any]

The computed_attribute block is now a dedicated model (ComputedAttributeWrite/Read)
with a Literal kind and extra=forbid, so the write contract for a computed attribute
is explicit and its kind/unknown-field errors are caught with a field-level location.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…_attribute

Replace the opaque dict[str, Any] sub-blocks in the generated write/read schema
models with typed models: DropdownChoice, a plain union of the five attribute
parameter shapes, and a kind-discriminated union for computed_attribute that
enforces the jinja2_template/transform requirement natively.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the flat attribute write/read models, which typed parameters as a
plain union of every parameters shape, with a discriminated union on kind.
A shared AttributeSchemaBase carries every field except parameters, and each
variant narrows kind to the kinds sharing one parameters shape and carries
that parameters model, so a Text attribute no longer validates NumberPool
parameters. The public AttributeSchema{Write,Read} name becomes the union
alias.

Route offline validation of a single item through pydantic.TypeAdapter so a
union alias (which has no model_validate) validates like a plain model.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… models

Extend the generated user-facing schema contract so it is complete:

- Write variant gains NodeExtensionWrite / SchemaExtensionWrite (extra="forbid")
  and InfrahubSchemaWrite now carries an extensions field and forbids extra
  top-level keys, so the published write contract covers schema extensions.
- Read variant gains read-only ProfileSchemaRead / TemplateSchemaRead so every
  read item is described by a generated model.
- validate_schema now validates the whole root (nodes, generics, extensions) via
  InfrahubSchemaWrite in a single pass; the bespoke extension helper is retired
  while field-level dotted paths and the "(received: ...)" suffix are preserved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Constrained fields in the generated user-facing write/read models were
rendered as inline Literals. Emit dedicated (str, Enum) classes into a new
self-contained generated/enums.py and type the fields with them, matching the
SDK's historical enum names. Every generated model gains use_enum_values=True
so runtime values stay plain strings; attribute/computed-attribute
discriminated unions use Literal[Enum.MEMBER] discriminators.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… [INFP-234]

Retire the hand-maintained model bodies in ``infrahub_sdk/schema/main.py`` and
re-base every public name on the generated write/read models plus hand-written
behavior mixins, keeping import paths, names, methods and envelopes stable.

- Enums (``AttributeKind``, ``BranchSupportType``, ``RelationshipCardinality``,
  ...) are re-exported from ``generated.enums`` under the historical names.
- Behavior mixins carry the 15 attribute/relationship helpers, the ``kind`` /
  ``supports_*`` / hierarchy flags, ``cardinality_is_*`` and write ``convert_api``.
- Read ``*API`` models stay concrete subclasses of the generated ``*Read`` models
  so ``isinstance`` keeps working; write ``NodeSchema``/``GenericSchema``/
  ``RelationshipSchema`` subclass the generated write models.
- A thin constructible ``AttributeSchema``/``AttributeSchemaAPI`` (on the shared
  write/read base) keeps ``AttributeSchema(name=..., kind=...)`` working; nodes
  narrow ``attributes``/``relationships`` to those variants and still validate
  into the strict generated discriminated union on dump.
- Envelopes (``SchemaRoot``, ``SchemaRootAPI``, ``BranchSchema``) stay
  hand-written on the generated inner models.

Breaking: ``AttributeKind.STRING`` removed; write-model defaults now match the
server contract (relationship min/max_count 0, node branch "aware",
generate_profile True, generate_template False) and reject unknown fields.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Enum-typed generated fields defaulted to the raw string value, which failed
mypy/ty against the enum annotation. Emit the enum member (use_enum_values
coerces to the value at runtime).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
dgarros and others added 5 commits July 16, 2026 05:48
… [INFP-234]

The enum-typed RelationshipSchemaWrite.cardinality field makes ty flag the
deliberate raw-string construction used to prove pydantic's use_enum_values
runtime coercion. Add a scoped ty: ignore so the intent-preserving test passes
python-lint.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
kind (namespace+name) and hash (server-computed) are read-only. Expose kind
as a pydantic computed_field and hash as a plain field on the generated read
base node model, so node/generic/profile/template read models inherit both.

Drop the hand-written kind property from the schema kind mixin and the hand
hash fields from the API read models; the write NodeSchema/GenericSchema no
longer carry the read-only kind mixin. Retype CoreNodeBase._schema to the read
union so static _schema.kind access stays valid.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reading a locally-authored (write) schema needs `.kind` attribute access
(e.g. the protocols CLI command). Expose `kind` as a plain property on the
write node models — derived from namespace+name, like on read — but do not
serialize it: on write it stays a property (not a `@computed_field`) so it
never enters the payload, where `extra="forbid"` would reject it on the
round-trip through the write/load contract. `hash` remains read-only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…se [INFP-234]

Picks up the upstream `ordered` attribute field (now classified write-visible)
in the generated schema models and syncs protocols.py with the current backend
core models.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ct [INFP-234]

The /api/schema/load write contract rejects read-only and internal fields.
Callers commonly build a payload from a schema read back from Infrahub or a
full model dump, which carries those fields. schema.load()/check() now project
each payload onto the generated write models before sending — dropping
read-only/internal keys and resolving the per-kind attribute and
computed-attribute discriminated unions — so such payloads load cleanly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@dgarros dgarros force-pushed the dga/user-schema-infp-234 branch from b1fe1f8 to 07579bb Compare July 16, 2026 05:49
…INFP-234]

Stripping read-only/internal fields client-side silently dropped fields the
server is meant to reject (e.g. a node-only field on an extension), defeating
the server's rejection contract and hiding user typos. The tolerate/reject
decision now lives server-side in the schema-load endpoint instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants