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
19 changes: 2 additions & 17 deletions SCHEMA_DELTAS.md
Original file line number Diff line number Diff line change
@@ -1,21 +1,6 @@
# Generated-types delta

## Files added

- `core/match_key.py` — MatchKey
- `enums/match_key_use.py` — MatchKeyUse

## Field changes

- `bundled/protocol/get_adcp_capabilities_response.py`
- **classes added**: BrandKitOverride11, PropertyType, SupportedMatchKey, Use, Vendor9
- **classes removed**: Surface
- `MediaBuy`: `+supported_match_keys`
- `core/product_identity.py`
- `ProductIdentity`: `+match_keys`
- `protocol/get_adcp_capabilities_response.py`
- `MediaBuy`: `+supported_match_keys`
- `trusted_match/provider_registration.py`
- **classes added**: MatchKey, Use
- `TmpProviderRegistration1`: `+match_keys`
- `TmpProviderRegistration2`: `+match_keys`
- `core/wholesale_feed_event.py`
- **classes removed**: Range
6 changes: 3 additions & 3 deletions docs/shared-type-names.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@ schemas legitimately declare a class called `QuerySummary` or `Creative`.
`adcp.types` binds one of them per name. Import from the domain module to
name the one you mean.

515 names, 1680 variants.
515 names, 1679 variants.

Auto-generated by `scripts/consolidate_exports.py`. DO NOT EDIT MANUALLY.
Generation date: 2026-11-08 02:42:08 UTC
Generation date: 2026-11-09 22:26:05 UTC

| Type name | Import one of |
| --- | --- |
Expand Down Expand Up @@ -371,7 +371,7 @@ Generation date: 2026-11-08 02:42:08 UTC
| `Qualifier` | `adcp.types.domains.core.committed_metric.Qualifier` <br> `adcp.types.domains.core.delivery_metric_aggregate.Qualifier` <br> `adcp.types.domains.core.missing_metric.Qualifier` <br> `adcp.types.domains.core.package_delivery_metric_value.Qualifier` <br> `adcp.types.domains.core.performance_feedback.Qualifier` <br> `adcp.types.domains.core.performance_feedback_metric.Qualifier` <br> `adcp.types.domains.core.vendor_metric_value.Qualifier` <br> `adcp.types.domains.media_buy.package_forecast_input.Qualifier` <br> `adcp.types.domains.media_buy.package_request.Qualifier` |
| `QuerySummary` | `adcp.types.domains.core.tasks_list_response.QuerySummary` <br> `adcp.types.domains.creative.list_creatives_response.QuerySummary` <br> `adcp.types.domains.protocol.list_tasks_response.QuerySummary` |
| `Radius` | `adcp.types.domains.core.catchment.Radius` <br> `adcp.types.domains.core.product_filters.Radius` <br> `adcp.types.domains.core.product_offer_filters.Radius` <br> `adcp.types.domains.core.targeting.Radius` <br> `adcp.types.domains.core.targeting_input.Radius` |
| `Range` | `adcp.types.domains.core.audience_characteristic.Range` <br> `adcp.types.domains.core.signal_definition.Range` <br> `adcp.types.domains.core.signal_listing.Range` <br> `adcp.types.domains.core.wholesale_feed_event.Range` <br> `adcp.types.domains.property.property_feature_definition.Range` <br> `adcp.types.domains.protocol.get_adcp_capabilities_response.Range` <br> `adcp.types.domains.signals.get_signals_response.Range` |
| `Range` | `adcp.types.domains.core.audience_characteristic.Range` <br> `adcp.types.domains.core.signal_definition.Range` <br> `adcp.types.domains.core.signal_listing.Range` <br> `adcp.types.domains.property.property_feature_definition.Range` <br> `adcp.types.domains.protocol.get_adcp_capabilities_response.Range` <br> `adcp.types.domains.signals.get_signals_response.Range` |
| `ReachWindow` | `adcp.types.domains.compliance.comply_test_controller_request.ReachWindow` <br> `adcp.types.domains.core.delivery_metrics.ReachWindow` |
| `Reason` | `adcp.types.domains.adagents.Reason` <br> `adcp.types.domains.core.async_response_refs.creative.sync_creatives_async_response_input_required.Reason` <br> `adcp.types.domains.core.async_response_refs.media_buy.build_creative_async_response_input_required.Reason` <br> `adcp.types.domains.core.async_response_refs.media_buy.create_media_buy_async_response_input_required.Reason` <br> `adcp.types.domains.core.async_response_refs.media_buy.get_products_async_response_input_required.Reason` <br> `adcp.types.domains.core.async_response_refs.media_buy.sync_catalogs_async_response_input_required.Reason` <br> `adcp.types.domains.core.async_response_refs.media_buy.update_media_buy_async_response_input_required.Reason` <br> `adcp.types.domains.core.brand_response_authorization_result.Reason` <br> `adcp.types.domains.core.capabilities_changed_webhook.Reason` <br> `adcp.types.domains.core.catalog_item_availability_update.Reason` <br> `adcp.types.domains.core.principal_changed_webhook.Reason` <br> `adcp.types.domains.core.reporting_coverage.Reason` <br> `adcp.types.domains.core.reporting_subscription_state.Reason` <br> `adcp.types.domains.creative.sync_creatives_async_response_input_required.Reason` <br> `adcp.types.domains.error_details.agent_permission_denied.Reason` <br> `adcp.types.domains.error_details.execution_requirement_unmet.Reason` <br> `adcp.types.domains.media_buy.build_creative_async_response_input_required.Reason` <br> `adcp.types.domains.media_buy.create_media_buy_async_response_input_required.Reason` <br> `adcp.types.domains.media_buy.get_products_async_response_input_required.Reason` <br> `adcp.types.domains.media_buy.sync_catalogs_async_response_input_required.Reason` <br> `adcp.types.domains.media_buy.update_media_buy_async_response_input_required.Reason` <br> `adcp.types.domains.sponsored_intelligence.si_terminate_session_request.Reason` |
| `ReasonCode` | `adcp.types.domains.core.account_status_changed_webhook.ReasonCode` <br> `adcp.types.domains.core.attestation_evaluation.ReasonCode` <br> `adcp.types.domains.core.reporting_adjustment.ReasonCode` |
Expand Down
187 changes: 142 additions & 45 deletions scripts/post_generate_fixes.py
Original file line number Diff line number Diff line change
Expand Up @@ -5135,56 +5135,153 @@ def fix_mcp_webhook_operation_id_optional() -> None:
print(" core/mcp_webhook_payload.py: made operation_id optional")


class _DropDescriptions(ast.NodeTransformer):
"""Strip ``description=`` from ``Field(...)`` and unwrap a bare ``Annotated[T]``."""

def visit_Call(self, node: ast.Call) -> ast.AST:
self.generic_visit(node)
if isinstance(node.func, ast.Name) and node.func.id == "Field":
node.keywords = [k for k in node.keywords if k.arg != "description"]
return node

def visit_Subscript(self, node: ast.Subscript) -> ast.AST:
self.generic_visit(node)
if not (isinstance(node.value, ast.Name) and node.value.id == "Annotated"):
return node
elts = list(node.slice.elts) if isinstance(node.slice, ast.Tuple) else [node.slice]
kept = [
e
for e in elts[1:]
if not (
isinstance(e, ast.Call)
and isinstance(e.func, ast.Name)
and e.func.id == "Field"
and not e.args
and not e.keywords
)
]
if not kept:
return elts[0]
node.slice = ast.Tuple(elts=[elts[0], *kept], ctx=ast.Load())
return node


def _range_shape(node: ast.ClassDef) -> str:
"""What a generated ``Range`` class validates, with descriptions removed.

The copies datamodel-code-generator emits differ from ``SignalListing.Range``
only in field descriptions. Bases, ``model_config`` (so an open schema
differs from a closed one), field types, constraints and defaults all count.
"""
stripped = _DropDescriptions().visit(ast.parse(ast.unparse(node)).body[0])
assert isinstance(stripped, ast.ClassDef)
stripped.body = [
stmt
for stmt in stripped.body
if not (isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Constant))
]
return ast.unparse(stripped)


def _top_level_class(tree: ast.Module, name: str) -> ast.ClassDef | None:
return next(
(node for node in tree.body if isinstance(node, ast.ClassDef) and node.name == name),
None,
)


def _extends_signal_listing(node: ast.ClassDef) -> bool:
return any(
(isinstance(base, ast.Name) and base.id == "SignalListing")
or (isinstance(base, ast.Attribute) and base.attr == "SignalListing")
for base in node.bases
)


def fix_signal_listing_range_subclasses() -> None:
"""Reuse SignalListing.Range for generated subclasses that redeclare range."""
replacements = {
OUTPUT_DIR
/ "signals"
/ "get_signals_response.py": [
(
"from ..core.signal_listing import SignalListing\n",
"from ..core.signal_listing import Range, SignalListing\n",
),
(
"\n\nclass Range(AdCPBaseModel):\n"
" model_config = ConfigDict(\n"
" extra='forbid',\n"
" )\n"
" min: Annotated[float, Field(description='Minimum value (inclusive)')]\n"
" max: Annotated[float, Field(description='Maximum value (inclusive)')]\n",
"",
),
],
OUTPUT_DIR
/ "core"
/ "wholesale_feed_event.py": [
(
"from .signal_listing import SignalListing\n",
"from .signal_listing import Range, SignalListing\n",
),
(
"\n\nclass Range(AdCPBaseModel):\n"
" model_config = ConfigDict(\n"
" extra='forbid',\n"
" )\n"
" min: float\n"
" max: float\n",
"",
),
],
}
"""Reuse SignalListing.Range in every module that subclasses SignalListing.

A class that extends ``core/signal-listing.json`` redeclares ``range``, and
the generator gives that redeclaration its own ``Range`` class: identical to
``SignalListing.Range`` but a different type, so the subclass's ``range`` no
longer has its parent's type and a ``SignalListing.Range`` does not validate
there. Every generated module with a ``SignalListing`` subclass is checked.
Its ``Range`` is compared with ``SignalListing.Range`` by what it validates,
deleted, and imported from ``signal_listing`` instead, so the pass keeps
working when the generator changes descriptions, scalar types or the schema's
``additionalProperties``. It used to match the generated text of two named
files, ``extra='forbid'`` and all, and reported success once that text drifted.

A module that declares ``Range`` without subclassing ``SignalListing`` keeps
it, as every schema keeps its own classes: ``signals/get_signals_response.py``
has inlined its signal since 3.3 and has no parent type to match.

Raises when such a ``Range`` no longer validates like ``SignalListing.Range``
(deleting it would change the wire contract), when the module imports
``SignalListing`` in a form the shared ``Range`` cannot be added to, or when
a duplicate survives: a silent miss is a regeneration failure.
"""
listing_path = OUTPUT_DIR / "core" / "signal_listing.py"
if not listing_path.exists():
print(" signal_listing.py not found (skipping SignalListing.Range reuse)")
return
listing = _top_level_class(ast.parse(listing_path.read_text()), "Range")

fixed = 0
for path, path_replacements in replacements.items():
if not path.exists():
for path in sorted(OUTPUT_DIR.rglob("*.py")):
if path == listing_path:
continue
source = path.read_text()
updated = source
for old, new in path_replacements:
updated = updated.replace(old, new, 1)
if updated != source:
path.write_text(updated)
fixed += 1
if "SignalListing" not in source:
continue
tree = ast.parse(source)
local = _top_level_class(tree, "Range")
if local is None or not any(
isinstance(node, ast.ClassDef) and _extends_signal_listing(node) for node in tree.body
):
continue
relative = path.relative_to(OUTPUT_DIR).as_posix()
if listing is None:
raise RuntimeError(
f"{relative}: subclasses SignalListing and declares Range, but "
"core/signal_listing.py no longer declares Range"
)
if _range_shape(local) != _range_shape(listing):
raise RuntimeError(
f"{relative}: Range no longer matches SignalListing.Range, so it cannot be "
f"reused; revisit fix_signal_listing_range_subclasses.\n"
f" local: {_range_shape(local)!r}\n"
f" listing: {_range_shape(listing)!r}"
)
imported = next(
(
node
for node in tree.body
if isinstance(node, ast.ImportFrom)
and (node.module or "").split(".")[-1] == "signal_listing"
and any(a.name == "SignalListing" and a.asname is None for a in node.names)
),
None,
)
if imported is None:
raise RuntimeError(
f"{relative}: subclasses SignalListing without a "
"'from ...signal_listing import SignalListing' to add Range to"
)

lines = source.splitlines(keepends=True)
assert local.end_lineno is not None
start = sum(map(len, lines[: local.lineno - 1]))
end = sum(map(len, lines[: local.end_lineno]))
if source[start - 2 : start] == "\n\n":
start -= 2
source = source[:start] + source[end:]
source = add_to_import(source, "." * imported.level + (imported.module or ""), "Range")

if _top_level_class(ast.parse(source), "Range") is not None:
raise RuntimeError(f"{relative}: a duplicate Range class survived the reuse pass")
path.write_text(source)
fixed += 1

if fixed:
print(f" Reused SignalListing.Range in {fixed} subclass file(s)")
Expand Down
2 changes: 1 addition & 1 deletion scripts/post_generation_manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@
"preserve_wire_text_on_normalizing_fields": true,
"insert_task_message_markers": true,
"point_integer_fields_at_the_schema_integer_type": true,
"remove_imports_shadowed_by_a_local_class": true,
"remove_imports_shadowed_by_a_local_class": "Its only target was core/wholesale_feed_event.py, where fix_signal_listing_range_subclasses added a Range import but missed the duplicate Range class it should have deleted. That pass now deletes the class, so no import is shadowed. Kept as a guard for the generator's import-and-redeclare pattern.",
"remove_unused_pydantic_field_imports": true,
"emit_open_asset_unions": true,
"declare_root_schema_module_exports": true,
Expand Down
12 changes: 2 additions & 10 deletions src/adcp/types/domains/core/wholesale_feed_event.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# generated by datamodel-codegen:
# filename: core/wholesale_feed_event.json
# timestamp: 2026-10-29T01:56:42+00:00
# timestamp: 2026-11-09T22:24:53+00:00

from __future__ import annotations

Expand All @@ -20,7 +20,7 @@
from . import signal_id as signal_id_1
from . import signal_ref as signal_ref_1
from . import vendor_pricing_option
from .signal_listing import SignalListing
from .signal_listing import Range, SignalListing


class EventType(StrEnum):
Expand Down Expand Up @@ -90,14 +90,6 @@ class RemovalReason(StrEnum):
policy_takedown = 'policy_takedown'


class Range(AdCPBaseModel):
model_config = ConfigDict(
extra='forbid',
)
min: StrictFloat
max: StrictFloat


class Payload(AdCPBaseModel):
model_config = ConfigDict(
extra='allow',
Expand Down
Loading
Loading