Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions src/adcp/validation/legacy.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,14 @@ class ValidationError(ValueError):
pass


def validate_publisher_properties_item(item: dict[str, Any]) -> None:
def validate_publisher_properties_item(item: Any) -> None:
"""Validate a single ``publisher_properties[]`` entry.

Accepts either a raw ``dict`` (the wire form) or a parsed Pydantic
model instance (``PublisherPropertySelector1`` / ``…2`` / ``…3``).
For Pydantic instances the model is coerced via
``.model_dump(exclude_none=False)`` and the same checks apply.

Two XORs are enforced per the publisher-property-selector JSON Schema
(adcp#4504):

Expand All @@ -42,12 +47,28 @@ def validate_publisher_properties_item(item: dict[str, Any]) -> None:
callers wanting per-publisher ID sets must use one entry per
publisher.

Why the Pydantic input form matters: ``datamodel-code-generator``
cannot translate the JSON Schema's
``allOf[not[required[both]]] + anyOf[required[either]]`` construct
into Pydantic field constraints, so the typed surface (selector 1/3
direct instantiation) is laxer than the schema. Consumers parsing
via Pydantic should call this helper post-construction to close the
gap.

Args:
item: A single item from publisher_properties array
item: A single item from publisher_properties array — either a
``dict`` or a Pydantic ``BaseModel`` instance.

Raises:
ValidationError: If discriminator or field constraints are violated
"""
if hasattr(item, "model_dump"):
item = item.model_dump(exclude_none=False)
if not isinstance(item, dict):
raise ValidationError(
"publisher_properties item must be a dict or a Pydantic model "
f"instance, got {type(item).__name__}"
)
selection_type = item.get("selection_type")
has_property_ids = "property_ids" in item and item["property_ids"] is not None
has_property_tags = "property_tags" in item and item["property_tags"] is not None
Expand Down
37 changes: 37 additions & 0 deletions tests/test_adagents.py
Original file line number Diff line number Diff line change
Expand Up @@ -2842,6 +2842,43 @@ def test_resolve_compact_form_via_get_properties_by_agent(self):
assert all(s["selection_type"] == "by_tag" for s in resolved)
assert all(s["property_tags"] == ["ctv"] for s in resolved)

def test_validate_accepts_pydantic_model_instance(self):
# datamodel-codegen can't emit allOf[not[required[both]]] +
# anyOf[required[either]] as Pydantic field constraints, so the
# typed surface accepts {} or {selection_type: "all"} with no
# publisher_domain* set. Pydantic consumers can close that gap by
# calling this helper on the parsed selector.
from adcp.types.generated_poc.core.publisher_property_selector import (
PublisherPropertySelector1,
)
from adcp.validation import (
ValidationError,
validate_publisher_properties_item,
)

# Pydantic-instantiated without a publisher_domain — silently
# legal at the type layer, but the runtime check raises.
bad = PublisherPropertySelector1(selection_type="all")
with pytest.raises(ValidationError, match="exactly one"):
validate_publisher_properties_item(bad)

# Same shape via dict for parity.
with pytest.raises(ValidationError, match="exactly one"):
validate_publisher_properties_item({"selection_type": "all"})

# Valid Pydantic instance passes.
good = PublisherPropertySelector1(selection_type="all", publisher_domain="cnn.com")
validate_publisher_properties_item(good)

def test_validate_rejects_non_dict_non_model(self):
from adcp.validation import (
ValidationError,
validate_publisher_properties_item,
)

with pytest.raises(ValidationError, match="dict or a Pydantic model"):
validate_publisher_properties_item("not-an-object")

def test_xor_violation_both_publisher_fields(self):
from adcp.validation import (
ValidationError,
Expand Down
Loading