diff --git a/scripts/post_generate_fixes.py b/scripts/post_generate_fixes.py index 42958aa63..af6d271f1 100644 --- a/scripts/post_generate_fixes.py +++ b/scripts/post_generate_fixes.py @@ -52,6 +52,9 @@ def _load_resolve_bundle_key(): _PROTOCOL_ENVELOPE_IMPORT = "from ..core.protocol_envelope import ProtocolEnvelope\n" _VERSION_ENVELOPE_IMPORT = "from ..core.version_envelope import AdcpVersionEnvelope\n" +_RESPONSE_ARM_DISPATCH_IMPORT = ( + "from adcp.types.response_dispatch import ResponseArmDispatchMixin\n" +) _STR_ENUM_MEMBER_ASSIGNMENT_IGNORE = " # type: ignore[assignment]" _STR_ATTRIBUTE_NAMES = set(dir(str)) @@ -1726,8 +1729,7 @@ def union_arm_names(node: ast.expr) -> list[str]: if isinstance(node, ast.Name): return [node.id] raise RuntimeError( - "generated AccountReference has an unsupported union expression: " - f"{ast.unparse(node)}" + f"generated AccountReference has an unsupported union expression: {ast.unparse(node)}" ) arm_names = union_arm_names(root_base.slice) @@ -3461,6 +3463,298 @@ def disambiguate_comply_response_arm() -> None: print(" compliance response: renamed Arm -> ComplyResponseArm") +def restore_flattened_contract_field_types() -> None: + """Restore constraints lost when codegen re-states ``allOf`` fields as ``Any``. + + datamodel-code-generator 0.64 flattens a required field inherited through an + ``allOf`` and, for these two schemas, emits an untyped local override. That + broadens the public model beyond the schema: signal references stop being + discriminated and creative representations accept arbitrary format kinds. + Keep the correction here so a clean regeneration retains the wire contract. + """ + product_target = OUTPUT_DIR / "core" / "product_signal_targeting_option.py" + if product_target.exists(): + source = product_target.read_text() + expected = " signal_ref: Any" + replacement = """ signal_ref: Annotated[ + signal_ref.SignalRef, + Field( + description="Canonical signal reference. Use scope 'product' for a product-local signal defined by this listing; use scope 'data_provider' with data_provider_domain for a signal defined in a data provider's published adagents.json signals[]; use scope 'signal_source' with signal_source_url for a source-native signal." + ), + ]""" + if expected in source: + vendor_import = "from . import vendor_pricing_option\n" + if vendor_import not in source: + raise RuntimeError("product_signal_targeting_option.py: missing vendor import") + source = source.replace( + vendor_import, "from . import signal_ref, vendor_pricing_option\n", 1 + ) + source = source.replace(expected, replacement, 1) + # ``Any`` was imported only for the codegen-erased field. + source = source.replace( + "from typing import Annotated, Any\n", "from typing import Annotated\n" + ) + product_target.write_text(source) + print(" core/product_signal_targeting_option.py: restored SignalRef discriminator") + elif replacement in source: + print( + " core/product_signal_targeting_option.py: SignalRef discriminator already restored" + ) + else: + raise RuntimeError( + "product_signal_targeting_option.py: expected signal_ref override not found" + ) + else: + print( + " core/product_signal_targeting_option.py not found (skipping SignalRef restoration)" + ) + + representation_target = OUTPUT_DIR / "core" / "creative_representation.py" + if not representation_target.exists(): + print(" core/creative_representation.py not found (skipping representation restoration)") + return + + source = representation_target.read_text() + expected = " format_kind: Any" + replacement = """ format_kind: Annotated[ + CanonicalFormatKind, + Field( + description="Canonical 3.2 path. The canonical format name this manifest targets (e.g., `image`, `video_hosted`, `audio_vast`, `seller_rendered_stateful_display`, `coordinated_placements`). Selects the contract against which the seller validates the manifest's assets. Mutually exclusive with deprecated `format_id`." + ), + ]""" + if expected in source: + if "from .canonical_format_kind import CanonicalFormatKind\n" not in source: + anchor = "from .creative_manifest import CreativeManifest\n" + if anchor not in source: + raise RuntimeError("creative_representation.py: missing CreativeManifest import") + source = source.replace( + anchor, "from .canonical_format_kind import CanonicalFormatKind\n" + anchor, 1 + ) + source = source.replace(expected, replacement, 1) + elif replacement not in source: + raise RuntimeError("creative_representation.py: expected format_kind override not found") + + generated_config = """class CreativeRepresentation(CreativeManifest): + model_config = ConfigDict( + extra='allow', + ) +""" + contract_config = """class CreativeRepresentation(CreativeManifest): + model_config = ConfigDict( + extra='allow', + json_schema_extra={ + 'not': { + 'anyOf': [ + {'required': ['format_id']}, + {'required': ['format_option_ref']}, + {'required': ['representation_selection']}, + ] + } + }, + ) +""" + if generated_config in source: + source = source.replace(generated_config, contract_config, 1) + elif "json_schema_extra=" not in source: + raise RuntimeError("creative_representation.py: expected model configuration not found") + + if "@model_validator(mode='before')" not in source: + if "from pydantic import ConfigDict, Field\n" not in source: + raise RuntimeError("creative_representation.py: missing Pydantic import") + source = source.replace( + "from pydantic import ConfigDict, Field\n", + "from pydantic import ConfigDict, Field, model_validator\n", + 1, + ) + source = ( + source.rstrip() + + """ + + @model_validator(mode='before') + @classmethod + def _reject_seller_bound_manifest_fields(cls, data: Any) -> Any: + \"\"\"Representations cannot carry seller-side manifest selectors.\"\"\" + if isinstance(data, dict): + forbidden = ('format_id', 'format_option_ref', 'representation_selection') + present = [field for field in forbidden if field in data] + if present: + raise ValueError( + 'creative representations must not include ' + ', '.join(present) + ) + return data +""" + ) + representation_target.write_text(source) + print(" core/creative_representation.py: restored canonical format contract") + + +def enforce_transformer_output_contract() -> None: + """Require a transformer to declare canonical or legacy output formats.""" + target = OUTPUT_DIR / "core" / "transformer.py" + if not target.exists(): + print(" core/transformer.py not found (skipping transformer output contract)") + return + + source = target.read_text() + if "def _require_output_format_declaration" in source: + old_condition = ( + " if self.output_capability_ids is None and self.output_format_ids is None:\n" + ) + new_condition = """ # Read Pydantic's stored values directly so validation itself does not + # emit a deprecation warning for the still-supported legacy field. + if ( + self.__dict__.get('output_capability_ids') is None + and self.__dict__.get('output_format_ids') is None + ): +""" + if old_condition in source: + target.write_text(source.replace(old_condition, new_condition, 1)) + print(" core/transformer.py: updated output contract deprecation handling") + return + print(" core/transformer.py: output contract already enforced") + return + if "class Transformer(" not in source: + raise RuntimeError("transformer.py: Transformer class not found") + if "from pydantic import AnyUrl, ConfigDict, Field, RootModel\n" not in source: + raise RuntimeError("transformer.py: missing Pydantic import") + + source = source.replace( + "from pydantic import AnyUrl, ConfigDict, Field, RootModel\n", + "from pydantic import AnyUrl, ConfigDict, Field, RootModel, model_validator\n", + 1, + ) + target.write_text( + source.rstrip() + + """ + + @model_validator(mode='after') + def _require_output_format_declaration(self) -> Transformer: + \"\"\"At least one output declaration is required by the schema.\"\"\" + # Read Pydantic's stored values directly so validation itself does not + # emit a deprecation warning for the still-supported legacy field. + if ( + self.__dict__.get('output_capability_ids') is None + and self.__dict__.get('output_format_ids') is None + ): + raise ValueError( + 'one of output_capability_ids or deprecated output_format_ids is required' + ) + return self +""" + ) + print(" core/transformer.py: enforced output declaration requirement") + + +def restore_constructible_response_bases() -> None: + """Keep selected public response names as constructible Pydantic models. + + The generated numbered arms are still the schema-specific parsing surface and + remain available through the existing aliases. A top-level union alias, + however, is not constructible and breaks callers that used the stable response + model API. Restore the former envelope base and make every generated arm a + subclass of it. The shared mixin dispatches ``Base.model_validate`` through + the arms, preserving both forms without losing task-specific wire fields. + """ + response_specs = ( + ("compliance/comply_test_controller_response.py", "ComplyTestControllerResponse"), + ( + "content_standards/create_content_standards_response.py", + "CreateContentStandardsResponse", + ), + ("content_standards/list_content_standards_response.py", "ListContentStandardsResponse"), + ("account/sync_governance_response.py", "SyncGovernanceResponse"), + ( + "content_standards/update_content_standards_response.py", + "UpdateContentStandardsResponse", + ), + ) + + for relative_path, base_name in response_specs: + target = OUTPUT_DIR / relative_path + if not target.exists(): + print(f" {relative_path} not found (skipping constructible response base)") + continue + + source = target.read_text() + if _RESPONSE_ARM_DISPATCH_IMPORT not in source: + future_import = "from __future__ import annotations\n\n" + if future_import not in source: + raise RuntimeError(f"{relative_path}: missing future annotations import") + source = source.replace( + future_import, future_import + _RESPONSE_ARM_DISPATCH_IMPORT + "\n", 1 + ) + try: + tree = ast.parse(source) + except SyntaxError as exc: + raise RuntimeError(f"{relative_path}: invalid generated Python") from exc + + arms = [ + node + for node in tree.body + if isinstance(node, ast.ClassDef) + and re.fullmatch(rf"{re.escape(base_name)}\d+", node.name) + ] + if not arms: + print(f" {relative_path}: response arms not generated (skipping constructible base)") + continue + + stable_base_exists = any( + isinstance(node, ast.ClassDef) and node.name == base_name for node in tree.body + ) + lines = source.splitlines(keepends=True) + for node in tree.body: + target_names: list[str] = [] + if isinstance(node, ast.Assign): + target_names = [item.id for item in node.targets if isinstance(item, ast.Name)] + elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name): + target_names = [node.target.id] + if base_name in target_names: + end_line = node.end_lineno or node.lineno + for line_number in range(node.lineno - 1, end_line): + lines[line_number] = "" + source = "".join(lines) + + arm_names = [ + arm.name for arm in sorted(arms, key=lambda arm: int(arm.name.removeprefix(base_name))) + ] + for arm in arms: + arm_header = re.compile(rf"^class {re.escape(arm.name)}\([^\n]*\):$", re.MULTILINE) + source, replacements = arm_header.subn( + f"class {arm.name}({base_name}):", source, count=1 + ) + if replacements != 1: + raise RuntimeError(f"{relative_path}: unable to rewrite {arm.name} base") + + first_arm = min(arms, key=lambda arm: arm.lineno) + arm_marker = f"class {first_arm.name}(" + first_arm_position = source.find(arm_marker) + if first_arm_position < 0: + raise RuntimeError(f"{relative_path}: unable to locate {first_arm.name}") + arm_list = ",\n ".join(arm_names) + compatibility_base = f"""class {base_name}(ResponseArmDispatchMixin, AdcpVersionEnvelope, ProtocolEnvelope): + \"\"\"Constructible compatibility base for generated response arms.\"\"\" + + @classmethod + def _response_arm_models(cls) -> tuple[type[{base_name}], ...]: + return ( + {arm_list}, + ) + + +""" + if not stable_base_exists: + source = source[:first_arm_position] + compatibility_base + source[first_arm_position:] + else: + base_marker = f"class {base_name}(" + base_position = source.find(base_marker) + if base_position < 0: + raise RuntimeError(f"{relative_path}: unable to update {base_name} base") + source = source[:base_position] + compatibility_base + source[first_arm_position:] + + target.write_text(source.rstrip() + "\n") + print(f" {relative_path}: restored constructible {base_name} base") + + def restore_response_variant_aliases() -> None: """Restore numbered response arms from schema data, not hand-written payloads. @@ -5057,9 +5351,7 @@ def fix_audience_evidence_attestation_subject() -> None: break if subject_class is None: return - fixed_class = ( - "class AttestationRef(AttestationReference):\n" f" subject: {subject_class}\n\n" - ) + fixed_class = f"class AttestationRef(AttestationReference):\n subject: {subject_class}\n\n" fixed = source[:class_start] + fixed_class + source[next_class + 1 :] if fixed != source: target.write_text(fixed) @@ -5449,6 +5741,9 @@ def main(argv: list[str] | None = None): restore_format_asset_numbered_aliases, restore_principal_result_aliases, disambiguate_comply_response_arm, + restore_flattened_contract_field_types, + enforce_transformer_output_contract, + restore_constructible_response_bases, restore_response_variant_aliases, fix_compliance_task_completion_response_ref, restore_get_products_field_compatibility_enum, diff --git a/src/adcp/types/generated_poc/account/sync_governance_response.py b/src/adcp/types/generated_poc/account/sync_governance_response.py index 7f67fce5a..785cd8e32 100644 --- a/src/adcp/types/generated_poc/account/sync_governance_response.py +++ b/src/adcp/types/generated_poc/account/sync_governance_response.py @@ -4,6 +4,8 @@ from __future__ import annotations +from adcp.types.response_dispatch import ResponseArmDispatchMixin + from adcp.types._str_enum import StrEnum from typing import Annotated @@ -30,7 +32,18 @@ class GovernanceAgent(AdCPBaseModel): url: Annotated[AnyUrl, Field(description='Governance agent endpoint URL.')] -class SyncGovernanceResponse2(AdcpVersionEnvelope, ProtocolEnvelope): +class SyncGovernanceResponse(ResponseArmDispatchMixin, AdcpVersionEnvelope, ProtocolEnvelope): + """Constructible compatibility base for generated response arms.""" + + @classmethod + def _response_arm_models(cls) -> tuple[type[SyncGovernanceResponse], ...]: + return ( + SyncGovernanceResponse1, + SyncGovernanceResponse2, + ) + + +class SyncGovernanceResponse2(SyncGovernanceResponse): model_config = ConfigDict( extra='allow', ) @@ -74,13 +87,10 @@ class Account(AdCPBaseModel): ] = None -class SyncGovernanceResponse1(AdcpVersionEnvelope, ProtocolEnvelope): +class SyncGovernanceResponse1(SyncGovernanceResponse): model_config = ConfigDict( extra='allow', ) accounts: Annotated[list[Account], Field(description='Per-account sync results')] context: context_1.ContextObject | None = None ext: ext_1.ExtensionObject | None = None - - -SyncGovernanceResponse = SyncGovernanceResponse1 | SyncGovernanceResponse2 diff --git a/src/adcp/types/generated_poc/compliance/comply_test_controller_response.py b/src/adcp/types/generated_poc/compliance/comply_test_controller_response.py index bf000fc95..ad886d22e 100644 --- a/src/adcp/types/generated_poc/compliance/comply_test_controller_response.py +++ b/src/adcp/types/generated_poc/compliance/comply_test_controller_response.py @@ -4,6 +4,8 @@ from __future__ import annotations +from adcp.types.response_dispatch import ResponseArmDispatchMixin + from adcp.types._str_enum import StrEnum from typing import Annotated, Any, Literal @@ -297,7 +299,24 @@ class Error(StrEnum): INTERNAL_ERROR = 'INTERNAL_ERROR' -class ComplyTestControllerResponse1(AdcpVersionEnvelope, ProtocolEnvelope): +class ComplyTestControllerResponse(ResponseArmDispatchMixin, AdcpVersionEnvelope, ProtocolEnvelope): + """Constructible compatibility base for generated response arms.""" + + @classmethod + def _response_arm_models(cls) -> tuple[type[ComplyTestControllerResponse], ...]: + return ( + ComplyTestControllerResponse1, + ComplyTestControllerResponse2, + ComplyTestControllerResponse3, + ComplyTestControllerResponse4, + ComplyTestControllerResponse5, + ComplyTestControllerResponse6, + ComplyTestControllerResponse7, + ComplyTestControllerResponse8, + ) + + +class ComplyTestControllerResponse1(ComplyTestControllerResponse): model_config = ConfigDict( extra='allow', ) @@ -312,7 +331,7 @@ class ComplyTestControllerResponse1(AdcpVersionEnvelope, ProtocolEnvelope): ext: ext_1.ExtensionObject | None = None -class ComplyTestControllerResponse2(AdcpVersionEnvelope, ProtocolEnvelope): +class ComplyTestControllerResponse2(ComplyTestControllerResponse): model_config = ConfigDict( extra='allow', ) @@ -326,7 +345,7 @@ class ComplyTestControllerResponse2(AdcpVersionEnvelope, ProtocolEnvelope): ext: ext_1.ExtensionObject | None = None -class ComplyTestControllerResponse3(AdcpVersionEnvelope, ProtocolEnvelope): +class ComplyTestControllerResponse3(ComplyTestControllerResponse): model_config = ConfigDict( extra='allow', ) @@ -344,7 +363,7 @@ class ComplyTestControllerResponse3(AdcpVersionEnvelope, ProtocolEnvelope): ext: ext_1.ExtensionObject | None = None -class ComplyTestControllerResponse4(AdcpVersionEnvelope, ProtocolEnvelope): +class ComplyTestControllerResponse4(ComplyTestControllerResponse): model_config = ConfigDict( extra='allow', ) @@ -360,7 +379,7 @@ class ComplyTestControllerResponse4(AdcpVersionEnvelope, ProtocolEnvelope): ext: ext_1.ExtensionObject | None = None -class ComplyTestControllerResponse5(AdcpVersionEnvelope, ProtocolEnvelope): +class ComplyTestControllerResponse5(ComplyTestControllerResponse): model_config = ConfigDict( extra='allow', ) @@ -370,7 +389,7 @@ class ComplyTestControllerResponse5(AdcpVersionEnvelope, ProtocolEnvelope): ext: ext_1.ExtensionObject | None = None -class ComplyTestControllerResponse6(AdcpVersionEnvelope, ProtocolEnvelope): +class ComplyTestControllerResponse6(ComplyTestControllerResponse): model_config = ConfigDict( extra='allow', ) @@ -386,7 +405,7 @@ class ComplyTestControllerResponse6(AdcpVersionEnvelope, ProtocolEnvelope): ext: ext_1.ExtensionObject | None = None -class ComplyTestControllerResponse7(AdcpVersionEnvelope, ProtocolEnvelope): +class ComplyTestControllerResponse7(ComplyTestControllerResponse): model_config = ConfigDict( extra='allow', ) @@ -420,7 +439,7 @@ class ComplyTestControllerResponse7(AdcpVersionEnvelope, ProtocolEnvelope): ext: ext_1.ExtensionObject | None = None -class ComplyTestControllerResponse8(AdcpVersionEnvelope, ProtocolEnvelope): +class ComplyTestControllerResponse8(ComplyTestControllerResponse): model_config = ConfigDict( extra='allow', ) @@ -439,15 +458,3 @@ class ComplyTestControllerResponse8(AdcpVersionEnvelope, ProtocolEnvelope): ] = None context: context_1.ContextObject | None = None ext: ext_1.ExtensionObject | None = None - - -ComplyTestControllerResponse = ( - ComplyTestControllerResponse1 - | ComplyTestControllerResponse2 - | ComplyTestControllerResponse3 - | ComplyTestControllerResponse4 - | ComplyTestControllerResponse5 - | ComplyTestControllerResponse6 - | ComplyTestControllerResponse7 - | ComplyTestControllerResponse8 -) diff --git a/src/adcp/types/generated_poc/content_standards/create_content_standards_response.py b/src/adcp/types/generated_poc/content_standards/create_content_standards_response.py index 09049ae19..ec7719530 100644 --- a/src/adcp/types/generated_poc/content_standards/create_content_standards_response.py +++ b/src/adcp/types/generated_poc/content_standards/create_content_standards_response.py @@ -4,6 +4,8 @@ from __future__ import annotations +from adcp.types.response_dispatch import ResponseArmDispatchMixin + from typing import Annotated from pydantic import Field @@ -15,7 +17,18 @@ from ..core.version_envelope import AdcpVersionEnvelope -class CreateContentStandardsResponse1(AdcpVersionEnvelope, ProtocolEnvelope): +class CreateContentStandardsResponse(ResponseArmDispatchMixin, AdcpVersionEnvelope, ProtocolEnvelope): + """Constructible compatibility base for generated response arms.""" + + @classmethod + def _response_arm_models(cls) -> tuple[type[CreateContentStandardsResponse], ...]: + return ( + CreateContentStandardsResponse1, + CreateContentStandardsResponse2, + ) + + +class CreateContentStandardsResponse1(CreateContentStandardsResponse): standards_id: Annotated[ str, Field(description='Unique identifier for the created standards configuration') ] @@ -23,7 +36,7 @@ class CreateContentStandardsResponse1(AdcpVersionEnvelope, ProtocolEnvelope): ext: ext_1.ExtensionObject | None = None -class CreateContentStandardsResponse2(AdcpVersionEnvelope, ProtocolEnvelope): +class CreateContentStandardsResponse2(CreateContentStandardsResponse): errors: list[error.Error] conflicting_standards_id: Annotated[ str | None, @@ -33,6 +46,3 @@ class CreateContentStandardsResponse2(AdcpVersionEnvelope, ProtocolEnvelope): ] = None context: context_1.ContextObject | None = None ext: ext_1.ExtensionObject | None = None - - -CreateContentStandardsResponse = CreateContentStandardsResponse1 | CreateContentStandardsResponse2 diff --git a/src/adcp/types/generated_poc/content_standards/list_content_standards_response.py b/src/adcp/types/generated_poc/content_standards/list_content_standards_response.py index 6ec07cde4..3c1b9465f 100644 --- a/src/adcp/types/generated_poc/content_standards/list_content_standards_response.py +++ b/src/adcp/types/generated_poc/content_standards/list_content_standards_response.py @@ -4,6 +4,8 @@ from __future__ import annotations +from adcp.types.response_dispatch import ResponseArmDispatchMixin + from typing import Annotated from pydantic import Field @@ -17,13 +19,24 @@ from . import content_standards -class ListContentStandardsResponse2(AdcpVersionEnvelope, ProtocolEnvelope): +class ListContentStandardsResponse(ResponseArmDispatchMixin, AdcpVersionEnvelope, ProtocolEnvelope): + """Constructible compatibility base for generated response arms.""" + + @classmethod + def _response_arm_models(cls) -> tuple[type[ListContentStandardsResponse], ...]: + return ( + ListContentStandardsResponse1, + ListContentStandardsResponse2, + ) + + +class ListContentStandardsResponse2(ListContentStandardsResponse): errors: list[error.Error] context: context_1.ContextObject | None = None ext: ext_1.ExtensionObject | None = None -class ListContentStandardsResponse1(AdcpVersionEnvelope, ProtocolEnvelope): +class ListContentStandardsResponse1(ListContentStandardsResponse): standards: Annotated[ list[content_standards.ContentStandards], Field(description='Array of content standards configurations matching the filter criteria'), @@ -31,6 +44,3 @@ class ListContentStandardsResponse1(AdcpVersionEnvelope, ProtocolEnvelope): pagination: pagination_response.PaginationResponse | None = None context: context_1.ContextObject | None = None ext: ext_1.ExtensionObject | None = None - - -ListContentStandardsResponse = ListContentStandardsResponse1 | ListContentStandardsResponse2 diff --git a/src/adcp/types/generated_poc/content_standards/update_content_standards_response.py b/src/adcp/types/generated_poc/content_standards/update_content_standards_response.py index dd721ffaa..991d4d3b0 100644 --- a/src/adcp/types/generated_poc/content_standards/update_content_standards_response.py +++ b/src/adcp/types/generated_poc/content_standards/update_content_standards_response.py @@ -4,6 +4,8 @@ from __future__ import annotations +from adcp.types.response_dispatch import ResponseArmDispatchMixin + from typing import Annotated, Literal from pydantic import ConfigDict, Field @@ -15,7 +17,18 @@ from ..core.version_envelope import AdcpVersionEnvelope -class UpdateContentStandardsResponse1(AdcpVersionEnvelope, ProtocolEnvelope): +class UpdateContentStandardsResponse(ResponseArmDispatchMixin, AdcpVersionEnvelope, ProtocolEnvelope): + """Constructible compatibility base for generated response arms.""" + + @classmethod + def _response_arm_models(cls) -> tuple[type[UpdateContentStandardsResponse], ...]: + return ( + UpdateContentStandardsResponse1, + UpdateContentStandardsResponse2, + ) + + +class UpdateContentStandardsResponse1(UpdateContentStandardsResponse): model_config = ConfigDict( extra='allow', ) @@ -27,7 +40,7 @@ class UpdateContentStandardsResponse1(AdcpVersionEnvelope, ProtocolEnvelope): ext: ext_1.ExtensionObject | None = None -class UpdateContentStandardsResponse2(AdcpVersionEnvelope, ProtocolEnvelope): +class UpdateContentStandardsResponse2(UpdateContentStandardsResponse): model_config = ConfigDict( extra='allow', ) @@ -43,6 +56,3 @@ class UpdateContentStandardsResponse2(AdcpVersionEnvelope, ProtocolEnvelope): ] = None context: context_1.ContextObject | None = None ext: ext_1.ExtensionObject | None = None - - -UpdateContentStandardsResponse = UpdateContentStandardsResponse1 | UpdateContentStandardsResponse2 diff --git a/src/adcp/types/generated_poc/core/creative_representation.py b/src/adcp/types/generated_poc/core/creative_representation.py index a6b48e291..efcb1f1ac 100644 --- a/src/adcp/types/generated_poc/core/creative_representation.py +++ b/src/adcp/types/generated_poc/core/creative_representation.py @@ -7,8 +7,9 @@ from typing import Annotated, Any from adcp.types.base import AdCPBaseModel -from pydantic import ConfigDict, Field +from pydantic import ConfigDict, Field, model_validator +from .canonical_format_kind import CanonicalFormatKind from .creative_manifest import CreativeManifest @@ -32,6 +33,15 @@ class Source(AdCPBaseModel): class CreativeRepresentation(CreativeManifest): model_config = ConfigDict( extra='allow', + json_schema_extra={ + 'not': { + 'anyOf': [ + {'required': ['format_id']}, + {'required': ['format_option_ref']}, + {'required': ['representation_selection']}, + ] + } + }, ) representation_id: Annotated[ str, @@ -42,4 +52,22 @@ class CreativeRepresentation(CreativeManifest): ), ] source: Source - format_kind: Any + format_kind: Annotated[ + CanonicalFormatKind, + Field( + description="Canonical 3.2 path. The canonical format name this manifest targets (e.g., `image`, `video_hosted`, `audio_vast`, `seller_rendered_stateful_display`, `coordinated_placements`). Selects the contract against which the seller validates the manifest's assets. Mutually exclusive with deprecated `format_id`." + ), + ] + + @model_validator(mode='before') + @classmethod + def _reject_seller_bound_manifest_fields(cls, data: Any) -> Any: + """Representations cannot carry seller-side manifest selectors.""" + if isinstance(data, dict): + forbidden = ('format_id', 'format_option_ref', 'representation_selection') + present = [field for field in forbidden if field in data] + if present: + raise ValueError( + 'creative representations must not include ' + ', '.join(present) + ) + return data diff --git a/src/adcp/types/generated_poc/core/product_signal_targeting_option.py b/src/adcp/types/generated_poc/core/product_signal_targeting_option.py index c7d5a0fa1..9d39a5040 100644 --- a/src/adcp/types/generated_poc/core/product_signal_targeting_option.py +++ b/src/adcp/types/generated_poc/core/product_signal_targeting_option.py @@ -5,11 +5,11 @@ from __future__ import annotations from adcp.types._str_enum import StrEnum -from typing import Annotated, Any +from typing import Annotated from pydantic import ConfigDict, Field -from . import vendor_pricing_option +from . import signal_ref, vendor_pricing_option from .signal_listing import SignalListing @@ -65,4 +65,9 @@ class ProductSignalTargetingOption(SignalListing): min_length=1, ), ] = None - signal_ref: Any + signal_ref: Annotated[ + signal_ref.SignalRef, + Field( + description="Canonical signal reference. Use scope 'product' for a product-local signal defined by this listing; use scope 'data_provider' with data_provider_domain for a signal defined in a data provider's published adagents.json signals[]; use scope 'signal_source' with signal_source_url for a source-native signal." + ), + ] diff --git a/src/adcp/types/generated_poc/core/transformer.py b/src/adcp/types/generated_poc/core/transformer.py index fdaa52290..403bcd172 100644 --- a/src/adcp/types/generated_poc/core/transformer.py +++ b/src/adcp/types/generated_poc/core/transformer.py @@ -8,7 +8,7 @@ from typing import Annotated, Any, Literal from adcp.types.base import AdCPBaseModel -from pydantic import AnyUrl, ConfigDict, Field, RootModel +from pydantic import AnyUrl, ConfigDict, Field, RootModel, model_validator from ..enums import channels from ..formats.canonical import ( @@ -1867,3 +1867,17 @@ class Transformer(AdCPBaseModel): description="Optional per-transformer fan-out limits that NARROW the agent-level get_adcp_capabilities `creative.multiplicity`. Same shape as the agent-level object. When present, this transformer's authoritative; its ceilings (max_creatives_limit / max_variants_limit) MUST NOT exceed the agent ceilings, and its variant_dimensions MUST be a subset of the agent's. Omit to inherit the agent-level capability unchanged." ), ] = None + + @model_validator(mode='after') + def _require_output_format_declaration(self) -> Transformer: + """At least one output declaration is required by the schema.""" + # Read Pydantic's stored values directly so validation itself does not + # emit a deprecation warning for the still-supported legacy field. + if ( + self.__dict__.get('output_capability_ids') is None + and self.__dict__.get('output_format_ids') is None + ): + raise ValueError( + 'one of output_capability_ids or deprecated output_format_ids is required' + ) + return self diff --git a/src/adcp/types/response_dispatch.py b/src/adcp/types/response_dispatch.py new file mode 100644 index 000000000..074f7282c --- /dev/null +++ b/src/adcp/types/response_dispatch.py @@ -0,0 +1,135 @@ +"""Compatibility dispatch for constructible generated response bases.""" + +from __future__ import annotations + +from typing import Any, cast + +from pydantic import BaseModel, TypeAdapter +from typing_extensions import Self + + +def _model_validate_kwargs( + *, + strict: bool | None, + extra: Any, + from_attributes: bool | None, + context: Any, + by_alias: bool | None, + by_name: bool | None, +) -> dict[str, Any]: + """Build arguments accepted by both early and current Pydantic 2.x.""" + kwargs: dict[str, Any] = { + "strict": strict, + "from_attributes": from_attributes, + "context": context, + } + # These keywords were added after Pydantic 2.0. Omitting their default + # ``None`` retains the older model_validate() compatibility contract. + if extra is not None: + kwargs["extra"] = extra + if by_alias is not None: + kwargs["by_alias"] = by_alias + if by_name is not None: + kwargs["by_name"] = by_name + return kwargs + + +def _model_validate_json_kwargs( + *, + strict: bool | None, + extra: Any, + context: Any, + by_alias: bool | None, + by_name: bool | None, +) -> dict[str, Any]: + """Build JSON-validation arguments accepted across Pydantic 2.x.""" + kwargs: dict[str, Any] = {"strict": strict, "context": context} + if extra is not None: + kwargs["extra"] = extra + if by_alias is not None: + kwargs["by_alias"] = by_alias + if by_name is not None: + kwargs["by_name"] = by_name + return kwargs + + +class ResponseArmDispatchMixin: + """Validate a stable response base through its generated schema arms. + + Some public response names predate code generation emitting a union of + numbered response-arm models. The generated compatibility base keeps the + public name constructible, while this mixin makes ``Base.model_validate`` + preserve the arm-specific fields that arrived on the wire. + """ + + @classmethod + def _response_arm_models(cls) -> tuple[type[BaseModel], ...]: + """Return the generated models that define this response's wire arms.""" + return () + + @classmethod + def model_validate( + cls: type[Self], + obj: Any, + *, + strict: bool | None = None, + extra: Any = None, + from_attributes: bool | None = None, + context: Any = None, + by_alias: bool | None = None, + by_name: bool | None = None, + ) -> Self: + """Validate stable bases as their matching generated response arm.""" + if isinstance(obj, cls): + return obj + + arms = cls._response_arm_models() + kwargs = _model_validate_kwargs( + strict=strict, + extra=extra, + from_attributes=from_attributes, + context=context, + by_alias=by_alias, + by_name=by_name, + ) + # Concrete arms inherit this method too; their own API must retain + # single-arm validation rather than dispatching to a sibling arm. + if not arms or cls in cast(Any, arms): + parent_model = cast(Any, super()) + return cast(Self, parent_model.model_validate(obj, **kwargs)) + + union_type: Any = arms[0] + for arm in arms[1:]: + union_type |= arm + + return cast(Self, TypeAdapter(union_type).validate_python(obj, **kwargs)) + + @classmethod + def model_validate_json( + cls: type[Self], + json_data: str | bytes | bytearray, + *, + strict: bool | None = None, + extra: Any = None, + context: Any = None, + by_alias: bool | None = None, + by_name: bool | None = None, + ) -> Self: + """Validate JSON through response arms without dropping their fields.""" + arms = cls._response_arm_models() + kwargs = _model_validate_json_kwargs( + strict=strict, + extra=extra, + context=context, + by_alias=by_alias, + by_name=by_name, + ) + if not arms or cls in cast(Any, arms): + parent_model = cast(Any, super()) + return cast(Self, parent_model.model_validate_json(json_data, **kwargs)) + + union_type: Any = arms[0] + for arm in arms[1:]: + union_type |= arm + + return cast(Self, TypeAdapter(union_type).validate_json(json_data, **kwargs)) diff --git a/tests/test_code_generation.py b/tests/test_code_generation.py index c7dd32978..acffbb43e 100644 --- a/tests/test_code_generation.py +++ b/tests/test_code_generation.py @@ -94,6 +94,113 @@ def test_disambiguate_comply_response_arm_renames_class_and_references(tmp_path, assert "class Arm(" not in source +def test_post_generation_restores_codegen_contract_compatibility(tmp_path, monkeypatch): + """Known 0.64 flattening and response-union regressions stay repaired.""" + from scripts import post_generate_fixes + + core_dir = tmp_path / "core" + core_dir.mkdir() + (core_dir / "product_signal_targeting_option.py").write_text( + "from typing import Annotated, Any\n" + "from . import vendor_pricing_option\n" + "from .signal_listing import SignalListing\n" + "class ProductSignalTargetingOption(SignalListing):\n" + " signal_ref: Any\n" + ) + (core_dir / "creative_representation.py").write_text( + "from typing import Annotated, Any\n" + "from pydantic import ConfigDict, Field\n" + "from .creative_manifest import CreativeManifest\n" + "class CreativeRepresentation(CreativeManifest):\n" + " model_config = ConfigDict(\n" + " extra='allow',\n" + " )\n" + " format_kind: Any\n" + ) + (core_dir / "transformer.py").write_text( + "from pydantic import AnyUrl, ConfigDict, Field, RootModel\n" + "class Transformer(AdCPBaseModel):\n" + " pass\n" + ) + response_specs = ( + ("compliance/comply_test_controller_response.py", "ComplyTestControllerResponse"), + ( + "content_standards/create_content_standards_response.py", + "CreateContentStandardsResponse", + ), + ( + "content_standards/list_content_standards_response.py", + "ListContentStandardsResponse", + ), + ("account/sync_governance_response.py", "SyncGovernanceResponse"), + ( + "content_standards/update_content_standards_response.py", + "UpdateContentStandardsResponse", + ), + ) + for relative_path, response_name in response_specs: + target = tmp_path / relative_path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text( + "from __future__ import annotations\n\n" + "class " + response_name + "1(AdcpVersionEnvelope, ProtocolEnvelope):\n pass\n" + "class " + + response_name + + "2(AdcpVersionEnvelope, ProtocolEnvelope):\n pass\n" + + response_name + + " = " + + response_name + + "1 | " + + response_name + + "2\n" + ) + + monkeypatch.setattr(post_generate_fixes, "OUTPUT_DIR", tmp_path) + post_generate_fixes.restore_flattened_contract_field_types() + post_generate_fixes.enforce_transformer_output_contract() + post_generate_fixes.restore_constructible_response_bases() + # The functions are deliberately safe when the post-fix pass runs twice. + post_generate_fixes.restore_flattened_contract_field_types() + post_generate_fixes.enforce_transformer_output_contract() + post_generate_fixes.restore_constructible_response_bases() + + product_source = (core_dir / "product_signal_targeting_option.py").read_text() + assert "from . import signal_ref" in product_source + assert "signal_ref: Annotated[\n signal_ref.SignalRef," in product_source + assert "Canonical signal reference." in product_source + + representation_source = (core_dir / "creative_representation.py").read_text() + assert "from .canonical_format_kind import CanonicalFormatKind" in representation_source + assert "format_kind: Annotated[\n CanonicalFormatKind," in representation_source + assert "Canonical 3.2 path." in representation_source + assert "'representation_selection'" in representation_source + assert "@model_validator(mode='before')" in representation_source + + transformer_source = (core_dir / "transformer.py").read_text() + assert "@model_validator(mode='after')" in transformer_source + assert "output_capability_ids" in transformer_source + assert "output_format_ids" in transformer_source + + for relative_path, response_name in response_specs: + response_source = (tmp_path / relative_path).read_text() + dispatch_import = "from adcp.types.response_dispatch import ResponseArmDispatchMixin" + assert dispatch_import in response_source + assert ( + f"class {response_name}(ResponseArmDispatchMixin, " + "AdcpVersionEnvelope, ProtocolEnvelope):" in response_source + ) + assert f"class {response_name}1({response_name}):" in response_source + arm_method = ( + "def _response_arm_models(cls) -> tuple[type[" + response_name + "], ...]:\n" + " return (\n" + " " + response_name + "1,\n" + " " + response_name + "2,\n" + " )" + ) + assert arm_method in response_source + assert f"{response_name} =" not in response_source + + def test_normalize_enum_descriptions_preserves_enum_order(): """Description maps become the positional list expected by codegen 0.64+.""" from scripts.generate_types import normalize_enum_descriptions @@ -309,13 +416,13 @@ def test_rewrite_refs_preserves_macro_declaration_canonical_enum_ref(): from scripts.generate_types import rewrite_refs schema = { - "$ref": ("https://adcontextprotocol.org/schemas/3.2.0-beta.10/" "enums/macro-dialect.json") + "$ref": ("https://adcontextprotocol.org/schemas/3.2.0-beta.10/enums/macro-dialect.json") } rewrite_refs(schema, Path("core/macro-declaration.json")) assert schema["$ref"] == ( - "https://adcontextprotocol.org/schemas/3.2.0-beta.10/" "enums/macro-dialect.json" + "https://adcontextprotocol.org/schemas/3.2.0-beta.10/enums/macro-dialect.json" ) @@ -727,9 +834,7 @@ def test_post_generate_restores_combined_get_products_field_enum(tmp_path, monke " pass\n" ) (media_buy_dir / "product_fields.py").write_text( - "class ProductResponseField(StrEnum):\n" - " product_id = 'product_id'\n" - " name = 'name'\n" + "class ProductResponseField(StrEnum):\n product_id = 'product_id'\n name = 'name'\n" ) monkeypatch.setattr(post_generate_fixes, "OUTPUT_DIR", generated_dir) diff --git a/tests/test_codegen_contract_compatibility.py b/tests/test_codegen_contract_compatibility.py new file mode 100644 index 000000000..2dcd471bf --- /dev/null +++ b/tests/test_codegen_contract_compatibility.py @@ -0,0 +1,250 @@ +"""Runtime contracts retained by post-generation compatibility fixes.""" + +from __future__ import annotations + +import json + +import pytest +from pydantic import BaseModel, ValidationError + + +def test_response_dispatch_omits_newer_pydantic_keywords_at_their_defaults() -> None: + from adcp.types.response_dispatch import _model_validate_json_kwargs, _model_validate_kwargs + + assert _model_validate_kwargs( + strict=None, + extra=None, + from_attributes=None, + context=None, + by_alias=None, + by_name=None, + ) == {"strict": None, "from_attributes": None, "context": None} + assert _model_validate_kwargs( + strict=True, + extra="forbid", + from_attributes=True, + context={"trace_id": "trace_1"}, + by_alias=True, + by_name=False, + ) == { + "strict": True, + "extra": "forbid", + "from_attributes": True, + "context": {"trace_id": "trace_1"}, + "by_alias": True, + "by_name": False, + } + assert _model_validate_json_kwargs( + strict=None, + extra=None, + context=None, + by_alias=None, + by_name=None, + ) == {"strict": None, "context": None} + + +def test_product_signal_targeting_option_keeps_discriminated_signal_ref() -> None: + from adcp import ProductSignalTargetingOption + from adcp.types.generated_poc.core.signal_ref import SignalRef + + assert ProductSignalTargetingOption.model_fields["signal_ref"].annotation is SignalRef + assert ProductSignalTargetingOption.model_json_schema()["properties"]["signal_ref"][ + "description" + ].startswith("Canonical signal reference.") + + option = ProductSignalTargetingOption.model_validate( + {"signal_ref": {"scope": "product", "signal_id": "signal_1"}} + ) + assert isinstance(option.signal_ref, SignalRef) + assert option.signal_ref.scope == "product" + + for invalid_signal_ref in ( + {"scope": "unknown", "signal_id": "signal_1"}, + "signal_1", + ): + with pytest.raises(ValidationError): + ProductSignalTargetingOption.model_validate({"signal_ref": invalid_signal_ref}) + + +def test_creative_representation_keeps_canonical_format_contract() -> None: + from adcp import LegacyBuildCreativeRequest + from adcp.types import CanonicalFormatKind + from adcp.types.generated_poc.core.creative_representation import CreativeRepresentation + + assert CreativeRepresentation.model_fields["format_kind"].annotation is CanonicalFormatKind + schema = CreativeRepresentation.model_json_schema() + assert schema["properties"]["format_kind"]["description"].startswith("Canonical 3.2 path.") + assert schema["not"] == { + "anyOf": [ + {"required": ["format_id"]}, + {"required": ["format_option_ref"]}, + {"required": ["representation_selection"]}, + ] + } + + representation = { + "representation_id": "representation_1", + "source": {"system": "test", "source_representation": "source_1"}, + "format_kind": "image", + "assets": {}, + } + parsed_representation = CreativeRepresentation.model_validate(representation) + assert parsed_representation.format_kind is CanonicalFormatKind.image + + with pytest.raises(ValidationError): + CreativeRepresentation.model_validate({**representation, "format_kind": "unknown"}) + + for seller_bound_field in ("format_id", "format_option_ref", "representation_selection"): + with pytest.raises(ValidationError): + # JSON Schema's ``required`` considers a key present even when null. + CreativeRepresentation.model_validate({**representation, seller_bound_field: None}) + + representation_set = { + "creative_id": "creative_1", + "revision_id": "revision_1", + "revision_content_digest": "sha256:" + "0" * 64, + "name": "Test creative", + "representations": [{**representation, "format_kind": "unknown"}], + } + with pytest.raises(ValidationError): + LegacyBuildCreativeRequest.model_validate( + { + "idempotency_key": "idem-123456789012", + "creative_representation_set": representation_set, + } + ) + + +def test_transformer_requires_a_canonical_or_legacy_output_declaration() -> None: + from adcp.types import ListTransformersResponse + from adcp.types.generated_poc.core.transformer import Transformer + + base_transformer = {"transformer_id": "transformer_1", "name": "Test transformer"} + with pytest.raises(ValidationError): + ListTransformersResponse.model_validate({"transformers": [base_transformer]}) + + assert ( + Transformer.model_validate( + {**base_transformer, "output_capability_ids": ["capability_1"]} + ).output_capability_ids + is not None + ) + assert ( + Transformer.model_validate( + { + **base_transformer, + "output_format_ids": [{"agent_url": "https://creative.example", "id": "format_1"}], + } + ).model_dump()["output_format_ids"] + is not None + ) + + +def test_public_response_bases_remain_constructible_and_arms_remain_specific() -> None: + from adcp.types import ( + ComplyTestControllerResponse, + CreateContentStandardsResponse, + ListContentStandardsResponse, + SyncGovernanceResponse, + UpdateContentStandardsResponse, + ) + from adcp.types.aliases import ( + ComplyListScenariosResponse, + CreateContentStandardsSuccessResponse, + ListContentStandardsSuccessResponse, + UpdateContentStandardsSuccessResponse, + ) + from adcp.types.generated_poc.account.sync_governance_response import SyncGovernanceResponse1 + from adcp.types.generated_poc.compliance.comply_test_controller_response import ( + ComplyTestControllerResponse1, + ) + from adcp.types.generated_poc.content_standards.create_content_standards_response import ( + CreateContentStandardsResponse1, + ) + from adcp.types.generated_poc.content_standards.list_content_standards_response import ( + ListContentStandardsResponse1, + ) + from adcp.types.generated_poc.content_standards.update_content_standards_response import ( + UpdateContentStandardsResponse1, + ) + from adcp.utils.response_parser import parse_json_or_text + + responses = ( + ( + ComplyTestControllerResponse, + ComplyListScenariosResponse, + ComplyTestControllerResponse1, + {"success": True, "scenarios": []}, + {"success": True}, + {"success": "invalid"}, + {"scenarios": []}, + ), + ( + CreateContentStandardsResponse, + CreateContentStandardsSuccessResponse, + CreateContentStandardsResponse1, + {"standards_id": "standards_1"}, + {}, + {}, + {"standards_id": "standards_1"}, + ), + ( + ListContentStandardsResponse, + ListContentStandardsSuccessResponse, + ListContentStandardsResponse1, + {"standards": []}, + {}, + {}, + {"standards": []}, + ), + ( + SyncGovernanceResponse, + SyncGovernanceResponse1, + SyncGovernanceResponse1, + {"accounts": []}, + {}, + {}, + {"accounts": []}, + ), + ( + UpdateContentStandardsResponse, + UpdateContentStandardsSuccessResponse, + UpdateContentStandardsResponse1, + {"success": True, "standards_id": "standards_1"}, + {"success": False, "standards_id": "standards_1"}, + {"success": False, "standards_id": "standards_1"}, + {"standards_id": "standards_1"}, + ), + ) + + for ( + response_base, + response_alias, + response_arm, + valid, + invalid_arm, + invalid_base, + preserved_fields, + ) in responses: + assert issubclass(response_base, BaseModel) + assert isinstance(response_base(), response_base) + assert response_alias is response_arm + assert issubclass(response_arm, response_base) + assert isinstance(response_alias.model_validate(valid), response_base) + + direct = response_base.model_validate(valid) + direct_json = response_base.model_validate_json(json.dumps(valid)) + parsed = parse_json_or_text(valid, response_base) + for result in (direct, direct_json, parsed): + assert type(result) is response_arm + assert all(getattr(result, name) == value for name, value in preserved_fields.items()) + + empty_base = response_base() + assert response_base.model_validate(empty_base) is empty_base + + with pytest.raises(ValidationError): + response_alias.model_validate(invalid_arm) + with pytest.raises(ValidationError): + response_base.model_validate(invalid_base) + with pytest.raises(ValueError): + parse_json_or_text(invalid_base, response_base)