feat(types): auto-enforce publisher-selector XOR at Pydantic parse time (closes #759)#761
Merged
Merged
Conversation
…me (closes #759) Closes the auto-enforcement half of the publisher-property-selector XOR gap. datamodel-code-generator cannot translate the JSON Schema's `allOf[not[required[both]]] + anyOf[required[either]]` construct into Pydantic field constraints; this lands a `model_validator(mode='after')` on the generated `PublisherPropertySelector1` and `…3` at import time so that direct construction now fails: PublisherPropertySelector1(selection_type="all") # raises ValidationError: must have exactly one of publisher_domain # or publisher_domains Implementation lives in `src/adcp/types/aliases.py` — the layering rule permits this module to touch generated types, and the patch happens once at module import. The mechanism uses `pydantic._internal._decorators.Decorator` + `ModelValidatorDecoratorInfo` to inject the validator into the existing class's `__pydantic_decorators__.model_validators` table, then forces `model_rebuild` so Pydantic re-derives its core schema with the new validator included. Caveat: the registration path is Pydantic-private (`_internal`). A drift-sentinel test (`test_publisher_selector_xor_autoenforce.py`) imports the same private surface and asserts the validator is registered post-import; if Pydantic ever changes the shape this fails loudly in CI so we can rework the patch or pin Pydantic. The internal API has been stable across Pydantic 2.x point releases to date. Selector 2 (`by_id`) is left unpatched — its schema only allows `publisher_domain` (`publisher_domains` is rejected at the JSON Schema level), so there's no XOR to enforce. The previous-helper test (`test_validate_accepts_pydantic_model_instance`) is updated to reflect the new ordering: Pydantic construction now rejects bare XOR violations directly; the dict path still uses the helper from #756. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.
Summary
Closes the auto-enforcement half of the publisher-property-selector XOR gap, tracked in #759.
datamodel-code-generatorcannot translate the JSON Schema'sallOf[not[required[both]]] + anyOf[required[either]]construct into Pydantic field constraints. Without this PR, direct construction of the generated selector classes silently accepts payloads the schema rejects:After this PR:
PR #756 (the helper accepting Pydantic instances) was the explicit/opt-in fix; this is the auto-enforce-at-construction-time companion.
Implementation
src/adcp/types/aliases.pyis the layering-allowed home for cross-cutting tweaks to generated types. The patch:PublisherPropertySelector1andPublisherPropertySelector3(the two arms with the XOR; arm 2 isby_idwhich has no XOR)._selector_xor_validate(self)— calls the existingvalidate_publisher_properties_itemhelper (feat(validation): validate_publisher_properties_item accepts Pydantic models #756) onself, surfacing its message as a PydanticValueError.pydantic._internal._decorators.Decorator+ModelValidatorDecoratorInfoto inject the validator into each class's__pydantic_decorators__.model_validatorstable, then callsmodel_rebuild(force=True)so Pydantic re-derives the core schema with the new validator included.The supported Pydantic-2 API for attaching a
model_validatorto an existing class doesn't exist — the decorator path is class-definition-time only. Subclassing wouldn't help because the discriminated union still resolves to the original generated classes. The codegen-patch option (modifyscripts/generate_types.pyto emit the validator) is a 1-2 day project; this PR ships the same end-state via the private-but-stable_internal._decoratorssurface.Drift sentinel
tests/test_publisher_selector_xor_autoenforce.pyincludes aTestPydanticInternalApiDriftSentinelclass that:DecoratorandModelValidatorDecoratorInfodirectly — fails if Pydantic renames or removes them.PublisherPropertySelector1.__pydantic_decorators__.model_validators["_selector_xor_validate"]is registered post-import — fails if the patch silently no-ops.CI failure on either is the canary for "rework the patch or pin Pydantic before upgrading."
Test coverage (7 new + 4 sentinel)
test_selector1_rejects_bare_construct{selection_type: "all"}→ ValidationErrortest_selector1_rejects_both_publisher_fieldstest_selector1_accepts_singular_formtest_selector1_accepts_compact_formtest_selector3_rejects_bare_constructtest_selector3_accepts_compact_form_with_required_tagstest_selector2_unpatched_passes_valid_inputTest plan
ruff check src/ tests/test_adagents.py— passesmypy src/adcp/— 807 source files, no issuespytest tests/test_adagents.py tests/test_publisher_selector_xor_autoenforce.py— 163 passedpytest tests/— 4,834 passedpytest tests/test_import_layering.py— layering rule still satisfied (aliases.py is on the approved list)🤖 Generated with Claude Code