Skip to content

feat: report the schema fields the write contract does not apply - #1223

Open
dgarros wants to merge 2 commits into
infrahub-developfrom
dg-schema-load-extra-field-feedback
Open

feat: report the schema fields the write contract does not apply#1223
dgarros wants to merge 2 commits into
infrahub-developfrom
dg-schema-load-extra-field-feedback

Conversation

@dgarros

@dgarros dgarros commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

What changed

The generated write models set extra="ignore", so a field the user may not set never reaches the server. That settled whether such a field has an effect, but not whether the author hears about it — a misspelled key produced a schema quietly different from the one they wrote.

Every extra key in a submitted payload is now classified:

  • a name the contract knows at that location but the user may not set is dropped and reported as a warning, so a schema read back from Infrahub, edited and loaded again keeps working;
  • any other name is an error, since the only ways to produce one are a typo and a field that no longer exists.
nodes[0].attributes[0].inherited: Read-only field, the submitted value is ignored (received: True)
nodes[0].attributes[0].optionl: Unknown field, it is not part of the schema (received: True)

Implementation notes

A generated table, not a third model family. infrahub_sdk/schema/generated/contract.py holds the non-settable field names of each write class (17 entries). It is produced by the Infrahub-side generator from two diffs: the read variant of a class against its write variant, and the internal pydantic counterpart of a value model against its generated write model. The second diff matters — the parameters/choice/computed-attribute/extension models are hand-declared in the generator, so they omit the id/state bookkeeping every internal schema model carries, and each computed-attribute variant omits its siblings' fields. Those names all appear in a schema dumped from Infrahub's own models, so treating them as unknown would have broken the round trip.

Applying the table needs to know which model governs each place in the payload. _collect_extra_fields walks the raw payload alongside the validated write document: the document resolves the model at every location, including which member of a discriminated union an attribute matched, so raw keys are compared against the fields that location actually accepts. This is why the walk gives exact dotted paths and the owning kind/element for free, and it avoids both a second generated model family and a hand-rolled discriminator resolver.

One consequence, documented in the docstring: extra fields surface only once the payload is otherwise valid, because the validated document is what resolves the contract. A payload rejected for another reason names its extra fields on the next run.

API changes

  • SchemaValidationResult gains warnings (list[SchemaValidationWarningDetail]) and warning_messages. Each warning carries the dotted path, the bare field name, and the schema kind / element that set it.
  • client.schema.validate() returns the verdict instead of None, and raises ValueError rather than a pydantic ValidationError. ValidationError subclasses ValueError, so except ValueError callers are unaffected; a caller catching ValidationError specifically needs updating.
  • validate_schema_content_and_exit() no longer takes client — it never needed a server.
  • infrahubctl validate schema no longer builds a client, so it works with no server configured. It reports warnings and errors; infrahubctl schema load / schema check report errors locally and leave warnings to the server response, which already carries them, to avoid printing each one twice.

Two shapes that previously passed and now fail

Both were silently dropped before, so the loaded schema differed from the authored one:

  1. a misspelled field name;
  2. attribute parameters belonging to a different attribute kind — for example start_range on a Number attribute, which configured nothing.

Testing

  • tests/unit/test_schema_offline_validation.py restructured: read-only cases assert the exact set of warning paths, unknown-field cases assert the exact set of error paths. Covers every nesting level (root, node, generic, attribute, relationship, parameters, choices, computed_attribute, extensions.nodes[*].attributes), the value-model bookkeeping fields, the sibling-variant field, and the reporting-order consequence above.
  • Full unit suite: 1735 passed. Three pre-existing failures in test_repository_app.py / test_task_app.py (rich table width mismatches) fail identically on a clean tree.
  • invoke format and invoke lint-code (ruff, ty, mypy) clean.

Companion PR

contract.py is generated by invoke backend.generate in the Infrahub repo. The Infrahub side, which generates it and consumes the warnings on POST /api/schema/load, is opsmill/infrahub#10095.

Note this branch is based on the commit the Infrahub release-1.11 submodule currently pins, so it sits behind infrahub-develop; the changes to ctl/schema.py on develop are additive, in a different part of the file.

🤖 Generated with Claude Code

The write models set extra="ignore", so a field the user may not set never
reaches the server. That decided the field has no effect but left the author
with no feedback, so a misspelled key produced a schema quietly different from
the one they wrote.

Classify every extra key instead. A name the contract knows at that location
but the user may not set is reported as a warning and still dropped, so a
schema read back from Infrahub keeps loading; any other name is an error.

The split is driven by a new generated artifact, schema/generated/contract.py,
holding the non-settable field names of each write class. Applying it needs to
know which model governs each place in the payload, so _collect_extra_fields
walks the raw payload alongside the validated write document: the document
resolves the model at every location, including which member of a discriminated
union an attribute matched. One consequence is that extra fields surface only
once the payload is otherwise valid.

validate_schema() now returns warnings alongside errors, and client.schema.validate()
reaches the same verdict -- raising ValueError rather than a pydantic
ValidationError, and returning the verdict when the payload is accepted.
infrahubctl validate schema reports both offline; schema load/check report errors
locally and leave the warnings to the server response, which already carries them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@dgarros
dgarros requested a review from a team as a code owner July 31, 2026 06:28
@cloudflare-workers-and-pages

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

Copy link
Copy Markdown

Deploying infrahub-sdk-python with  Cloudflare Pages  Cloudflare Pages

Latest commit: 758065b
Status: ✅  Deploy successful!
Preview URL: https://b1fd59ae.infrahub-sdk-python.pages.dev
Branch Preview URL: https://dg-schema-load-extra-field-f.infrahub-sdk-python.pages.dev

View logs

@codecov

codecov Bot commented Jul 31, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 89.65517% with 9 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
infrahub_sdk/ctl/schema.py 68.75% 5 Missing ⚠️
infrahub_sdk/ctl/validate.py 81.81% 1 Missing and 1 partial ⚠️
infrahub_sdk/schema/__init__.py 66.66% 1 Missing ⚠️
infrahub_sdk/schema/validate.py 98.14% 1 Missing ⚠️
@@                 Coverage Diff                  @@
##           infrahub-develop    #1223      +/-   ##
====================================================
+ Coverage             83.69%   84.01%   +0.32%     
====================================================
  Files                   144      147       +3     
  Lines                 12754    13067     +313     
  Branches               1859     1931      +72     
====================================================
+ Hits                  10674    10978     +304     
- Misses                 1516     1522       +6     
- Partials                564      567       +3     
Flag Coverage Δ
integration-tests 38.95% <16.09%> (-0.47%) ⬇️
python-3.10 56.85% <66.66%> (+0.89%) ⬆️
python-3.11 56.85% <66.66%> (+0.89%) ⬆️
python-3.12 56.86% <66.66%> (+0.89%) ⬆️
python-3.13 56.86% <66.66%> (+0.89%) ⬆️
python-3.14 56.85% <66.66%> (+0.89%) ⬆️
python-filler-3.12 23.60% <22.98%> (-0.46%) ⬇️

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

Files with missing lines Coverage Δ
infrahub_sdk/schema/generated/__init__.py 100.00% <100.00%> (ø)
infrahub_sdk/schema/generated/contract.py 100.00% <100.00%> (ø)
infrahub_sdk/schema/__init__.py 73.50% <66.66%> (-0.26%) ⬇️
infrahub_sdk/schema/validate.py 96.84% <98.14%> (+1.49%) ⬆️
infrahub_sdk/ctl/validate.py 49.29% <81.81%> (-2.82%) ⬇️
infrahub_sdk/ctl/schema.py 72.51% <68.75%> (+7.55%) ⬆️

... and 2 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.

1 issue found across 8 files

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/validate.py">

<violation number="1" location="infrahub_sdk/schema/validate.py:178">
P2: Valid nested payloads supplied through the Python API as tuples are accepted by Pydantic's `list[...]` fields, but this walk only descends when the raw value is literally a `list`. As a result, unknown or read-only keys inside tuple-backed `nodes`, `attributes`, `relationships`, or `generics` items are silently dropped and never reported, defeating the new extra-field check for otherwise valid payloads. Accept the sequence forms that the write model accepts (at minimum tuples) before walking the items.</violation>
</file>

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

Re-trigger cubic

continue
raw, value = payload[name], getattr(instance, name)
child_path = f"{path}.{name}" if path else name
if isinstance(value, list) and isinstance(raw, 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.

P2: Valid nested payloads supplied through the Python API as tuples are accepted by Pydantic's list[...] fields, but this walk only descends when the raw value is literally a list. As a result, unknown or read-only keys inside tuple-backed nodes, attributes, relationships, or generics items are silently dropped and never reported, defeating the new extra-field check for otherwise valid payloads. Accept the sequence forms that the write model accepts (at minimum tuples) before walking the items.

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

<comment>Valid nested payloads supplied through the Python API as tuples are accepted by Pydantic's `list[...]` fields, but this walk only descends when the raw value is literally a `list`. As a result, unknown or read-only keys inside tuple-backed `nodes`, `attributes`, `relationships`, or `generics` items are silently dropped and never reported, defeating the new extra-field check for otherwise valid payloads. Accept the sequence forms that the write model accepts (at minimum tuples) before walking the items.</comment>

<file context>
@@ -79,6 +108,99 @@ def _collect_validation_errors(
+            continue
+        raw, value = payload[name], getattr(instance, name)
+        child_path = f"{path}.{name}" if path else name
+        if isinstance(value, list) and isinstance(raw, list):
+            for index, (raw_item, item) in enumerate(zip(raw, value, strict=False)):
+                if not isinstance(item, BaseModel) or not isinstance(raw_item, dict):
</file context>
Suggested change
if isinstance(value, list) and isinstance(raw, list):
if isinstance(value, list) and isinstance(raw, (list, tuple)):

A finding carried the bare key, so `parameters.id` was reported as `id`
against the owning attribute -- claiming a field is read-only that is in fact
settable there -- and collided with an `id` reported from another block when
consumers group findings by name.

Qualify the name with the fields walked since the last kind or element, which
re-anchor the identity a finding is reported against. `inherited` on an
attribute is unchanged; `parameters.id` and `extensions.id` now say so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@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.

one idea for a different approach while I review the tests and the other PR. there's also a cubic comment that I'm not really sure about

console.print(f"[red]Unable to find {schema}")
raise typer.Exit(1)

client = initialize_client()

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.

nice to see the client removed here

"""
model = type(instance)
fields = model.model_fields
read_only = _read_only_fields(model)

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 would think that model.model_fields would include all fields defined on the model including those inherited, in which case passing fields into _read_only_fields would let you skip the __mro__ loop. but I could definitely be missing something

except PydanticValidationError as exc:
_collect_validation_errors(exc=exc, errors=errors)
else:
_collect_extra_fields(payload=schema, instance=validated, errors=errors, warnings=warnings)

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 looks like it works, but it is hard to follow and uses some patterns that aren't quite code smells, but are a little suspicious

  • __mro__ access
  • a lot of isinstance, getattr, and .get() that are brittle

I have an idea for another approach that might be worth. I haven't tested it and could definitely be wrong about it.

  1. first deserialize the request into the Read model, capturing any errors. I think this would cover the removed field and typo cases
  2. transfer the Read instance to the corresponding Write model, capturing anything dropped/rejected as warnings

again, not sure if this would work, but it would be nice to let Pydantic do this work for us

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants