feat: report the schema fields the write contract does not apply - #1223
feat: report the schema fields the write contract does not apply#1223dgarros wants to merge 2 commits into
Conversation
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>
Deploying infrahub-sdk-python with
|
| 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 |
Codecov Report❌ Patch coverage is @@ 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
Flags with carried forward coverage won't be shown. Click here to find out more.
... and 2 files with indirect coverage changes 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
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): |
There was a problem hiding this comment.
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>
| 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
left a comment
There was a problem hiding this comment.
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() |
There was a problem hiding this comment.
nice to see the client removed here
| """ | ||
| model = type(instance) | ||
| fields = model.model_fields | ||
| read_only = _read_only_fields(model) |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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.
- first deserialize the request into the
Readmodel, capturing any errors. I think this would cover the removed field and typo cases - transfer the
Readinstance to the correspondingWritemodel, 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
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:
Implementation notes
A generated table, not a third model family.
infrahub_sdk/schema/generated/contract.pyholds 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 theid/statebookkeeping 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_fieldswalks 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
SchemaValidationResultgainswarnings(list[SchemaValidationWarningDetail]) andwarning_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 ofNone, and raisesValueErrorrather than a pydanticValidationError.ValidationErrorsubclassesValueError, soexcept ValueErrorcallers are unaffected; a caller catchingValidationErrorspecifically needs updating.validate_schema_content_and_exit()no longer takesclient— it never needed a server.infrahubctl validate schemano longer builds a client, so it works with no server configured. It reports warnings and errors;infrahubctl schema load/schema checkreport 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:
parametersbelonging to a different attributekind— for examplestart_rangeon aNumberattribute, which configured nothing.Testing
tests/unit/test_schema_offline_validation.pyrestructured: 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.test_repository_app.py/test_task_app.py(rich table width mismatches) fail identically on a clean tree.invoke formatandinvoke lint-code(ruff, ty, mypy) clean.Companion PR
contract.pyis generated byinvoke backend.generatein the Infrahub repo. The Infrahub side, which generates it and consumes the warnings onPOST /api/schema/load, is opsmill/infrahub#10095.Note this branch is based on the commit the Infrahub
release-1.11submodule currently pins, so it sits behindinfrahub-develop; the changes toctl/schema.pyon develop are additive, in a different part of the file.🤖 Generated with Claude Code