Skip to content

Commit 01c6491

Browse files
bokelleyclaude
andauthored
feat(validation): validate_publisher_properties_item accepts Pydantic models (#756)
Closes the documented Pydantic-XOR gap surfaced by the round-1 protocol review on #729 / PR #753. datamodel-codegen cannot translate the publisher-property-selector JSON Schema's `allOf[not[required[both]]] + anyOf[required[either]]` construct into Pydantic field constraints, so direct instantiation of `PublisherPropertySelector1` / `…3` silently accepts payloads the schema rejects (notably `{selection_type: "all"}` with neither `publisher_domain` nor `publisher_domains` set). This change lets Pydantic consumers close the gap with a single call: selector = PublisherPropertySelector1.model_validate(payload) validate_publisher_properties_item(selector) # raises if XOR fails The helper now accepts either a dict (existing wire-form path) or any object with a `.model_dump()` method (i.e. a Pydantic model). For anything else it raises a clear error. Tests cover both the model and the dict input forms plus the type-error path. Auto-enforcement at parse time (a `model_validator` attached post-class to the generated selectors) would require either hand-patching generated_poc/ — forbidden by the codebase's strict layering rule — or hooking Pydantic core-schema internals, which is fragile. A separate follow-up issue tracks that work. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 76a679f commit 01c6491

2 files changed

Lines changed: 60 additions & 2 deletions

File tree

src/adcp/validation/legacy.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,14 @@ class ValidationError(ValueError):
2727
pass
2828

2929

30-
def validate_publisher_properties_item(item: dict[str, Any]) -> None:
30+
def validate_publisher_properties_item(item: Any) -> None:
3131
"""Validate a single ``publisher_properties[]`` entry.
3232
33+
Accepts either a raw ``dict`` (the wire form) or a parsed Pydantic
34+
model instance (``PublisherPropertySelector1`` / ``…2`` / ``…3``).
35+
For Pydantic instances the model is coerced via
36+
``.model_dump(exclude_none=False)`` and the same checks apply.
37+
3338
Two XORs are enforced per the publisher-property-selector JSON Schema
3439
(adcp#4504):
3540
@@ -42,12 +47,28 @@ def validate_publisher_properties_item(item: dict[str, Any]) -> None:
4247
callers wanting per-publisher ID sets must use one entry per
4348
publisher.
4449
50+
Why the Pydantic input form matters: ``datamodel-code-generator``
51+
cannot translate the JSON Schema's
52+
``allOf[not[required[both]]] + anyOf[required[either]]`` construct
53+
into Pydantic field constraints, so the typed surface (selector 1/3
54+
direct instantiation) is laxer than the schema. Consumers parsing
55+
via Pydantic should call this helper post-construction to close the
56+
gap.
57+
4558
Args:
46-
item: A single item from publisher_properties array
59+
item: A single item from publisher_properties array — either a
60+
``dict`` or a Pydantic ``BaseModel`` instance.
4761
4862
Raises:
4963
ValidationError: If discriminator or field constraints are violated
5064
"""
65+
if hasattr(item, "model_dump"):
66+
item = item.model_dump(exclude_none=False)
67+
if not isinstance(item, dict):
68+
raise ValidationError(
69+
"publisher_properties item must be a dict or a Pydantic model "
70+
f"instance, got {type(item).__name__}"
71+
)
5172
selection_type = item.get("selection_type")
5273
has_property_ids = "property_ids" in item and item["property_ids"] is not None
5374
has_property_tags = "property_tags" in item and item["property_tags"] is not None

tests/test_adagents.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2872,6 +2872,43 @@ def test_resolve_compact_form_via_get_properties_by_agent(self):
28722872
assert all(s["selection_type"] == "by_tag" for s in resolved)
28732873
assert all(s["property_tags"] == ["ctv"] for s in resolved)
28742874

2875+
def test_validate_accepts_pydantic_model_instance(self):
2876+
# datamodel-codegen can't emit allOf[not[required[both]]] +
2877+
# anyOf[required[either]] as Pydantic field constraints, so the
2878+
# typed surface accepts {} or {selection_type: "all"} with no
2879+
# publisher_domain* set. Pydantic consumers can close that gap by
2880+
# calling this helper on the parsed selector.
2881+
from adcp.types.generated_poc.core.publisher_property_selector import (
2882+
PublisherPropertySelector1,
2883+
)
2884+
from adcp.validation import (
2885+
ValidationError,
2886+
validate_publisher_properties_item,
2887+
)
2888+
2889+
# Pydantic-instantiated without a publisher_domain — silently
2890+
# legal at the type layer, but the runtime check raises.
2891+
bad = PublisherPropertySelector1(selection_type="all")
2892+
with pytest.raises(ValidationError, match="exactly one"):
2893+
validate_publisher_properties_item(bad)
2894+
2895+
# Same shape via dict for parity.
2896+
with pytest.raises(ValidationError, match="exactly one"):
2897+
validate_publisher_properties_item({"selection_type": "all"})
2898+
2899+
# Valid Pydantic instance passes.
2900+
good = PublisherPropertySelector1(selection_type="all", publisher_domain="cnn.com")
2901+
validate_publisher_properties_item(good)
2902+
2903+
def test_validate_rejects_non_dict_non_model(self):
2904+
from adcp.validation import (
2905+
ValidationError,
2906+
validate_publisher_properties_item,
2907+
)
2908+
2909+
with pytest.raises(ValidationError, match="dict or a Pydantic model"):
2910+
validate_publisher_properties_item("not-an-object")
2911+
28752912
def test_xor_violation_both_publisher_fields(self):
28762913
from adcp.validation import (
28772914
ValidationError,

0 commit comments

Comments
 (0)