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
137 changes: 129 additions & 8 deletions src/adcp/types/aliases.py
Original file line number Diff line number Diff line change
Expand Up @@ -1480,15 +1480,41 @@ def get_pricing(options: list[PricingOption]) -> None:
# _forward_compat.py patches Format.assets and Assets94.assets with these
# types at import time using model_rebuild(force=True).

_KNOWN_INDIVIDUAL_ASSET_TYPES: frozenset[str] = frozenset({
"image", "video", "audio", "text", "markdown", "html", "css",
"javascript", "vast", "daast", "url", "webhook", "brief", "catalog",
})
_KNOWN_INDIVIDUAL_ASSET_TYPES: frozenset[str] = frozenset(
{
"image",
"video",
"audio",
"text",
"markdown",
"html",
"css",
"javascript",
"vast",
"daast",
"url",
"webhook",
"brief",
"catalog",
}
)

_KNOWN_GROUP_ASSET_TYPES: frozenset[str] = frozenset({
"image", "video", "audio", "text", "markdown", "html", "css",
"javascript", "vast", "daast", "url", "webhook",
})
_KNOWN_GROUP_ASSET_TYPES: frozenset[str] = frozenset(
{
"image",
"video",
"audio",
"text",
"markdown",
"html",
"css",
"javascript",
"vast",
"daast",
"url",
"webhook",
}
)


def _format_asset_discriminator(v: Any) -> str:
Expand Down Expand Up @@ -1791,3 +1817,98 @@ class UnknownGroupAsset(_BaseGroupAsset):
"UrlFormatGroupAsset",
"WebhookFormatGroupAsset",
]


# === Post-hoc XOR enforcement on PublisherPropertySelector{1,3} ===
#
# datamodel-code-generator cannot translate the publisher-property-selector
# JSON Schema's `allOf[not[required[both]]] + anyOf[required[either]]`
# construct into Pydantic field constraints (adcp#4504, tracked as
# adcp-client-python#759). Without this patch, direct instantiation of
# the generated selector classes silently accepts payloads the schema
# rejects:
#
# PublisherPropertySelector1(selection_type="all") # would pass — bug
# PublisherPropertySelector3(publisher_domain="a", publisher_domains=["b"]) # would pass — bug
#
# This block attaches an `@model_validator(mode="after")` to the
# generated classes at import time. Implementation note: the supported
# Pydantic-2 API for adding a validator post-hoc to an existing class
# does not exist; we use `pydantic._internal._decorators.Decorator` —
# private but stable across Pydantic 2.x point releases. A drift test
# (``tests/test_publisher_selector_xor_autoenforce.py``) fails loudly if
# Pydantic ever changes the registration shape so the issue surfaces in
# CI rather than as runtime validation regressions.
#
# Scope:
# - PublisherPropertySelector1 (selection_type="all") — both XORs apply
# - PublisherPropertySelector3 (selection_type="by_tag") — both XORs apply
# - PublisherPropertySelector2 (selection_type="by_id") — no XOR (by_id
# carries only publisher_domain by spec; publisher_domains is rejected
# at the JSON-schema level). Left unpatched.
from pydantic._internal._decorators import ( # noqa: E402
Decorator as _PydanticDecorator,
)
from pydantic._internal._decorators import ( # noqa: E402
ModelValidatorDecoratorInfo as _ModelValidatorDecoratorInfo,
)

from adcp.types._generated import ( # noqa: E402
PublisherPropertySelector1 as _Selector1,
)
from adcp.types._generated import (
PublisherPropertySelector3 as _Selector3,
)


def _selector_xor_validate(self: Any) -> Any:
"""Enforce publisher_domain XOR publisher_domains[] on selector 1 / 3.

Runs after Pydantic has populated the fields. Defers the full
diagnostic shape to `validate_publisher_properties_item` for parity
with the dict-path enforcement; a violation surfaces here as a
Pydantic `ValidationError` (containing the helper's message) rather
than as the helper's `ValidationError` directly.
"""
# Local import — avoids a top-level cycle through adcp.validation
# back into types.aliases.
from adcp.validation.legacy import (
ValidationError as _LegacyValidationError,
)
from adcp.validation.legacy import (
validate_publisher_properties_item as _validate_item,
)

try:
_validate_item(self)
except _LegacyValidationError as exc:
raise ValueError(str(exc)) from exc
return self


def _attach_selector_xor_validator(cls: type) -> None:
"""Inject a model_validator(mode='after') onto an existing Pydantic class.

The supported decorator path is class-definition-time only; the
generated selector classes can't carry the validator without
modifying generated code (forbidden — overwritten on next regen).
This walks the same `_internal._decorators` machinery the decorator
syntax uses, then forces a `model_rebuild` so Pydantic re-derives
its core schema with the new validator included.
"""
cls._selector_xor_validate = _selector_xor_validate # type: ignore[attr-defined]
info = _ModelValidatorDecoratorInfo(mode="after")
decorator = _PydanticDecorator.build(
cls,
cls_var_name="_selector_xor_validate",
shim=None,
info=info,
)
cls.__pydantic_decorators__.model_validators[ # type: ignore[attr-defined]
"_selector_xor_validate"
] = decorator
cls.model_rebuild(force=True) # type: ignore[attr-defined]


_attach_selector_xor_validator(_Selector1)
_attach_selector_xor_validator(_Selector3)
22 changes: 10 additions & 12 deletions tests/test_adagents.py
Original file line number Diff line number Diff line change
Expand Up @@ -2873,11 +2873,11 @@ def test_resolve_compact_form_via_get_properties_by_agent(self):
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.
# With issue #759 (auto-enforce XOR via post-hoc model_validator),
# direct construction of an XOR-violating selector raises at the
# Pydantic layer. The dict path still goes through the helper.
from pydantic import ValidationError as _PydValidationError

from adcp.types.generated_poc.core.publisher_property_selector import (
PublisherPropertySelector1,
)
Expand All @@ -2886,17 +2886,15 @@ def test_validate_accepts_pydantic_model_instance(self):
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)
# Pydantic construction now rejects the bare form directly.
with pytest.raises(_PydValidationError, match="exactly one"):
PublisherPropertySelector1(selection_type="all")

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

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

Expand Down
125 changes: 125 additions & 0 deletions tests/test_publisher_selector_xor_autoenforce.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
"""Auto-enforcement of the PublisherPropertySelector XOR constraint at Pydantic parse time.

Closes adcp-client-python#759. The patch in ``adcp.types.aliases`` uses
``pydantic._internal._decorators`` (private API but stable across
Pydantic 2.x point releases) to attach a ``model_validator(mode='after')``
to the generated selector classes. The first three tests verify the
behavior; the last test is a **drift sentinel** that fails loudly if
Pydantic's internal decorator registration ever changes shape so the
issue surfaces in CI rather than as silent validation regressions.
"""

from __future__ import annotations

import pytest
from pydantic import ValidationError


class TestSelectorXorAutoEnforce:
"""Direct construction of an XOR-violating selector must fail."""

def test_selector1_rejects_bare_construct(self):
# selection_type='all' with neither publisher_domain nor publisher_domains
from adcp.types.generated_poc.core.publisher_property_selector import (
PublisherPropertySelector1,
)

with pytest.raises(ValidationError, match="exactly one"):
PublisherPropertySelector1(selection_type="all")

def test_selector1_rejects_both_publisher_fields(self):
from adcp.types.generated_poc.core.publisher_property_selector import (
PublisherPropertySelector1,
)

with pytest.raises(ValidationError, match="mutually exclusive"):
PublisherPropertySelector1(
selection_type="all",
publisher_domain="cnn.com",
publisher_domains=["espn.com"],
)

def test_selector1_accepts_singular_form(self):
from adcp.types.generated_poc.core.publisher_property_selector import (
PublisherPropertySelector1,
)

s = PublisherPropertySelector1(selection_type="all", publisher_domain="cnn.com")
assert s.publisher_domain == "cnn.com"

def test_selector1_accepts_compact_form(self):
from adcp.types.generated_poc.core.publisher_property_selector import (
PublisherPropertySelector1,
)

s = PublisherPropertySelector1(
selection_type="all", publisher_domains=["a.example", "b.example"]
)
assert s.publisher_domains is not None
assert [str(d.root) for d in s.publisher_domains] == ["a.example", "b.example"]

def test_selector3_rejects_bare_construct(self):
from adcp.types.generated_poc.core.publisher_property_selector import (
PublisherPropertySelector3,
)

with pytest.raises(ValidationError, match="exactly one"):
PublisherPropertySelector3(selection_type="by_tag", property_tags=["ctv"])

def test_selector3_accepts_compact_form_with_required_tags(self):
from adcp.types.generated_poc.core.publisher_property_selector import (
PublisherPropertySelector3,
)

s = PublisherPropertySelector3(
selection_type="by_tag",
property_tags=["ctv"],
publisher_domains=["a.example", "b.example"],
)
assert s.publisher_domains is not None
assert [str(d.root) for d in s.publisher_domains] == ["a.example", "b.example"]

def test_selector2_unpatched_passes_valid_input(self):
# by_id selector has no XOR — only publisher_domain is allowed,
# publisher_domains is rejected at the JSON-schema level. The
# auto-enforce patch correctly leaves this class alone.
from adcp.types.generated_poc.core.publisher_property_selector import (
PublisherPropertySelector2,
)

s = PublisherPropertySelector2(
selection_type="by_id",
property_ids=["p1"],
publisher_domain="cnn.com",
)
assert s.publisher_domain == "cnn.com"


class TestPydanticInternalApiDriftSentinel:
"""If Pydantic ever changes the shape of ``_internal._decorators``
the patch in ``adcp.types.aliases`` silently no-ops and selector
validation regresses. This test imports the same private surface
the patch uses and verifies the API still exists. CI failure here
is the canary for "rework the patch or pin Pydantic".
"""

def test_decorator_class_present(self):
from pydantic._internal._decorators import Decorator # noqa: F401

def test_model_validator_decorator_info_present(self):
from pydantic._internal._decorators import ( # noqa: F401
ModelValidatorDecoratorInfo,
)

def test_selector1_has_registered_validator(self):
# The patch lands at module-import time. If the registration
# shape changes and the patch silently no-ops, this catches it.
from adcp.types.generated_poc.core.publisher_property_selector import (
PublisherPropertySelector1,
)

validators = PublisherPropertySelector1.__pydantic_decorators__.model_validators
assert "_selector_xor_validate" in validators, (
"XOR auto-enforce validator missing from PublisherPropertySelector1 — "
"patch in adcp.types.aliases may have silently failed."
)
Loading