From f80d26d471c5ed2ced9e67e91a1ef6ef6e56384c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 12:31:00 +0000 Subject: [PATCH 1/5] build(deps-dev): bump datamodel-code-generator Bumps the pip group with 1 update in the / directory: [datamodel-code-generator](https://github.com/koxudaxi/datamodel-code-generator). Updates `datamodel-code-generator` from 0.56.1 to 0.64.0 - [Release notes](https://github.com/koxudaxi/datamodel-code-generator/releases) - [Changelog](https://github.com/koxudaxi/datamodel-code-generator/blob/main/CHANGELOG.md) - [Commits](https://github.com/koxudaxi/datamodel-code-generator/compare/0.56.1...0.64.0) --- updated-dependencies: - dependency-name: datamodel-code-generator dependency-version: 0.64.0 dependency-type: direct:development dependency-group: pip ... Signed-off-by: dependabot[bot] --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index bb0987b9c..eb0eb45c6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -125,7 +125,7 @@ dev = [ # Pin to exact version: codegen's variant numbering (e.g. CreateMediaBuyResponse1 vs # CreateMediaBuyResponse) shifts between versions, producing diff churn and breaking # generated-code imports that reference specific suffixes. - "datamodel-code-generator[http]==0.56.1", + "datamodel-code-generator[http]==0.64.0", # Runs Starlette app lifespan under httpx.ASGITransport in tests — # the canonical library for what httpx doesn't do natively. Used by # tests/test_mcp_middleware_composition.py and future integration @@ -340,7 +340,7 @@ skips = ["B101"] # Allow assert in code (we're not using -O optimization) [dependency-groups] dev = [ - "datamodel-code-generator==0.56.1", + "datamodel-code-generator==0.64.0", "pre-commit>=4.4.0", "types-protobuf>=7.34.1.20260408", # Pinned in the dev group so ``uv run mypy`` (used by the pre-commit From aa795bc8dcc479588ea650d356567387167a3007 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sat, 5 Sep 2026 14:45:15 +0000 Subject: [PATCH 2/5] fix(codegen): support secure generator upgrade --- .github/workflows/ci.yml | 1 - pyproject.toml | 4 +- scripts/collision_allowlist.json | 1 + scripts/generate_ergonomic_coercion.py | 7 +- scripts/generate_types.py | 115 ++++++++++++++++- scripts/post_generate_fixes.py | 65 ++++++++-- tests/test_code_generation.py | 165 +++++++++++++++++++++++++ 7 files changed, 337 insertions(+), 21 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3ebdbf43d..89d5ec98b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -372,7 +372,6 @@ jobs: run: python scripts/bundle_schemas.py - name: Generate models - if: steps.version-check.outputs.is_prerelease != 'true' run: python scripts/generate_types.py - name: Validate generated code syntax diff --git a/pyproject.toml b/pyproject.toml index eb0eb45c6..125c81353 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -125,7 +125,7 @@ dev = [ # Pin to exact version: codegen's variant numbering (e.g. CreateMediaBuyResponse1 vs # CreateMediaBuyResponse) shifts between versions, producing diff churn and breaking # generated-code imports that reference specific suffixes. - "datamodel-code-generator[http]==0.64.0", + "datamodel-code-generator[http]==0.63.0", # Runs Starlette app lifespan under httpx.ASGITransport in tests — # the canonical library for what httpx doesn't do natively. Used by # tests/test_mcp_middleware_composition.py and future integration @@ -340,7 +340,7 @@ skips = ["B101"] # Allow assert in code (we're not using -O optimization) [dependency-groups] dev = [ - "datamodel-code-generator==0.64.0", + "datamodel-code-generator==0.63.0", "pre-commit>=4.4.0", "types-protobuf>=7.34.1.20260408", # Pinned in the dev group so ``uv run mypy`` (used by the pre-commit diff --git a/scripts/collision_allowlist.json b/scripts/collision_allowlist.json index 6100bc81d..940fd37e1 100644 --- a/scripts/collision_allowlist.json +++ b/scripts/collision_allowlist.json @@ -19,6 +19,7 @@ "Amount", "AppliesToEnum", "Area", + "Arm", "Art9Basis", "Artifact", "AssetSource", diff --git a/scripts/generate_ergonomic_coercion.py b/scripts/generate_ergonomic_coercion.py index 87fba5245..01802005b 100644 --- a/scripts/generate_ergonomic_coercion.py +++ b/scripts/generate_ergonomic_coercion.py @@ -419,9 +419,10 @@ def _find_media_buy_success_variant(module: Any) -> type[_PydBaseModel] | None: lines.append(f"from adcp.types.generated_poc.{path} import {name}") # Add request type imports - lines.append("from adcp.types.generated_poc.media_buy.create_media_buy_request import (") - lines.append(" CreateMediaBuyRequest,") - lines.append(")") + if "CreateMediaBuyRequest" in all_coercions: + lines.append("from adcp.types.generated_poc.media_buy.create_media_buy_request import (") + lines.append(" CreateMediaBuyRequest,") + lines.append(")") lines.append("from adcp.types.generated_poc.media_buy.get_products_request import (") lines.append(" GetProductsRequest,") lines.append(")") diff --git a/scripts/generate_types.py b/scripts/generate_types.py index aae0ae259..d281dde8a 100755 --- a/scripts/generate_types.py +++ b/scripts/generate_types.py @@ -151,6 +151,29 @@ def _is_macro_schema(path: Path) -> bool: ) +def normalize_enum_descriptions(obj): + """Normalize OpenAPI-style enum description maps for code generation. + + AdCP schemas key ``x-enum-descriptions`` by enum value. Newer + datamodel-code-generator releases validate that extension as a positional + list, so translate the map in enum order in the temporary schema tree. + The published schema cache remains byte-for-byte unchanged. + """ + if isinstance(obj, dict): + descriptions = obj.get("x-enum-descriptions") + enum_values = obj.get("enum") + if isinstance(descriptions, dict) and isinstance(enum_values, list): + obj["x-enum-descriptions"] = [ + str(descriptions.get(str(enum_value), "")) for enum_value in enum_values + ] + for value in obj.values(): + normalize_enum_descriptions(value) + elif isinstance(obj, list): + for item in obj: + normalize_enum_descriptions(item) + return obj + + def rewrite_refs(obj, current_schema_rel_path: Path): """ Recursively rewrite $ref paths: @@ -303,8 +326,20 @@ def flatten_validation_oneof(schema: dict) -> dict: if not branches: return schema - # All branches must contain only 'required' (and optionally 'not') - if not all(set(b.keys()) <= {"required", "not"} for b in branches): + # Alternative annotations do not turn a required-field constraint into a + # distinct object shape. + validation_branch_keys = { + "$comment", + "deprecated", + "description", + "examples", + "not", + "required", + "title", + } + if not all( + isinstance(branch, dict) and set(branch) <= validation_branch_keys for branch in branches + ): return schema # All branches are required-only — this is a validation constraint, not a type union @@ -332,6 +367,75 @@ def flatten_validation_oneof(schema: dict) -> dict: return schema +_ROOT_OBJECT_VALIDATION_UNIONS = { + Path("account/list-account-changes-response.json"), + Path("brand/search-brands-response.json"), + Path("brand/verify-brand-claim-request.json"), + Path("core/product.json"), + Path("media-buy/buy-products-request.json"), + Path("media-buy/create-media-buy-request.json"), + Path("media-buy/get-reporting-status-response.json"), + Path("media-buy/request-proposals-request.json"), +} + + +def flatten_root_object_validation_union(schema: dict, schema_path: Path) -> dict: + """Keep known root object overlays as concrete, subclassable models. + + These schemas put their shared object envelope at the root and use a + root-level ``oneOf``/``anyOf`` only for cross-field validation. codegen + 0.63+ emits a ``RootModel`` union for that pattern, which breaks the SDK's + public subclassability contract. Merge branch-only fields conservatively; + nested unions remain untouched and runtime validators continue to enforce + the cross-field rules that the older generator also could not express. + """ + if schema_path not in _ROOT_OBJECT_VALIDATION_UNIONS or schema.get("type") != "object": + return schema + + branch_key = next((key for key in ("anyOf", "oneOf") if key in schema), None) + if branch_key is None: + return schema + branches = schema[branch_key] + if ( + not isinstance(branches, list) + or not branches + or not all(isinstance(branch, dict) for branch in branches) + ): + return schema + + properties = dict(schema.get("properties", {})) + branch_properties: dict[str, list[dict]] = {} + for branch in branches: + for name, definition in branch.get("properties", {}).items(): + if name not in properties and isinstance(definition, dict): + branch_properties.setdefault(name, []).append(definition) + + for name, definitions in branch_properties.items(): + unique = {json.dumps(definition, sort_keys=True): definition for definition in definitions} + variants = list(unique.values()) + if len(variants) == 1: + properties[name] = variants[0] + elif all("const" in variant for variant in variants): + properties[name] = {"enum": [variant["const"] for variant in variants]} + elif all(variant.get("type") == "object" for variant in variants): + properties[name] = {"type": "object", "additionalProperties": True} + else: + properties[name] = {"anyOf": variants} + + schema["properties"] = properties + top_required = set(schema.get("required", [])) + branch_required = [set(branch.get("required", [])) for branch in branches] + common_required = set.intersection(*branch_required) if branch_required else set() + required = sorted(top_required | common_required) + if required: + schema["required"] = required + else: + schema.pop("required", None) + del schema[branch_key] + print(f" flattened root {branch_key} object overlay in {schema_path}") + return schema + + def flatten_schemas(temp_dir: Path): """ Copy schemas to temp directory, preserving directory structure. @@ -394,12 +498,14 @@ def flatten_schemas(temp_dir: Path): # generated convenience model omits this field. properties.pop("formats", None) - # Rewrite $ref paths: convert absolute paths to relative, hyphens to underscores + # Normalize generator-specific extensions, then rewrite $ref paths. + schema = normalize_enum_descriptions(schema) schema = rewrite_refs(schema, rel_path) schema = stabilize_inlined_core_refs(schema, rel_path) schema = stabilize_nested_discriminators(schema, rel_path) - # Flatten validation-only anyOf/oneOf into single-class schemas + # Flatten validation-only anyOf/oneOf into single-class schemas. + schema = flatten_root_object_validation_union(schema, rel_path) schema = flatten_validation_oneof(schema) with open(output_file, "w") as f: @@ -568,6 +674,7 @@ def generate_root_discovery_types(input_dir: Path, output_dir: Path = OUTPUT_DIR source = SCHEMAS_DIR / schema_rel_path prepared = input_dir / f"_{schema_rel_path.stem}_discovery.json" schema = json.loads(source.read_text()) + schema = normalize_enum_descriptions(schema) schema = rewrite_refs(schema, schema_rel_path) schema = flatten_validation_oneof(schema) prepared.write_text(json.dumps(schema, indent=2)) diff --git a/scripts/post_generate_fixes.py b/scripts/post_generate_fixes.py index 30d269a9d..c160ac7d1 100644 --- a/scripts/post_generate_fixes.py +++ b/scripts/post_generate_fixes.py @@ -1398,21 +1398,22 @@ def _intersection_field(field: str, keep_base: str, all_bases: list[str]) -> str # datamodel-codegen expands a discriminated union intersected by # allOf into one merge class per union branch. When multiple - # bases pin the conventional ``type`` discriminator, its first - # base is the generated union branch and the other base is the - # common allOf constraint. Preserve that branch identity so the - # emitted discriminated union retains one class per discriminator - # value; this is a distinct generator artifact, not an ordering - # decision between a loose and narrow schema arm. - union_branch_base = ( - in_module_bases[0] + # bases pin a discriminator (commonly ``type`` or ``mode``), its + # first base is the generated union branch and the other base is + # the common allOf constraint. Preserve that branch identity so + # the emitted discriminated union retains one class per + # discriminator value; this is a distinct generator artifact, + # not an ordering decision between a loose and narrow schema arm. + literal_conflicts = [ + field + for field in conflicting_fields if sum( - _literal_values(annotations_by_class[base].get("type", "")) is not None + _literal_values(annotations_by_class[base].get(field, "")) is not None for base in in_module_bases ) >= 2 - else None - ) + ] + union_branch_base = in_module_bases[0] if literal_conflicts else None # JSON Schema allOf is order-independent. Codegen's base ordering # is not a semantic signal, so select the arm that actually @@ -1524,6 +1525,19 @@ def _intersection_field(field: str, keep_base: str, all_bases: list[str]) -> str insert_before.setdefault(first_field_line, []).extend( intersection_fields[field] for field in sorted(missing_intersections) ) + + # A merge wrapper can consist entirely of redundant field + # re-declarations. If collapsing the bases removes every body + # statement, retain a syntactically valid empty class. + body_has_surviving_statement = any( + any( + line_no not in drop_lines + for line_no in range(stmt.lineno, (stmt.end_lineno or stmt.lineno) + 1) + ) + for stmt in cls.body + ) + if not body_has_surviving_statement and not missing_intersections: + insert_before.setdefault(cls.body[0].lineno, []).append(" pass\n") file_classes += 1 if not header_edits and not drop_lines: @@ -4545,6 +4559,35 @@ def fix_list_creatives_format_reference_xor() -> None: return source = target.read_text() + if "class Creative(AdCPBaseModel):" in source and "class Creatives(" not in source: + if "Creatives = Creative\nCreatives1 = Creative" in source: + print(" creative/list_creatives_response.py: merged creative XOR already fixed") + return + + source = source.replace( + "from pydantic import AwareDatetime, ConfigDict, Field, RootModel, StringConstraints", + "from pydantic import AwareDatetime, ConfigDict, Field, RootModel, StringConstraints, model_validator", + 1, + ) + merged_validator = """ + + @model_validator(mode='after') + def _validate_format_reference_xor(self) -> Creative: + if (self.format_id is None) == (self.format_kind is None): + raise ValueError('exactly one of format_id and format_kind is required') + return self + + +Creatives = Creative +Creatives1 = Creative +""" + marker = "\n\nclass ListCreativesResponse(AdcpVersionEnvelope, ProtocolEnvelope):" + if marker not in source: + raise RuntimeError("ListCreativesResponse marker missing from merged creative output") + target.write_text(source.replace(marker, merged_validator + marker, 1)) + print(" creative/list_creatives_response.py: restored merged creative XOR and aliases") + return + if "_reject_canonical_format_ref" in source and "_reject_legacy_format_ref" in source: print(" creative/list_creatives_response.py: format reference XOR already fixed") return diff --git a/tests/test_code_generation.py b/tests/test_code_generation.py index 30d0bda3a..b40eec0d4 100644 --- a/tests/test_code_generation.py +++ b/tests/test_code_generation.py @@ -14,6 +14,114 @@ import pytest +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 + + schema = { + "enum": ["binary", "categorical", "numeric"], + "x-enum-descriptions": { + "numeric": "Continuous value", + "binary": "Boolean value", + "categorical": "Discrete value", + }, + } + + assert normalize_enum_descriptions(schema)["x-enum-descriptions"] == [ + "Boolean value", + "Discrete value", + "Continuous value", + ] + + +def test_normalize_enum_descriptions_recurses_into_embedded_schemas(): + from scripts.generate_types import normalize_enum_descriptions + + schema = { + "$defs": { + "state": { + "enum": ["ready", "done"], + "x-enum-descriptions": {"done": "Finished", "ready": "Available"}, + } + } + } + + normalize_enum_descriptions(schema) + + assert schema["$defs"]["state"]["x-enum-descriptions"] == ["Available", "Finished"] + + +def test_flatten_validation_oneof_accepts_branch_annotations(): + from scripts.generate_types import flatten_validation_oneof + + schema = { + "title": "Request", + "type": "object", + "properties": {"first": {"type": "string"}, "second": {"type": "string"}}, + "required": ["mode"], + "anyOf": [ + { + "title": "First mode", + "description": "Requires a payload.", + "required": ["first"], + }, + { + "title": "Second mode", + "deprecated": True, + "required": ["second"], + }, + ], + } + + flattened = flatten_validation_oneof(schema) + + assert "anyOf" not in flattened + assert flattened["required"] == ["mode"] + + +def test_flatten_known_root_object_union_merges_branch_only_fields(): + from scripts.generate_types import flatten_root_object_validation_union + + schema = { + "type": "object", + "allOf": [{"$ref": "envelope.json"}], + "properties": {"context": {"type": "object"}}, + "oneOf": [ + { + "properties": {"status": {"const": "completed"}, "result": {"type": "string"}}, + "required": ["status", "result"], + }, + { + "properties": {"status": {"const": "failed"}, "errors": {"type": "array"}}, + "required": ["status", "errors"], + }, + ], + } + + flattened = flatten_root_object_validation_union( + schema, Path("account/list-account-changes-response.json") + ) + + assert "oneOf" not in flattened + assert flattened["properties"]["status"] == {"enum": ["completed", "failed"]} + assert set(flattened["properties"]) == {"context", "status", "result", "errors"} + assert flattened["required"] == ["status"] + assert flattened["allOf"] == [{"$ref": "envelope.json"}] + + +def test_flatten_root_object_union_ignores_unlisted_schema(): + from scripts.generate_types import flatten_root_object_validation_union + + schema = { + "type": "object", + "properties": {"kind": {"type": "string"}}, + "oneOf": [{"properties": {"kind": {"const": "a"}}}], + } + + assert flatten_root_object_validation_union(schema, Path("core/real-union.json")) is schema + assert "oneOf" in schema + + def test_rewrite_refs_localizes_canonical_schema_urls_without_corrupting_prerelease(): """Canonical absolute refs become local module paths before normalization.""" from scripts.generate_types import rewrite_refs @@ -337,6 +445,32 @@ def test_allof_merge_leaves_named_disjoint_bases_untouched(tmp_path, monkeypatch assert target.read_text() == source +def test_allof_merge_preserves_non_type_literal_discriminator_branch(tmp_path, monkeypatch): + from scripts import post_generate_fixes + + generated_dir = tmp_path / "generated_poc" + target = generated_dir / "core" / "budget_allocation.py" + target.parent.mkdir(parents=True) + target.write_text( + "from typing import Literal\n\n" + "class Fixed:\n" + " mode: Literal['fixed']\n\n" + "class Percentage:\n" + " mode: Literal['percentage']\n\n" + "class FixedBranch(Fixed, Percentage):\n" + " mode: Literal['fixed']\n" + ) + monkeypatch.setattr(post_generate_fixes, "OUTPUT_DIR", generated_dir) + + post_generate_fixes.fix_allof_merge_field_override_conflicts() + + fixed = target.read_text() + assert "class FixedBranch(Fixed):" in fixed + assert " mode: Literal['fixed']" not in fixed.split("class FixedBranch", 1)[1] + assert "class FixedBranch(Fixed):\n pass\n" in fixed + compile(fixed, str(target), "exec") + + def test_allof_merge_fails_when_narrow_base_is_ambiguous(tmp_path, monkeypatch): import pytest @@ -359,6 +493,37 @@ def test_allof_merge_fails_when_narrow_base_is_ambiguous(tmp_path, monkeypatch): post_generate_fixes.fix_allof_merge_field_override_conflicts() +def test_list_creatives_merged_model_restores_xor_and_legacy_aliases(tmp_path, monkeypatch): + from scripts import post_generate_fixes + + generated_dir = tmp_path / "generated_poc" + target = generated_dir / "creative" / "list_creatives_response.py" + target.parent.mkdir(parents=True) + target.write_text( + "from __future__ import annotations\n\n" + "from pydantic import AwareDatetime, ConfigDict, Field, RootModel, StringConstraints\n\n" + "class AdCPBaseModel:\n" + " pass\n\n" + "class AdcpVersionEnvelope:\n" + " pass\n\n" + "class ProtocolEnvelope:\n" + " pass\n\n" + "class Creative(AdCPBaseModel):\n" + " format_id = None\n" + " format_kind = None\n\n" + "class ListCreativesResponse(AdcpVersionEnvelope, ProtocolEnvelope):\n" + " pass\n" + ) + monkeypatch.setattr(post_generate_fixes, "OUTPUT_DIR", generated_dir) + + post_generate_fixes.fix_list_creatives_format_reference_xor() + + fixed = target.read_text() + assert "def _validate_format_reference_xor(self) -> Creative:" in fixed + assert "Creatives = Creative\nCreatives1 = Creative" in fixed + compile(fixed, str(target), "exec") + + def test_allof_merge_preserves_concrete_type_constraints_and_requiredness(tmp_path, monkeypatch): import pytest from pydantic import ValidationError From 2cec9536dbf8b8e9838ba9a0650cd89bd48dc7be Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sat, 5 Sep 2026 14:58:18 +0000 Subject: [PATCH 3/5] fix(codegen): stabilize generated principal aliases --- scripts/generate_types.py | 6 +- scripts/post_generate_fixes.py | 64 +++++++++++++++++++ src/adcp/types/aliases.py | 24 ++----- .../protocol/get_principal_response.py | 7 ++ .../protocol/sync_principal_response.py | 6 ++ tests/test_code_generation.py | 56 ++++++++++++++++ 6 files changed, 145 insertions(+), 18 deletions(-) diff --git a/scripts/generate_types.py b/scripts/generate_types.py index d281dde8a..4cc0a2bca 100755 --- a/scripts/generate_types.py +++ b/scripts/generate_types.py @@ -458,7 +458,11 @@ def flatten_schemas(temp_dir: Path): temp_dir.mkdir() # Recursively find all JSON schemas (including subdirectories) - schema_files = list(SCHEMAS_DIR.rglob("*.json")) + # The generator assigns numeric suffixes while traversing this aggregate + # input tree. Path.rglob() follows filesystem insertion order, which + # differs between developer machines and fresh CI checkouts. Create the + # temporary tree in a stable order so generated names are reproducible. + schema_files = sorted(SCHEMAS_DIR.rglob("*.json")) # Skip the top-level index.json schema_files = [f for f in schema_files if f.name != "index.json"] schema_files = [ diff --git a/scripts/post_generate_fixes.py b/scripts/post_generate_fixes.py index c160ac7d1..330d9bfb0 100644 --- a/scripts/post_generate_fixes.py +++ b/scripts/post_generate_fixes.py @@ -3256,6 +3256,69 @@ def restore_format_asset_numbered_aliases() -> None: print(f" core/format.py: restored Assets94 -> {repeatable_class}") +def restore_principal_result_aliases() -> None: + """Expose principal result arms by discriminator instead of numeric suffix. + + datamodel-code-generator numbers anonymous ``result`` variants according + to aggregate schema traversal order. Those numbers are implementation + details and can differ across generator versions or filesystem order. + """ + specs = { + "protocol/get_principal_response.py": { + "PrincipalUnconfiguredResult": "unconfigured", + "PrincipalCurrentResult": "current", + "PrincipalRecognizedResult": "recognized", + "PrincipalReadFailedResult": "failed", + }, + "protocol/sync_principal_response.py": { + "PrincipalValidatedResult": "validated", + "PrincipalAppliedResult": "applied", + "PrincipalSyncFailedResult": "failed", + }, + } + + for relative_path, aliases in specs.items(): + target = OUTPUT_DIR / relative_path + if not target.exists(): + print(f" {relative_path} not found (skipping principal result aliases)") + continue + + source = target.read_text() + tree = ast.parse(source) + classes_by_kind: dict[str, str] = {} + for node in tree.body: + if not isinstance(node, ast.ClassDef): + continue + for stmt in node.body: + if ( + isinstance(stmt, ast.AnnAssign) + and isinstance(stmt.target, ast.Name) + and stmt.target.id == "kind" + ): + kind = _extract_single_literal_value(stmt.annotation) + if isinstance(kind, str): + classes_by_kind[kind] = node.name + break + + missing = sorted(set(aliases.values()) - classes_by_kind.keys()) + if missing: + raise RuntimeError(f"{relative_path}: principal result kinds not generated: {missing}") + + assignments = [f"{alias} = {classes_by_kind[kind]}" for alias, kind in aliases.items()] + marker = assignments[0] + if marker in source: + print(f" {relative_path}: principal result aliases already restored") + continue + + target.write_text( + source.rstrip() + + "\n\n\n# Stable aliases for anonymous result arms (selected by discriminator).\n" + + "\n".join(assignments) + + "\n" + ) + print(f" {relative_path}: restored {len(assignments)} principal result aliases") + + def restore_response_variant_aliases() -> None: """Restore numbered response arms from schema data, not hand-written payloads. @@ -5241,6 +5304,7 @@ def main(argv: list[str] | None = None): restore_format_category_deprecation_shim, restore_signal_catalog_type_alias, restore_format_asset_numbered_aliases, + restore_principal_result_aliases, restore_response_variant_aliases, fix_compliance_task_completion_response_ref, restore_get_products_field_compatibility_enum, diff --git a/src/adcp/types/aliases.py b/src/adcp/types/aliases.py index cad1d06e1..e55f07baa 100644 --- a/src/adcp/types/aliases.py +++ b/src/adcp/types/aliases.py @@ -145,28 +145,18 @@ from adcp.types.generated_poc.core.product_allocation import ProductAllocation from adcp.types.generated_poc.core.signal_coverage_forecast import SignalCoverageForecast from adcp.types.generated_poc.protocol.get_principal_response import ( - Result as PrincipalUnconfiguredResult, -) -from adcp.types.generated_poc.protocol.get_principal_response import ( - Result6 as PrincipalCurrentResult, -) -from adcp.types.generated_poc.protocol.get_principal_response import ( - Result7 as PrincipalRecognizedResult, -) -from adcp.types.generated_poc.protocol.get_principal_response import ( - Result9 as PrincipalReadFailedResult, + PrincipalCurrentResult, + PrincipalReadFailedResult, + PrincipalRecognizedResult, + PrincipalUnconfiguredResult, ) from adcp.types.generated_poc.protocol.sync_principal_request import ( Configuration as PrincipalConfiguration, ) from adcp.types.generated_poc.protocol.sync_principal_response import ( - Result as PrincipalValidatedResult, -) -from adcp.types.generated_poc.protocol.sync_principal_response import ( - Result17 as PrincipalAppliedResult, -) -from adcp.types.generated_poc.protocol.sync_principal_response import ( - Result19 as PrincipalSyncFailedResult, + PrincipalAppliedResult, + PrincipalSyncFailedResult, + PrincipalValidatedResult, ) from adcp.types.generated_poc.core.vendor_pricing_option import ( VendorPricingOption as VendorPricingOptionUnion, diff --git a/src/adcp/types/generated_poc/protocol/get_principal_response.py b/src/adcp/types/generated_poc/protocol/get_principal_response.py index 3cf6367e8..ec131282d 100644 --- a/src/adcp/types/generated_poc/protocol/get_principal_response.py +++ b/src/adcp/types/generated_poc/protocol/get_principal_response.py @@ -91,3 +91,10 @@ class GetPrincipalResponse(AdcpVersionEnvelope, ProtocolEnvelope): result: Result6 | Result7 | Result | Result9 context: context_1.ContextObject | None = None ext: ext_1.ExtensionObject | None = None + + +# Stable aliases for anonymous result arms (selected by discriminator). +PrincipalUnconfiguredResult = Result +PrincipalCurrentResult = Result6 +PrincipalRecognizedResult = Result7 +PrincipalReadFailedResult = Result9 diff --git a/src/adcp/types/generated_poc/protocol/sync_principal_response.py b/src/adcp/types/generated_poc/protocol/sync_principal_response.py index 918e9941d..06bf1ec64 100644 --- a/src/adcp/types/generated_poc/protocol/sync_principal_response.py +++ b/src/adcp/types/generated_poc/protocol/sync_principal_response.py @@ -94,3 +94,9 @@ class SyncPrincipalResponse(AdcpVersionEnvelope, ProtocolEnvelope): result: Result17 | Result | Result19 context: context_1.ContextObject | None = None ext: ext_1.ExtensionObject | None = None + + +# Stable aliases for anonymous result arms (selected by discriminator). +PrincipalValidatedResult = Result +PrincipalAppliedResult = Result17 +PrincipalSyncFailedResult = Result19 diff --git a/tests/test_code_generation.py b/tests/test_code_generation.py index b40eec0d4..54198e58a 100644 --- a/tests/test_code_generation.py +++ b/tests/test_code_generation.py @@ -14,6 +14,62 @@ import pytest +def test_flatten_schemas_uses_stable_path_order(tmp_path, monkeypatch, capsys): + """Aggregate model suffixes must not depend on filesystem insertion order.""" + from scripts import generate_types + + schemas = tmp_path / "schemas" + schemas.mkdir() + (schemas / "zeta.json").write_text('{"type": "object"}') + (schemas / "alpha.json").write_text('{"type": "object"}') + monkeypatch.setattr(generate_types, "SCHEMAS_DIR", schemas) + monkeypatch.setattr(generate_types, "GENERATED_SCHEMA_EXCLUDE_FILES", frozenset()) + monkeypatch.setattr(generate_types, "GENERATED_SCHEMA_EXCLUDE_DIRS", frozenset()) + + generate_types.flatten_schemas(tmp_path / "prepared") + + output = capsys.readouterr().out + assert output.index(" alpha.json") < output.index(" zeta.json") + + +def test_restore_principal_result_aliases_uses_kind_discriminators(tmp_path, monkeypatch): + """Principal aliases remain stable when anonymous class suffixes change.""" + from scripts import post_generate_fixes + + protocol_dir = tmp_path / "protocol" + protocol_dir.mkdir() + (protocol_dir / "get_principal_response.py").write_text( + "from typing import Literal\n" + "class Result42:\n" + " kind: Literal['current'] = 'current'\n" + "class Result3:\n" + " kind: Literal['recognized'] = 'recognized'\n" + "class Result:\n" + " kind: Literal['unconfigured'] = 'unconfigured'\n" + "class Result8:\n" + " kind: Literal['failed'] = 'failed'\n" + ) + (protocol_dir / "sync_principal_response.py").write_text( + "from typing import Literal\n" + "class Result4:\n" + " kind: Literal['validated'] = 'validated'\n" + "class Result12:\n" + " kind: Literal['applied'] = 'applied'\n" + "class Result99:\n" + " kind: Literal['failed'] = 'failed'\n" + ) + monkeypatch.setattr(post_generate_fixes, "OUTPUT_DIR", tmp_path) + + post_generate_fixes.restore_principal_result_aliases() + + get_source = (protocol_dir / "get_principal_response.py").read_text() + sync_source = (protocol_dir / "sync_principal_response.py").read_text() + assert "PrincipalCurrentResult = Result42" in get_source + assert "PrincipalRecognizedResult = Result3" in get_source + assert "PrincipalAppliedResult = Result12" in sync_source + assert "PrincipalSyncFailedResult = Result99" in sync_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 From 2b6a01ba02c9e707fbdf082bda24a3d708d7249f Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sat, 5 Sep 2026 15:16:00 +0000 Subject: [PATCH 4/5] fix(codegen): keep collision snapshot release-aligned --- scripts/collision_allowlist.json | 1 - 1 file changed, 1 deletion(-) diff --git a/scripts/collision_allowlist.json b/scripts/collision_allowlist.json index 940fd37e1..6100bc81d 100644 --- a/scripts/collision_allowlist.json +++ b/scripts/collision_allowlist.json @@ -19,7 +19,6 @@ "Amount", "AppliesToEnum", "Area", - "Arm", "Art9Basis", "Artifact", "AssetSource", From 07026d442951c492cf8e5bca4351066402fa89c5 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sat, 5 Sep 2026 15:26:59 +0000 Subject: [PATCH 5/5] fix(codegen): disambiguate generated response enum --- scripts/post_generate_fixes.py | 27 +++++++++++++++++++++++++++ tests/test_code_generation.py | 24 ++++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/scripts/post_generate_fixes.py b/scripts/post_generate_fixes.py index 330d9bfb0..3c93a77bb 100644 --- a/scripts/post_generate_fixes.py +++ b/scripts/post_generate_fixes.py @@ -3319,6 +3319,32 @@ def restore_principal_result_aliases() -> None: print(f" {relative_path}: restored {len(assignments)} principal result aliases") +def disambiguate_comply_response_arm() -> None: + """Give the comply response's anonymous ``Arm`` enum a stable name. + + The request schema already generates an unrelated public ``Arm`` enum. + Codegen 0.63 also calls the response enum ``Arm``, which trips the exact + collision guard only after regeneration. Rename the response-local type + before exports are consolidated so both the release tree and a fresh tree + have an unambiguous namespace. + """ + target = OUTPUT_DIR / "compliance" / "comply_test_controller_response.py" + if not target.exists(): + print(" comply_test_controller_response.py not found (skipping Arm rename)") + return + + source = target.read_text() + if "class ComplyResponseArm(" in source: + print(" compliance response Arm already disambiguated") + return + if "class Arm(" not in source: + print(" compliance response Arm not generated (no rename needed)") + return + + target.write_text(re.sub(r"\bArm\b", "ComplyResponseArm", source)) + print(" compliance response: renamed Arm -> ComplyResponseArm") + + def restore_response_variant_aliases() -> None: """Restore numbered response arms from schema data, not hand-written payloads. @@ -5305,6 +5331,7 @@ def main(argv: list[str] | None = None): restore_signal_catalog_type_alias, restore_format_asset_numbered_aliases, restore_principal_result_aliases, + disambiguate_comply_response_arm, restore_response_variant_aliases, fix_compliance_task_completion_response_ref, restore_get_products_field_compatibility_enum, diff --git a/tests/test_code_generation.py b/tests/test_code_generation.py index 54198e58a..4ac354b46 100644 --- a/tests/test_code_generation.py +++ b/tests/test_code_generation.py @@ -70,6 +70,30 @@ def test_restore_principal_result_aliases_uses_kind_discriminators(tmp_path, mon assert "PrincipalSyncFailedResult = Result99" in sync_source +def test_disambiguate_comply_response_arm_renames_class_and_references(tmp_path, monkeypatch): + """Fresh codegen output cannot add a generic public Arm collision.""" + from scripts import post_generate_fixes + + compliance_dir = tmp_path / "compliance" + compliance_dir.mkdir() + target = compliance_dir / "comply_test_controller_response.py" + target.write_text( + "from enum import Enum\n" + "class Arm(Enum):\n" + " submitted = 'submitted'\n" + "class Forced:\n" + " arm: Arm\n" + ) + monkeypatch.setattr(post_generate_fixes, "OUTPUT_DIR", tmp_path) + + post_generate_fixes.disambiguate_comply_response_arm() + + source = target.read_text() + assert "class ComplyResponseArm(Enum):" in source + assert "arm: ComplyResponseArm" in source + assert "class Arm(" not in 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