diff --git a/src/adcp/validation/legacy.py b/src/adcp/validation/legacy.py index 0012430dc..baa89b49e 100644 --- a/src/adcp/validation/legacy.py +++ b/src/adcp/validation/legacy.py @@ -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): @@ -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 diff --git a/tests/test_adagents.py b/tests/test_adagents.py index e977c9851..acd325d42 100644 --- a/tests/test_adagents.py +++ b/tests/test_adagents.py @@ -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,