fix: enforce propertyNames on extra-allow generated models - #66
Open
vishkaty wants to merge 1 commit into
Open
Conversation
signals.json declares propertyNames (reverse-domain keys) alongside named
properties and additionalProperties:true. datamodel-code-generator emits this
as class Signals(BaseModel) with model_config=ConfigDict(extra="allow") and the
two named fields, so unknown (extra) keys are never checked against the key
pattern.
Observed: Signals(**{"dev.ucp.buyer_ip": "1.2.3.4", "bogus KEY!": "x"}) is
accepted and "bogus KEY!" is kept in model_extra.
Expected: the malformed key is rejected, because signals.json requires every
property name to match the reverse-domain pattern
^[a-z][a-z0-9]*(?:\.[a-z][a-z0-9_]*)+$ (signals.json propertyNames.pattern, the
same pattern as reverse_domain_name.json). Well-formed reverse-domain extras
(e.g. com.example.device_id) must still be preserved under extra="allow".
The already-enforced sibling case is the dict-keyed maps (e.g. ucp.json
capabilities/services/payment_handlers/supported_versions), emitted as
dict[ReverseDomainName, V] where pydantic validates the keys. The gap is only
the extra-allow BaseModel shape: propertyNames declared on an object that also
has named properties.
This extends the post-generation model_validator approach added for
minProperties (Universal-Commerce-Protocol#49-class): postprocess_models.py scans the preprocessed schemas
for objects that declare propertyNames AND carry named properties, reads the key
pattern from the source schema (inline pattern or a $ref to e.g.
reverse_domain_name.json, never duplicated in code), and injects a
model_validator(mode="after") that matches every model_extra key against the
pattern with re.fullmatch. fullmatch (not re.match) is used so a $-anchored
pattern does not admit a trailing newline (re.match lets $ match before a final
\n); this agrees with pydantic-core / ECMA-262 (JSON Schema's regex dialect)
key semantics, the same behavior the dict-keyed map path already applies. The
check reaches the base model and its generated request variants (Signals,
SignalsCreateRequest, SignalsUpdateRequest, SignalsCompleteRequest).
Out of scope: identity_linking.json scopes map degrades to Any due to an
allOf/$ref resolution problem, so no propertyNames-bearing model is emitted for
it (a separate defect); and the dict-keyed maps above already enforce their key
pattern.
Regenerated with ./generate_models.sh 2026-04-08; the diff is limited to the
propertyNames enforcement on the four Signals models and regeneration is
byte-identical.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
propertyNamesis not enforced on the generated models where it maps to anextra="allow"object with named fields.Observed (on current
main, committed models):Expected: the malformed key is rejected.
signals.jsonrequires everyproperty name to match the reverse-domain pattern
^[a-z][a-z0-9]*(?:\.[a-z][a-z0-9_]*)+$(signals.jsonpropertyNames.pattern,the same pattern as
shopping/types/reverse_domain_name.json). Well-formedreverse-domain extras (e.g.
com.example.device_id) must still be preserved,since the schema sets
additionalProperties: true.Why it happens
signals.jsondeclarespropertyNamesalongside namedpropertiesandadditionalProperties: true, sodatamodel-code-generatoremits it as aBaseModelwithmodel_config=ConfigDict(extra="allow")and the two namedfields. Extra keys are allowed but never checked against the key pattern.
By contrast, an object with
propertyNamesbut no named properties (e.g.ucp.jsoncapabilities/services/payment_handlers/supported_versions) is emitted asdict[ReverseDomainName, V], wherepydantic already validates the keys. This PR closes only the
extra="allow"BaseModel gap.
Fix (source-driven, in the generation pipeline)
postprocess_models.pyscans the preprocessed schemas for objects that declarepropertyNamesand carry namedproperties, reads the key pattern from thesource schema (inline
pattern, or a$refto e.g.reverse_domain_name.json— never duplicated in code), and injects a
model_validator(mode="after")thatmatches every
model_extrakey against the pattern. This mirrors and extendsthe post-generation
model_validatorapproach already merged forminProperties(the #49 constraint class).
Key matching uses
re.fullmatch, notre.match: with a$-anchored patternre.matchadmits a trailing newline (Signals(**{"com.example.k\n": "x"})would slip through), whereas
re.fullmatchagrees with pydantic-core /ECMA-262 (JSON Schema's regex dialect) key semantics — the exact behavior the
dict[ReverseDomainName, V]path already applies to its keys. The scanner alsowarns if a resolved pattern is not
^/$-anchored, so a future unanchoredpattern is not silently over-restricted.
Scope
The only
propertyNames+ named-propertiesnode in the 2026-04-08 spec isSignals, so the enforcement lands onSignalsand its three generatedrequest variants —
SignalsCreateRequest,SignalsUpdateRequest,SignalsCompleteRequest. Explicitly out of scope:dict-keyedpropertyNamesmaps inucp.json(
capabilities/services/payment_handlers/supported_versions) — alreadyenforced via
dict[ReverseDomainName, V]key validation.identity_linking.jsonconfig.scopes— the surrounding capability degradesto
Anydue to a separateallOf/$refresolution issue, so nopropertyNames-bearing model is emitted for it. That is a different defect andis not addressed here.
Verification
then green after regeneration. Kill-test: removing the injector call turns the
new semantic tests red.
com.example.k\n) rejected;well-formed reverse-domain extra preserved in
model_extra; the named fields(
dev.ucp.buyer_ip,dev.ucp.user_agent) still populate. Enforcement iscovered on all four Signals models.
pip install -e .+python -m unittest discover -s tests -p "test_*.py":all green.
./generate_models.sh 2026-04-08; the diff is limited to thepropertyNamesenforcement on the four Signals models, and regeneration isbyte-identical (the model-drift equality check stays clean).
pre-commit(ruff+ruff-format) clean.Category (Required)