Skip to content

feat(types): auto-enforce publisher-selector XOR at Pydantic parse time (closes #759)#761

Merged
bokelley merged 1 commit into
mainfrom
bokelley/selector-xor-pydantic-autoenforce
May 20, 2026
Merged

feat(types): auto-enforce publisher-selector XOR at Pydantic parse time (closes #759)#761
bokelley merged 1 commit into
mainfrom
bokelley/selector-xor-pydantic-autoenforce

Conversation

@bokelley
Copy link
Copy Markdown
Contributor

Summary

Closes the auto-enforcement half of the publisher-property-selector XOR gap, tracked in #759.

datamodel-code-generator cannot translate the JSON Schema's allOf[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:

PublisherPropertySelector1(selection_type="all")
# Returns a "valid" instance — but neither publisher_domain nor
# publisher_domains is set, which the JSON Schema rejects.

After this PR:

PublisherPropertySelector1(selection_type="all")
# Raises ValidationError: publisher_properties item must have
# exactly one of publisher_domain or publisher_domains.

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.py is the layering-allowed home for cross-cutting tweaks to generated types. The patch:

  1. Imports PublisherPropertySelector1 and PublisherPropertySelector3 (the two arms with the XOR; arm 2 is by_id which has no XOR).
  2. Defines _selector_xor_validate(self) — calls the existing validate_publisher_properties_item helper (feat(validation): validate_publisher_properties_item accepts Pydantic models #756) on self, surfacing its message as a Pydantic ValueError.
  3. Uses pydantic._internal._decorators.Decorator + ModelValidatorDecoratorInfo to inject the validator into each class's __pydantic_decorators__.model_validators table, then calls model_rebuild(force=True) so Pydantic re-derives the core schema with the new validator included.

The supported Pydantic-2 API for attaching a model_validator to 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 (modify scripts/generate_types.py to emit the validator) is a 1-2 day project; this PR ships the same end-state via the private-but-stable _internal._decorators surface.

Drift sentinel

tests/test_publisher_selector_xor_autoenforce.py includes a TestPydanticInternalApiDriftSentinel class that:

  • Imports Decorator and ModelValidatorDecoratorInfo directly — fails if Pydantic renames or removes them.
  • Asserts 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 Asserts
test_selector1_rejects_bare_construct {selection_type: "all"} → ValidationError
test_selector1_rejects_both_publisher_fields both set → ValidationError "mutually exclusive"
test_selector1_accepts_singular_form valid singular passes
test_selector1_accepts_compact_form valid compact form passes
test_selector3_rejects_bare_construct by_tag bare → ValidationError
test_selector3_accepts_compact_form_with_required_tags by_tag + tags + domains passes
test_selector2_unpatched_passes_valid_input by_id selector unchanged (no XOR)
Drift sentinel × 3 private-API entry points still exist

Test plan

  • ruff check src/ tests/test_adagents.py — passes
  • mypy src/adcp/ — 807 source files, no issues
  • pytest tests/test_adagents.py tests/test_publisher_selector_xor_autoenforce.py — 163 passed
  • pytest tests/ — 4,834 passed
  • pytest tests/test_import_layering.py — layering rule still satisfied (aliases.py is on the approved list)
  • CI green across Python 3.10–3.13

🤖 Generated with Claude Code

…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>
@bokelley bokelley merged commit e973864 into main May 20, 2026
16 checks passed
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.

1 participant