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
154 changes: 137 additions & 17 deletions scripts/post_generate_fixes.py
Original file line number Diff line number Diff line change
Expand Up @@ -4417,7 +4417,9 @@ def _require_identity_match_dimensions(self) -> TmpProviderRegistration1:
print(" trusted_match/identity_match_response.py response class not found")
return
response_class = response_class_match.group(1)
source = source.rstrip() + f"""
source = (
source.rstrip()
+ f"""

@model_validator(mode='after')
def _validate_tmpx_provider_ids(self) -> {response_class}:
Expand All @@ -4432,6 +4434,7 @@ def _validate_tmpx_provider_ids(self) -> {response_class}:
raise ValueError('tmpx_providers keys must be valid provider_id values')
return self
"""
)
identity_match_response.write_text(source.rstrip() + "\n")
print(" trusted_match/identity_match_response.py: added runtime validators")

Expand Down Expand Up @@ -5266,7 +5269,9 @@ def restore_flattened_contract_field_types() -> None:

if "@model_validator(mode='before')" not in source:
source = ensure_pydantic_import(source, "model_validator")
source = source.rstrip() + """
source = (
source.rstrip()
+ """

@model_validator(mode='before')
@classmethod
Expand All @@ -5281,6 +5286,7 @@ def _reject_seller_bound_manifest_fields(cls, data: Any) -> Any:
)
return data
"""
)
representation_target.write_text(source)
print(" core/creative_representation.py: restored canonical format contract")

Expand Down Expand Up @@ -5313,7 +5319,9 @@ def enforce_transformer_output_contract() -> None:
if "class Transformer(" not in source:
raise RuntimeError("transformer.py: Transformer class not found")
source = ensure_pydantic_import(source, "model_validator")
target.write_text(source.rstrip() + """
target.write_text(
source.rstrip()
+ """

@model_validator(mode='after')
def _require_output_format_declaration(self) -> Transformer:
Expand All @@ -5328,7 +5336,8 @@ def _require_output_format_declaration(self) -> Transformer:
'one of output_capability_ids or deprecated output_format_ids is required'
)
return self
""")
"""
)
print(" core/transformer.py: enforced output declaration requirement")


Expand Down Expand Up @@ -6255,7 +6264,8 @@ def fix_verify_brand_claim_models() -> None:
bulk_response = OUTPUT_DIR / "brand" / "verify_brand_claims_response.py"

if request.exists() and "claim_type:" not in request.read_text():
request.write_text("""# generated by datamodel-codegen:
request.write_text(
"""# generated by datamodel-codegen:
# filename: brand/verify_brand_claim_request.json

from __future__ import annotations
Expand Down Expand Up @@ -6287,11 +6297,13 @@ class VerifyBrandClaimRequest(AdcpVersionEnvelope):
dict[str, Any],
Field(description='Claim payload. Shape varies by claim_type.'),
]
""")
"""
)
print(" brand/verify_brand_claim_request.py: restored claim fields")

if response.exists() and "VerifyBrandClaimSuccessResponse" not in response.read_text():
response.write_text("""# generated by datamodel-codegen:
response.write_text(
"""# generated by datamodel-codegen:
# filename: brand/verify_brand_claim_response.json

from __future__ import annotations
Expand Down Expand Up @@ -6350,7 +6362,8 @@ class VerifyBrandClaimErrorResponse(AdcpVersionEnvelope, ProtocolEnvelope):


VerifyBrandClaimResponse = VerifyBrandClaimSuccessResponse | VerifyBrandClaimErrorResponse
""")
"""
)
print(" brand/verify_brand_claim_response.py: restored response arms")

response_schema = SCHEMA_DIR / "brand" / "verify-brand-claim-response.json"
Expand All @@ -6359,7 +6372,8 @@ class VerifyBrandClaimErrorResponse(AdcpVersionEnvelope, ProtocolEnvelope):
and response_schema.exists()
and '"signed_response"' in response_schema.read_text()
):
response.write_text("""# generated by datamodel-codegen:
response.write_text(
"""# generated by datamodel-codegen:
# filename: brand/verify_brand_claim_response.json

from __future__ import annotations
Expand Down Expand Up @@ -6510,7 +6524,8 @@ class VerifyBrandClaimErrorResponse(AdcpVersionEnvelope, ProtocolEnvelope):


VerifyBrandClaimResponse = VerifyBrandClaimSuccessResponse | VerifyBrandClaimErrorResponse
""")
"""
)
print(" brand/verify_brand_claim_response.py: restored signed response fields")

bulk_response_schema = SCHEMA_DIR / "brand" / "verify-brand-claims-response.json"
Expand All @@ -6519,7 +6534,8 @@ class VerifyBrandClaimErrorResponse(AdcpVersionEnvelope, ProtocolEnvelope):
and bulk_response_schema.exists()
and '"signed_response"' in bulk_response_schema.read_text()
):
bulk_response.write_text("""# generated by datamodel-codegen:
bulk_response.write_text(
"""# generated by datamodel-codegen:
# filename: brand/verify_brand_claims_response.json

from __future__ import annotations
Expand Down Expand Up @@ -6702,7 +6718,8 @@ class VerifyBrandClaimsErrorResponse(AdcpVersionEnvelope, ProtocolEnvelope):


VerifyBrandClaimsResponse = VerifyBrandClaimsResponseBulk | VerifyBrandClaimsErrorResponse
""")
"""
)
print(" brand/verify_brand_claims_response.py: restored signed response fields")

if bulk_response.exists():
Expand Down Expand Up @@ -7234,7 +7251,9 @@ def fix_legacy_purchase_accepted_losses() -> None:
)
validator_marker = " def _accepted_losses_match_schema("
if validator_marker not in fixed and "field_validator" in fixed:
fixed = fixed.rstrip() + """
fixed = (
fixed.rstrip()
+ """


@field_validator('selected_product_ids')
Expand Down Expand Up @@ -7262,11 +7281,103 @@ def _accepted_losses_match_schema(
raise ValueError('accepted_losses must include the required compatibility losses')
return values
"""
)
if fixed != source:
target.write_text(fixed)
print(" media_buy/legacy_purchase_continuation_input.py: narrowed accepted_losses")


def drop_empty_model_arms_from_array_fields() -> None:
"""Strip the empty-model arm codegen emits beside a constrained array (#1616).

A schema array that carries an ``allOf`` of ``contains``/``if``-``then``
constraints (``losses`` on the legacy purchase continuation, ``refinements``
on refine_proposals_request) has no Python shape codegen can give those
keywords, so datamodel-code-generator renders the composition as an empty
object model and widens the field to ``list[Item] | Empty``. Pydantic then
applies the array's ``min_length``/``max_length`` to the whole union, and a
dict on the wire reaches the model arm, where the constraint raises a raw
``TypeError`` (``object of type 'Losses' has no len()``) instead of a
``ValidationError``. Every schema-valid ``McpWebhookPayload`` and
``AsyncResponseData`` document that visits that arm while the union is
being resolved crashes the same way.

The protocol contract is the list; the model arm is an artifact. Drop the
arm from every field whose union is a ``list[...]`` plus an empty
``AdCPBaseModel`` subclass, in either order, and leave the class itself in
place: it is a public ``adcp.types`` name and removing it would be a 9.x
surface change for nothing. Idempotent: a field with no empty arm is
untouched.
"""
print("Dropping empty-model arms from constrained array fields...")
for path in sorted(OUTPUT_DIR.rglob("*.py")):
source = path.read_text()
tree = ast.parse(source)
empty_models = {
node.name
for node in tree.body
if isinstance(node, ast.ClassDef)
and len(node.bases) == 1
and isinstance(node.bases[0], ast.Name)
and node.bases[0].id == "AdCPBaseModel"
and len(node.body) == 1
and isinstance(node.body[0], ast.Pass)
}
if not empty_models:
continue
replacements: list[tuple[ast.expr, ast.expr]] = []
for node in ast.walk(tree):
if not isinstance(node, ast.AnnAssign):
continue
for union in ast.walk(node.annotation):
if not (isinstance(union, ast.BinOp) and isinstance(union.op, ast.BitOr)):
continue
arms = _flatten_union_arms(union)
kept = [
arm
for arm in arms
if not (isinstance(arm, ast.Name) and arm.id in empty_models)
]
has_list = any(
isinstance(arm, ast.Subscript)
and isinstance(arm.value, ast.Name)
and arm.value.id == "list"
for arm in kept
)
if len(kept) == len(arms) or not kept or not has_list:
continue
replacements.append((union, _rebuild_union(kept)))
break
if not replacements:
continue
lines = source.splitlines(keepends=True)
for union, rebuilt in sorted(
replacements, key=lambda pair: (pair[0].lineno, pair[0].col_offset), reverse=True
):
assert union.end_lineno is not None and union.end_col_offset is not None
start = sum(len(line) for line in lines[: union.lineno - 1]) + union.col_offset
end = sum(len(line) for line in lines[: union.end_lineno - 1]) + union.end_col_offset
source = source[:start] + ast.unparse(rebuilt) + source[end:]
lines = source.splitlines(keepends=True)
path.write_text(source)
print(f" {path.relative_to(OUTPUT_DIR)}: dropped {len(replacements)} empty-model arm(s)")


def _flatten_union_arms(node: ast.expr) -> list[ast.expr]:
"""The arms of a ``A | B | C`` expression, left to right."""
if isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr):
return _flatten_union_arms(node.left) + _flatten_union_arms(node.right)
return [node]


def _rebuild_union(arms: list[ast.expr]) -> ast.expr:
"""``arms`` joined back into a ``|`` union (a single arm stands alone)."""
rebuilt = arms[0]
for arm in arms[1:]:
rebuilt = ast.BinOp(left=rebuilt, op=ast.BitOr(), right=arm)
return rebuilt


def preserve_request_signing_operation_strings() -> None:
"""Keep request-signing operation lists source-compatible with beta.5.

Expand Down Expand Up @@ -7392,7 +7503,9 @@ def _require_portable_bound(self) -> {class_name}:
)
class_start = source.find("class MediaBuyChangeTerm(AdCPBaseModel):")
if class_start >= 0 and "def _validate_constraint_action" not in source[class_start:]:
source = source.rstrip() + """
source = (
source.rstrip()
+ """

@model_validator(mode='after')
def _validate_constraint_action(self) -> MediaBuyChangeTerm:
Expand All @@ -7412,7 +7525,9 @@ def _validate_constraint_action(self) -> MediaBuyChangeTerm:
if action not in allowed.get(kind, set()):
raise ValueError('constraint kind is incompatible with action')
return self
""" + "\n"
"""
+ "\n"
)
term_path.write_text(source)
print(" media_buy/change_term.py: restored constraint/action compatibility")

Expand All @@ -7425,7 +7540,9 @@ def _validate_constraint_action(self) -> MediaBuyChangeTerm:
)
class_start = source.find("class CommercialTerms(AdCPBaseModel):")
if class_start >= 0 and "def _validate_change_term_set" not in source[class_start:]:
source = source.rstrip() + """
source = (
source.rstrip()
+ """

@model_validator(mode='after')
def _validate_change_term_set(self) -> CommercialTerms:
Expand Down Expand Up @@ -7474,7 +7591,9 @@ def _validate_change_term_set(self) -> CommercialTerms:
):
raise ValueError('change-term earliest effective time exceeds latest time')
return self
""" + "\n"
"""
+ "\n"
)
terms_path.write_text(source)
print(" media_buy/commercial_terms.py: restored change-term set invariants")

Expand Down Expand Up @@ -8417,6 +8536,7 @@ def main(argv: list[str] | None = None) -> int:
fix_product_fields_item_reference,
fix_audience_evidence_attestation_subject,
fix_legacy_purchase_accepted_losses,
drop_empty_model_arms_from_array_fields,
preserve_request_signing_operation_strings,
enforce_change_term_runtime_constraints,
inject_literal_discriminator_defaults,
Expand Down
1 change: 1 addition & 0 deletions scripts/post_generation_manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"fix_product_fields_item_reference": true,
"fix_audience_evidence_attestation_subject": true,
"fix_legacy_purchase_accepted_losses": true,
"drop_empty_model_arms_from_array_fields": true,
"preserve_request_signing_operation_strings": true,
"enforce_change_term_runtime_constraints": true,
"inject_literal_discriminator_defaults": true,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# generated by datamodel-codegen:
# filename: media_buy/refine_proposals_request.json
# timestamp: 2026-10-21T11:05:54+00:00
# timestamp: 2026-10-27T00:03:14+00:00

from __future__ import annotations

Expand Down Expand Up @@ -69,7 +69,7 @@ class RefineProposalsRequest(AdcpRequest, AdcpVersionEnvelope):
),
]
refinements: Annotated[
list[proposal_refinement.ProposalRefinement] | Refinements,
list[proposal_refinement.ProposalRefinement],
Field(
description='Proposal operations to apply, with at most 25 entries per request. revise creates a draft successor from a draft, committed, or accepted source. finalize MUST target a draft, reserves inventory, and creates a committed successor whose expires_at is the hold deadline. A batch containing finalize MUST contain only finalize entries and is atomic. proposal_id values MUST be unique; results preserve request order.',
max_length=25,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# generated by datamodel-codegen:
# filename: media_buy/refine_proposals_response.json
# timestamp: 2026-10-25T19:32:54+00:00
# timestamp: 2026-10-27T00:03:14+00:00

from __future__ import annotations

Expand Down Expand Up @@ -596,7 +596,7 @@ class RefineProposalsResponse1(AdcpResponse, AdCPBaseModel):
NoExplicitNull,
] = None
results: Annotated[
list[Results8] | Results,
list[Results8],
Field(
description="Ordered results. If any result is finalized, every result MUST be finalized; a finalize batch either creates every requested hold or none. Every returned proposal carries parent_proposal_id equal to the result's source_proposal_id, making negotiation lineage reconstructible from the proposals alone.",
min_length=1,
Expand Down Expand Up @@ -631,7 +631,7 @@ class RefineProposalsResponse2(AdcpResponse, CompactTaskSubmitted):
NoExplicitNull,
] = None
results: Annotated[
list[Results14] | Results | None,
list[Results14] | None,
Field(
description="Ordered results. If any result is finalized, every result MUST be finalized; a finalize batch either creates every requested hold or none. Every returned proposal carries parent_proposal_id equal to the result's source_proposal_id, making negotiation lineage reconstructible from the proposals alone.",
min_length=1,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# generated by datamodel-codegen:
# filename: media_buy/request_proposals_response.json
# timestamp: 2026-10-25T19:32:54+00:00
# timestamp: 2026-10-27T00:03:14+00:00

from __future__ import annotations

Expand Down Expand Up @@ -136,7 +136,7 @@ class PurchaseContinuation1(AdCPBaseModel):
),
]
losses: Annotated[
list[Loss] | Losses,
list[Loss],
Field(
description='Guarantees that the established create_media_buy continuation cannot provide. The coordinator MUST fail before mutation unless the caller explicitly accepts every listed loss.',
min_length=2,
Expand Down
15 changes: 13 additions & 2 deletions tests/conformance/_round_trip.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,16 @@ def describe(self) -> str:
return f"{where}: {self.kind} {self.before!r} -> {self.after!r}"


#: Exceptions a generated model has been seen to raise from inside validation
#: instead of reporting a ``ValidationError``: a constraint applied to an arm
#: that cannot carry it (#1616). A refusal recorded under one of these class
#: names is a model defect, never a schema disagreement.
RAISED_OUTSIDE_VALIDATION: tuple[type[Exception], ...] = (TypeError, ValueError)
RAISED_OUTSIDE_VALIDATION_NAMES: frozenset[str] = frozenset(
exc.__name__ for exc in RAISED_OUTSIDE_VALIDATION
)


@dataclass(frozen=True)
class Refusal:
"""A schema-valid document the model does not accept."""
Expand Down Expand Up @@ -704,9 +714,10 @@ def round_trip(
frozenset(error["type"] for error in exc.errors()),
"; ".join(f"{'/'.join(map(str, e['loc']))}: {e['msg']}" for e in exc.errors()[:3]),
)
except (TypeError, ValueError) as exc:
except RAISED_OUTSIDE_VALIDATION as exc:
# A model whose validator raises rather than refuses is a defect of
# its own; the sweep records it and moves on.
# its own (#1616); the sweep records it under the exception's class
# name, which ``test_wire_round_trip`` forbids, and moves on.
return [], Refusal(
document.schema, document.name, frozenset({type(exc).__name__}), str(exc)[:200]
)
Expand Down
Loading
Loading