Skip to content

Commit e973864

Browse files
bokelleyclaude
andauthored
feat(types): auto-enforce publisher-selector XOR at Pydantic parse time (closes #759) (#761)
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>
1 parent 969f373 commit e973864

3 files changed

Lines changed: 264 additions & 20 deletions

File tree

src/adcp/types/aliases.py

Lines changed: 129 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1480,15 +1480,41 @@ def get_pricing(options: list[PricingOption]) -> None:
14801480
# _forward_compat.py patches Format.assets and Assets94.assets with these
14811481
# types at import time using model_rebuild(force=True).
14821482

1483-
_KNOWN_INDIVIDUAL_ASSET_TYPES: frozenset[str] = frozenset({
1484-
"image", "video", "audio", "text", "markdown", "html", "css",
1485-
"javascript", "vast", "daast", "url", "webhook", "brief", "catalog",
1486-
})
1483+
_KNOWN_INDIVIDUAL_ASSET_TYPES: frozenset[str] = frozenset(
1484+
{
1485+
"image",
1486+
"video",
1487+
"audio",
1488+
"text",
1489+
"markdown",
1490+
"html",
1491+
"css",
1492+
"javascript",
1493+
"vast",
1494+
"daast",
1495+
"url",
1496+
"webhook",
1497+
"brief",
1498+
"catalog",
1499+
}
1500+
)
14871501

1488-
_KNOWN_GROUP_ASSET_TYPES: frozenset[str] = frozenset({
1489-
"image", "video", "audio", "text", "markdown", "html", "css",
1490-
"javascript", "vast", "daast", "url", "webhook",
1491-
})
1502+
_KNOWN_GROUP_ASSET_TYPES: frozenset[str] = frozenset(
1503+
{
1504+
"image",
1505+
"video",
1506+
"audio",
1507+
"text",
1508+
"markdown",
1509+
"html",
1510+
"css",
1511+
"javascript",
1512+
"vast",
1513+
"daast",
1514+
"url",
1515+
"webhook",
1516+
}
1517+
)
14921518

14931519

14941520
def _format_asset_discriminator(v: Any) -> str:
@@ -1791,3 +1817,98 @@ class UnknownGroupAsset(_BaseGroupAsset):
17911817
"UrlFormatGroupAsset",
17921818
"WebhookFormatGroupAsset",
17931819
]
1820+
1821+
1822+
# === Post-hoc XOR enforcement on PublisherPropertySelector{1,3} ===
1823+
#
1824+
# datamodel-code-generator cannot translate the publisher-property-selector
1825+
# JSON Schema's `allOf[not[required[both]]] + anyOf[required[either]]`
1826+
# construct into Pydantic field constraints (adcp#4504, tracked as
1827+
# adcp-client-python#759). Without this patch, direct instantiation of
1828+
# the generated selector classes silently accepts payloads the schema
1829+
# rejects:
1830+
#
1831+
# PublisherPropertySelector1(selection_type="all") # would pass — bug
1832+
# PublisherPropertySelector3(publisher_domain="a", publisher_domains=["b"]) # would pass — bug
1833+
#
1834+
# This block attaches an `@model_validator(mode="after")` to the
1835+
# generated classes at import time. Implementation note: the supported
1836+
# Pydantic-2 API for adding a validator post-hoc to an existing class
1837+
# does not exist; we use `pydantic._internal._decorators.Decorator` —
1838+
# private but stable across Pydantic 2.x point releases. A drift test
1839+
# (``tests/test_publisher_selector_xor_autoenforce.py``) fails loudly if
1840+
# Pydantic ever changes the registration shape so the issue surfaces in
1841+
# CI rather than as runtime validation regressions.
1842+
#
1843+
# Scope:
1844+
# - PublisherPropertySelector1 (selection_type="all") — both XORs apply
1845+
# - PublisherPropertySelector3 (selection_type="by_tag") — both XORs apply
1846+
# - PublisherPropertySelector2 (selection_type="by_id") — no XOR (by_id
1847+
# carries only publisher_domain by spec; publisher_domains is rejected
1848+
# at the JSON-schema level). Left unpatched.
1849+
from pydantic._internal._decorators import ( # noqa: E402
1850+
Decorator as _PydanticDecorator,
1851+
)
1852+
from pydantic._internal._decorators import ( # noqa: E402
1853+
ModelValidatorDecoratorInfo as _ModelValidatorDecoratorInfo,
1854+
)
1855+
1856+
from adcp.types._generated import ( # noqa: E402
1857+
PublisherPropertySelector1 as _Selector1,
1858+
)
1859+
from adcp.types._generated import (
1860+
PublisherPropertySelector3 as _Selector3,
1861+
)
1862+
1863+
1864+
def _selector_xor_validate(self: Any) -> Any:
1865+
"""Enforce publisher_domain XOR publisher_domains[] on selector 1 / 3.
1866+
1867+
Runs after Pydantic has populated the fields. Defers the full
1868+
diagnostic shape to `validate_publisher_properties_item` for parity
1869+
with the dict-path enforcement; a violation surfaces here as a
1870+
Pydantic `ValidationError` (containing the helper's message) rather
1871+
than as the helper's `ValidationError` directly.
1872+
"""
1873+
# Local import — avoids a top-level cycle through adcp.validation
1874+
# back into types.aliases.
1875+
from adcp.validation.legacy import (
1876+
ValidationError as _LegacyValidationError,
1877+
)
1878+
from adcp.validation.legacy import (
1879+
validate_publisher_properties_item as _validate_item,
1880+
)
1881+
1882+
try:
1883+
_validate_item(self)
1884+
except _LegacyValidationError as exc:
1885+
raise ValueError(str(exc)) from exc
1886+
return self
1887+
1888+
1889+
def _attach_selector_xor_validator(cls: type) -> None:
1890+
"""Inject a model_validator(mode='after') onto an existing Pydantic class.
1891+
1892+
The supported decorator path is class-definition-time only; the
1893+
generated selector classes can't carry the validator without
1894+
modifying generated code (forbidden — overwritten on next regen).
1895+
This walks the same `_internal._decorators` machinery the decorator
1896+
syntax uses, then forces a `model_rebuild` so Pydantic re-derives
1897+
its core schema with the new validator included.
1898+
"""
1899+
cls._selector_xor_validate = _selector_xor_validate # type: ignore[attr-defined]
1900+
info = _ModelValidatorDecoratorInfo(mode="after")
1901+
decorator = _PydanticDecorator.build(
1902+
cls,
1903+
cls_var_name="_selector_xor_validate",
1904+
shim=None,
1905+
info=info,
1906+
)
1907+
cls.__pydantic_decorators__.model_validators[ # type: ignore[attr-defined]
1908+
"_selector_xor_validate"
1909+
] = decorator
1910+
cls.model_rebuild(force=True) # type: ignore[attr-defined]
1911+
1912+
1913+
_attach_selector_xor_validator(_Selector1)
1914+
_attach_selector_xor_validator(_Selector3)

tests/test_adagents.py

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2946,11 +2946,11 @@ def test_resolve_compact_form_via_get_properties_by_agent(self):
29462946
assert all(s["property_tags"] == ["ctv"] for s in resolved)
29472947

29482948
def test_validate_accepts_pydantic_model_instance(self):
2949-
# datamodel-codegen can't emit allOf[not[required[both]]] +
2950-
# anyOf[required[either]] as Pydantic field constraints, so the
2951-
# typed surface accepts {} or {selection_type: "all"} with no
2952-
# publisher_domain* set. Pydantic consumers can close that gap by
2953-
# calling this helper on the parsed selector.
2949+
# With issue #759 (auto-enforce XOR via post-hoc model_validator),
2950+
# direct construction of an XOR-violating selector raises at the
2951+
# Pydantic layer. The dict path still goes through the helper.
2952+
from pydantic import ValidationError as _PydValidationError
2953+
29542954
from adcp.types.generated_poc.core.publisher_property_selector import (
29552955
PublisherPropertySelector1,
29562956
)
@@ -2959,17 +2959,15 @@ def test_validate_accepts_pydantic_model_instance(self):
29592959
validate_publisher_properties_item,
29602960
)
29612961

2962-
# Pydantic-instantiated without a publisher_domain — silently
2963-
# legal at the type layer, but the runtime check raises.
2964-
bad = PublisherPropertySelector1(selection_type="all")
2965-
with pytest.raises(ValidationError, match="exactly one"):
2966-
validate_publisher_properties_item(bad)
2962+
# Pydantic construction now rejects the bare form directly.
2963+
with pytest.raises(_PydValidationError, match="exactly one"):
2964+
PublisherPropertySelector1(selection_type="all")
29672965

2968-
# Same shape via dict for parity.
2966+
# Dict path still uses the helper.
29692967
with pytest.raises(ValidationError, match="exactly one"):
29702968
validate_publisher_properties_item({"selection_type": "all"})
29712969

2972-
# Valid Pydantic instance passes.
2970+
# Valid Pydantic instance passes both layers.
29732971
good = PublisherPropertySelector1(selection_type="all", publisher_domain="cnn.com")
29742972
validate_publisher_properties_item(good)
29752973

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
"""Auto-enforcement of the PublisherPropertySelector XOR constraint at Pydantic parse time.
2+
3+
Closes adcp-client-python#759. The patch in ``adcp.types.aliases`` uses
4+
``pydantic._internal._decorators`` (private API but stable across
5+
Pydantic 2.x point releases) to attach a ``model_validator(mode='after')``
6+
to the generated selector classes. The first three tests verify the
7+
behavior; the last test is a **drift sentinel** that fails loudly if
8+
Pydantic's internal decorator registration ever changes shape so the
9+
issue surfaces in CI rather than as silent validation regressions.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import pytest
15+
from pydantic import ValidationError
16+
17+
18+
class TestSelectorXorAutoEnforce:
19+
"""Direct construction of an XOR-violating selector must fail."""
20+
21+
def test_selector1_rejects_bare_construct(self):
22+
# selection_type='all' with neither publisher_domain nor publisher_domains
23+
from adcp.types.generated_poc.core.publisher_property_selector import (
24+
PublisherPropertySelector1,
25+
)
26+
27+
with pytest.raises(ValidationError, match="exactly one"):
28+
PublisherPropertySelector1(selection_type="all")
29+
30+
def test_selector1_rejects_both_publisher_fields(self):
31+
from adcp.types.generated_poc.core.publisher_property_selector import (
32+
PublisherPropertySelector1,
33+
)
34+
35+
with pytest.raises(ValidationError, match="mutually exclusive"):
36+
PublisherPropertySelector1(
37+
selection_type="all",
38+
publisher_domain="cnn.com",
39+
publisher_domains=["espn.com"],
40+
)
41+
42+
def test_selector1_accepts_singular_form(self):
43+
from adcp.types.generated_poc.core.publisher_property_selector import (
44+
PublisherPropertySelector1,
45+
)
46+
47+
s = PublisherPropertySelector1(selection_type="all", publisher_domain="cnn.com")
48+
assert s.publisher_domain == "cnn.com"
49+
50+
def test_selector1_accepts_compact_form(self):
51+
from adcp.types.generated_poc.core.publisher_property_selector import (
52+
PublisherPropertySelector1,
53+
)
54+
55+
s = PublisherPropertySelector1(
56+
selection_type="all", publisher_domains=["a.example", "b.example"]
57+
)
58+
assert s.publisher_domains is not None
59+
assert [str(d.root) for d in s.publisher_domains] == ["a.example", "b.example"]
60+
61+
def test_selector3_rejects_bare_construct(self):
62+
from adcp.types.generated_poc.core.publisher_property_selector import (
63+
PublisherPropertySelector3,
64+
)
65+
66+
with pytest.raises(ValidationError, match="exactly one"):
67+
PublisherPropertySelector3(selection_type="by_tag", property_tags=["ctv"])
68+
69+
def test_selector3_accepts_compact_form_with_required_tags(self):
70+
from adcp.types.generated_poc.core.publisher_property_selector import (
71+
PublisherPropertySelector3,
72+
)
73+
74+
s = PublisherPropertySelector3(
75+
selection_type="by_tag",
76+
property_tags=["ctv"],
77+
publisher_domains=["a.example", "b.example"],
78+
)
79+
assert s.publisher_domains is not None
80+
assert [str(d.root) for d in s.publisher_domains] == ["a.example", "b.example"]
81+
82+
def test_selector2_unpatched_passes_valid_input(self):
83+
# by_id selector has no XOR — only publisher_domain is allowed,
84+
# publisher_domains is rejected at the JSON-schema level. The
85+
# auto-enforce patch correctly leaves this class alone.
86+
from adcp.types.generated_poc.core.publisher_property_selector import (
87+
PublisherPropertySelector2,
88+
)
89+
90+
s = PublisherPropertySelector2(
91+
selection_type="by_id",
92+
property_ids=["p1"],
93+
publisher_domain="cnn.com",
94+
)
95+
assert s.publisher_domain == "cnn.com"
96+
97+
98+
class TestPydanticInternalApiDriftSentinel:
99+
"""If Pydantic ever changes the shape of ``_internal._decorators``
100+
the patch in ``adcp.types.aliases`` silently no-ops and selector
101+
validation regresses. This test imports the same private surface
102+
the patch uses and verifies the API still exists. CI failure here
103+
is the canary for "rework the patch or pin Pydantic".
104+
"""
105+
106+
def test_decorator_class_present(self):
107+
from pydantic._internal._decorators import Decorator # noqa: F401
108+
109+
def test_model_validator_decorator_info_present(self):
110+
from pydantic._internal._decorators import ( # noqa: F401
111+
ModelValidatorDecoratorInfo,
112+
)
113+
114+
def test_selector1_has_registered_validator(self):
115+
# The patch lands at module-import time. If the registration
116+
# shape changes and the patch silently no-ops, this catches it.
117+
from adcp.types.generated_poc.core.publisher_property_selector import (
118+
PublisherPropertySelector1,
119+
)
120+
121+
validators = PublisherPropertySelector1.__pydantic_decorators__.model_validators
122+
assert "_selector_xor_validate" in validators, (
123+
"XOR auto-enforce validator missing from PublisherPropertySelector1 — "
124+
"patch in adcp.types.aliases may have silently failed."
125+
)

0 commit comments

Comments
 (0)