diff --git a/scripts/post_generate_fixes.py b/scripts/post_generate_fixes.py index 9f5f43d56..ca20f1605 100644 --- a/scripts/post_generate_fixes.py +++ b/scripts/post_generate_fixes.py @@ -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}: @@ -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") @@ -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 @@ -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") @@ -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: @@ -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") @@ -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 @@ -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 @@ -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" @@ -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 @@ -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" @@ -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 @@ -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(): @@ -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') @@ -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. @@ -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: @@ -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") @@ -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: @@ -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") @@ -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, diff --git a/scripts/post_generation_manifest.json b/scripts/post_generation_manifest.json index ec9a571ce..c02999b4d 100644 --- a/scripts/post_generation_manifest.json +++ b/scripts/post_generation_manifest.json @@ -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, diff --git a/src/adcp/types/domains/media_buy/refine_proposals_request.py b/src/adcp/types/domains/media_buy/refine_proposals_request.py index 8aae650d2..40a021e0c 100644 --- a/src/adcp/types/domains/media_buy/refine_proposals_request.py +++ b/src/adcp/types/domains/media_buy/refine_proposals_request.py @@ -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 @@ -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, diff --git a/src/adcp/types/domains/media_buy/refine_proposals_response.py b/src/adcp/types/domains/media_buy/refine_proposals_response.py index 135548ecb..36c34596d 100644 --- a/src/adcp/types/domains/media_buy/refine_proposals_response.py +++ b/src/adcp/types/domains/media_buy/refine_proposals_response.py @@ -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 @@ -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, @@ -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, diff --git a/src/adcp/types/domains/media_buy/request_proposals_response.py b/src/adcp/types/domains/media_buy/request_proposals_response.py index d70869cae..ceec8f9b3 100644 --- a/src/adcp/types/domains/media_buy/request_proposals_response.py +++ b/src/adcp/types/domains/media_buy/request_proposals_response.py @@ -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 @@ -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, diff --git a/tests/conformance/_round_trip.py b/tests/conformance/_round_trip.py index 105426373..f6d4445b8 100644 --- a/tests/conformance/_round_trip.py +++ b/tests/conformance/_round_trip.py @@ -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.""" @@ -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] ) diff --git a/tests/conformance/test_wire_round_trip.py b/tests/conformance/test_wire_round_trip.py index da69824b2..1208e2ca7 100644 --- a/tests/conformance/test_wire_round_trip.py +++ b/tests/conformance/test_wire_round_trip.py @@ -84,19 +84,18 @@ ) #: Schemas with at least one schema-valid document the generated model refuses -#: (raises, or fails validation). Refusing is the schema/model parity suite's -#: domain, not this one's; the set is recorded so a refusal cannot silently -#: remove a schema from the round-trip grade, and so that it only shrinks. -#: ``mcp-webhook-payload`` and ``async-response-data`` raise ``TypeError`` -#: (#1616); the others refuse documents the bundled schema accepts -#: (``publisher-property-selector`` through the mutual-exclusivity validator -#: ``_ergonomic`` adds, which the schema does not express). +#: (fails validation). Refusing is the schema/model parity suite's domain, not +#: this one's; the set is recorded so a refusal cannot silently remove a schema +#: from the round-trip grade, and so that it only shrinks. Each refuses +#: documents the bundled schema accepts (``publisher-property-selector`` +#: through the mutual-exclusivity validator ``_ergonomic`` adds, which the +#: schema does not express). A model that *raises* instead of refusing is not +#: recorded here: :func:`test_no_schema_valid_document_raises_outside_validation` +#: forbids it outright (#1616). REFUSING = frozenset( { "brand/creative-approval-request.json", "brand/verify-brand-claims-response.json", - "core/async-response-data.json", - "core/mcp-webhook-payload.json", "core/publisher-property-selector.json", "core/reporting-delivery-capabilities.json", "media-buy/change-term.json", @@ -250,3 +249,22 @@ def test_refusing_schemas_are_declared() -> None: ) fixed = sorted(REFUSING - refusing) assert not fixed, f"schemas that no longer refuse: {fixed}; delete them from REFUSING" + + +def test_no_schema_valid_document_raises_outside_validation() -> None: + """A model refuses with ``ValidationError`` or not at all; it never raises (#1616). + + ``mcp-webhook-payload`` and ``async-response-data`` used to raise a raw + ``TypeError`` from a ``min_length`` applied to an empty-model union arm. + Unlike a refusal, a raise is never a schema disagreement to declare: it is + a generator defect, and this test has no allowlist. + """ + raised = [ + f" {schema} [{refusal.document}] {sorted(refusal.error_types)}: {refusal.message}" + for schema in SCHEMAS + for refusal in _graded(schema)[1] + if refusal.error_types & rt.RAISED_OUTSIDE_VALIDATION_NAMES + ] + assert not raised, "\n".join( + [f"{len(raised)} schema-valid document(s) made a generated model raise:"] + raised[:20] + ) diff --git a/tests/test_constrained_array_empty_arms.py b/tests/test_constrained_array_empty_arms.py new file mode 100644 index 000000000..14274747b --- /dev/null +++ b/tests/test_constrained_array_empty_arms.py @@ -0,0 +1,152 @@ +"""A schema-valid document validates or fails with ``ValidationError``; it never raises (#1616). + +datamodel-code-generator renders an array that carries an ``allOf`` of +``contains``/``if``-``then`` constraints as ``list[Item] | ``: +``losses`` on the legacy purchase continuation, ``refinements`` on +``refine_proposals_request``, ``results`` on ``refine_proposals_response``. +Pydantic applied the array's ``min_length`` to the whole union, so a dict that +reached the model arm raised ``TypeError: object of type 'Losses' has no len()`` +instead of a ``ValidationError`` -- and every schema-valid ``McpWebhookPayload`` +whose ``result`` union visited that arm crashed the same way. +``post_generate_fixes.drop_empty_model_arms_from_array_fields`` strips the arm; +these tests hold that line on the generated tree and on the public models. +""" + +from __future__ import annotations + +import ast +import json +from pathlib import Path +from typing import Any + +import pytest +from pydantic import TypeAdapter, ValidationError + +from adcp.types import McpWebhookPayload +from adcp.types.domains.core.async_response_data import AdcpAsyncResponseData +from adcp.types.domains.media_buy.refine_proposals_request import RefineProposalsRequest +from adcp.types.domains.media_buy.refine_proposals_response import RefineProposalsResponse1 +from adcp.types.domains.media_buy.request_proposals_response import PurchaseContinuation1 +from adcp.validation.schema_loader import get_named_validator +from tests.conformance import _round_trip as rt + +WEBHOOK_SCHEMA = "core/mcp-webhook-payload.json" +ASYNC_SCHEMA = "core/async-response-data.json" +_GENERATED_DIR = Path(__file__).resolve().parents[1] / "src/adcp/types/domains" + + +def _webhook_examples() -> list[Any]: + document = json.loads((rt.schema_root() / WEBHOOK_SCHEMA).read_text()) + examples = document["examples"] + assert len(examples) == 4, "the bundle ships four mcp-webhook-payload examples" + return examples + + +@pytest.mark.parametrize("example", _webhook_examples(), ids=lambda example: example["description"]) +def test_every_bundle_webhook_example_validates(example: dict[str, Any]) -> None: + """Each example's ``data`` is schema-valid and parses into ``McpWebhookPayload``.""" + validator = get_named_validator(WEBHOOK_SCHEMA) + assert validator is not None + payload = example["data"] + validator.validate(payload) + parsed = McpWebhookPayload.model_validate_json(json.dumps(payload)) + assert parsed.task_id == payload["task_id"] + assert McpWebhookPayload.model_validate(payload).status == parsed.status + + +@pytest.mark.parametrize("example", _webhook_examples(), ids=lambda example: example["description"]) +def test_every_completed_webhook_example_validates(example: dict[str, Any]) -> None: + """The sweep's completed form of each example -- the document that raised -- parses.""" + completed = rt.complete_example(WEBHOOK_SCHEMA, example) + assert completed is not None, "the completed example must still be schema-valid" + parsed = McpWebhookPayload.model_validate_json(json.dumps(completed)) + assert parsed.task_id == completed["task_id"] + + +def test_synthesized_async_response_data_validates() -> None: + """A schema-valid ``async-response-data`` document resolves through the union.""" + payload = rt.synthesize_named(ASYNC_SCHEMA) + assert payload is not None and rt.is_valid(ASYNC_SCHEMA, payload) + TypeAdapter(AdcpAsyncResponseData).validate_json(json.dumps(payload)) + + +_LEGACY_CONTINUATION = { + "kind": "legacy_create", + "continuation_token": "cont_0123456789abcdef", + "continuation_expires_at": "2026-01-01T00:00:00Z", + "source_adcp_version": "3.1", + "product_ids": ["prod_1"], + "losses": ["feed_version_not_atomic", "pricing_version_not_atomic"], + "requires_explicit_acceptance": True, +} + + +@pytest.mark.parametrize( + ("model", "payload", "field"), + [ + pytest.param(PurchaseContinuation1, _LEGACY_CONTINUATION, "losses", id="losses"), + pytest.param( + RefineProposalsRequest, + {"idempotency_key": "idem_0123456789abcdef", "refinements": []}, + "refinements", + id="refinements", + ), + pytest.param(RefineProposalsResponse1, {"results": []}, "results", id="results"), + ], +) +def test_a_dict_on_a_constrained_array_is_a_validation_error( + model: type[Any], payload: dict[str, Any], field: str +) -> None: + """A non-list reaches the field's constraints as a ``ValidationError``, not a ``TypeError``.""" + with pytest.raises(ValidationError) as refused: + model.model_validate_json(json.dumps({**payload, field: {}})) + assert {error["type"] for error in refused.value.errors()} >= {"list_type"} + with pytest.raises(ValidationError): + model.model_validate({**payload, field: {"not": "a list"}}) + + +def test_legacy_continuation_still_accepts_its_losses() -> None: + parsed = PurchaseContinuation1.model_validate(_LEGACY_CONTINUATION) + assert [loss.value for loss in parsed.losses] == _LEGACY_CONTINUATION["losses"] + with pytest.raises(ValidationError): # min_length=2 still applies to the list + PurchaseContinuation1.model_validate( + {**_LEGACY_CONTINUATION, "losses": ["feed_version_not_atomic"]} + ) + + +def _union_arms(node: ast.expr) -> list[ast.expr]: + if isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr): + return _union_arms(node.left) + _union_arms(node.right) + return [node] + + +def test_generated_tree_has_no_empty_model_arm_beside_a_list() -> None: + """The generator rule holds across the whole tree, not only the three known fields.""" + violations: list[str] = [] + for path in sorted(_GENERATED_DIR.rglob("*.py")): + tree = ast.parse(path.read_text()) + empty = { + node.name + for node in tree.body + if isinstance(node, ast.ClassDef) + and [ast.unparse(base) for base in node.bases] == ["AdCPBaseModel"] + and len(node.body) == 1 + and isinstance(node.body[0], ast.Pass) + } + if not empty: + continue + 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 = _union_arms(union) + has_list = any(ast.unparse(arm).startswith("list[") for arm in arms) + empty_arms = [ast.unparse(arm) for arm in arms if ast.unparse(arm) in empty] + if has_list and empty_arms: + violations.append( + f"{path.relative_to(_GENERATED_DIR)}:{node.lineno} {empty_arms}" + ) + break + assert not violations, "empty-model arms beside a list (#1616):\n" + "\n".join(violations)