diff --git a/scripts/generate_ergonomic_coercion.py b/scripts/generate_ergonomic_coercion.py index ae69b243..9a2d6482 100644 --- a/scripts/generate_ergonomic_coercion.py +++ b/scripts/generate_ergonomic_coercion.py @@ -95,22 +95,26 @@ def get_base_type(annotation: Any) -> Any: def is_list_of(annotation: Any, item_check) -> tuple[bool, Any]: - """Check if annotation is list[X] where X passes item_check. + """Check if annotation is list[X] or Sequence[X] where X passes item_check. - Handles both list[X] and list[X] | None. + Handles both T[X] and T[X] | None (where T is list or collections.abc.Sequence). """ - # First check if the annotation itself is a list + from collections.abc import Sequence as AbcSequence + + _list_origins = (list, AbcSequence) + + # First check if the annotation itself is a list/Sequence origin = get_origin(annotation) - if origin is list: + if origin in _list_origins: args = get_args(annotation) if args and item_check(args[0]): return True, args[0] - # Then check if it's Optional[list[X]] (i.e., list[X] | None) + # Then check if it's Optional[list[X]] or Optional[Sequence[X]] base = get_base_type(annotation) if base is not None and base is not annotation: origin = get_origin(base) - if origin is list: + if origin in _list_origins: args = get_args(base) if args and item_check(args[0]): return True, args[0] @@ -369,12 +373,15 @@ def _find_success_variant() -> type[_PydBaseModel]: "5. FieldModel (enum) lists accept string lists", "", "Note: List variance issues (list[Subclass] not assignable to list[BaseClass])", - "are a fundamental Python typing limitation. Users extending library types", - "should use Sequence[T] in their own code or cast() for type checker appeasement.", + "are a fundamental Python typing limitation. Response-only container fields", + "(affected_packages, media_buys, packages, media_buy_deliveries) already use", + "Sequence[T] in their generated base class. For other fields not yet migrated,", + "adopters should use Sequence[T] in their own code or cast() for appeasement.", '"""', "", "from __future__ import annotations", "", + "from collections.abc import Sequence", "from typing import Annotated, Any", "", "from pydantic import BeforeValidator", @@ -555,11 +562,18 @@ def _find_success_variant() -> type[_PydBaseModel]: ) lines.append(" )") elif c["type"] == "subclass_list": + from collections.abc import Sequence as AbcSequence + target = c["target_class"].__name__ - # Check if the field is required (no | None) field_info = cls.model_fields[field] is_optional = "None" in str(field_info.annotation) - type_str = f"list[{target}] | None" if is_optional else f"list[{target}]" + # Preserve Sequence[T] when the field already uses it (covariant + # inheritance, set by post_generate_fixes.rewrite_response_list_to_sequence). + ann = field_info.annotation + base_ann = get_base_type(ann) + is_seq = get_origin(base_ann if base_ann is not None else ann) is AbcSequence + container = "Sequence" if is_seq else "list" + type_str = f"{container}[{target}] | None" if is_optional else f"{container}[{target}]" lines.append(" _patch_field_annotation(") lines.append(f" {type_name},") lines.append(f' "{field}",') diff --git a/scripts/post_generate_fixes.py b/scripts/post_generate_fixes.py index 69e64bac..b5c2c623 100644 --- a/scripts/post_generate_fixes.py +++ b/scripts/post_generate_fixes.py @@ -547,6 +547,67 @@ def add_rootmodel_getattr_proxy(): print(" No RootModel union types needed __getattr__ proxy") +# Response-only list fields changed to Sequence[T] so adopters can narrow the +# element type without type: ignore[assignment] under strict mypy. Only +# response-side fields (received, never mutated) are safe to change; request- +# side list fields (packages/creatives on request types) stay as list[T] +# because adopters call .append() on them. See issue #624. +RESPONSE_SEQUENCE_FIELDS: list[tuple[str, str]] = [ + ("media_buy/update_media_buy_response.py", "affected_packages"), + ("media_buy/get_media_buys_response.py", "media_buys"), + ("media_buy/get_media_buys_response.py", "packages"), + ("media_buy/get_media_buy_delivery_response.py", "media_buy_deliveries"), +] + + +def rewrite_response_list_to_sequence() -> None: + """Change list[T] → Sequence[T] on response-only container fields. + + list[T] is invariant so ``affected_packages: list[MyPkg]`` on a subclass + triggers mypy[assignment] against the parent's ``list[Pkg]``. Sequence[T] + is covariant, removing the error for adopters who extend element types. + """ + print("Rewriting response list fields to Sequence for covariant inheritance...") + + for rel_path, field_name in RESPONSE_SEQUENCE_FIELDS: + target = OUTPUT_DIR / rel_path + if not target.exists(): + print(f" {rel_path}: not found (skipping)") + continue + + content = target.read_text() + + # Idempotency: skip if field already uses Sequence + if re.search(rf"{re.escape(field_name)}: Annotated\[\s+Sequence\[", content): + print(f" {rel_path}: {field_name} already uses Sequence (skipping)") + continue + + new_content = re.sub( + rf"({re.escape(field_name)}: Annotated\[\s+)list\[", + r"\1Sequence[", + content, + ) + + if new_content == content: + print(f" {rel_path}: {field_name} — list[ pattern not found (skipping)") + continue + + # Add Sequence import from collections.abc in stdlib block. + # Anchor on the first stdlib import line (enum or typing) so Sequence + # lands in correct alphabetical position (c < e < t). + if "from collections.abc import Sequence" not in new_content: + new_content = re.sub( + r"^(from (?:enum|typing) import .+)$", + r"from collections.abc import Sequence\n\1", + new_content, + count=1, + flags=re.MULTILINE, + ) + + target.write_text(new_content) + print(f" {rel_path}: {field_name} → Sequence[...]") + + def fix_list_field_shadowing(): """Fix models where a field named 'list' shadows the builtin list type. @@ -1151,6 +1212,7 @@ def main(): unwrap_rootmodel_unions() add_rootmodel_getattr_proxy() fix_list_field_shadowing() + rewrite_response_list_to_sequence() fix_reuse_model_discriminator_bug() restore_format_category_deprecation_shim() inject_literal_discriminator_defaults() diff --git a/src/adcp/types/_ergonomic.py b/src/adcp/types/_ergonomic.py index f5133f54..37f22b6f 100644 --- a/src/adcp/types/_ergonomic.py +++ b/src/adcp/types/_ergonomic.py @@ -25,12 +25,15 @@ 5. FieldModel (enum) lists accept string lists Note: List variance issues (list[Subclass] not assignable to list[BaseClass]) -are a fundamental Python typing limitation. Users extending library types -should use Sequence[T] in their own code or cast() for type checker appeasement. +are a fundamental Python typing limitation. Response-only container fields +(affected_packages, media_buys, packages, media_buy_deliveries) already use +Sequence[T] in their generated base class. For other fields not yet migrated, +adopters should use Sequence[T] in their own code or cast() for appeasement. """ from __future__ import annotations +from collections.abc import Sequence from typing import Annotated, Any from pydantic import BeforeValidator @@ -500,7 +503,7 @@ def _apply_coercion() -> None: GetMediaBuyDeliveryResponse, "media_buy_deliveries", Annotated[ - list[MediaBuyDelivery], + Sequence[MediaBuyDelivery], BeforeValidator(coerce_subclass_list(MediaBuyDelivery)), ], ) diff --git a/src/adcp/types/generated_poc/media_buy/get_media_buy_delivery_response.py b/src/adcp/types/generated_poc/media_buy/get_media_buy_delivery_response.py index 256b6267..c2ffd065 100644 --- a/src/adcp/types/generated_poc/media_buy/get_media_buy_delivery_response.py +++ b/src/adcp/types/generated_poc/media_buy/get_media_buy_delivery_response.py @@ -4,6 +4,7 @@ from __future__ import annotations +from collections.abc import Sequence from enum import Enum from typing import Annotated, Any from collections.abc import Sequence diff --git a/src/adcp/types/generated_poc/media_buy/get_media_buys_response.py b/src/adcp/types/generated_poc/media_buy/get_media_buys_response.py index 0d405c0f..cd6f720d 100644 --- a/src/adcp/types/generated_poc/media_buy/get_media_buys_response.py +++ b/src/adcp/types/generated_poc/media_buy/get_media_buys_response.py @@ -4,6 +4,7 @@ from __future__ import annotations +from collections.abc import Sequence from enum import Enum from typing import Annotated from collections.abc import Sequence @@ -334,7 +335,7 @@ class MediaBuy(AdCPBaseModel): ), ] = None packages: Annotated[ - list[Package], + Sequence[Package], Field( description='Packages within this media buy, augmented with creative approval status and optional delivery snapshots' ), diff --git a/src/adcp/types/generated_poc/media_buy/update_media_buy_response.py b/src/adcp/types/generated_poc/media_buy/update_media_buy_response.py index 1b57f77a..eed31c44 100644 --- a/src/adcp/types/generated_poc/media_buy/update_media_buy_response.py +++ b/src/adcp/types/generated_poc/media_buy/update_media_buy_response.py @@ -4,6 +4,7 @@ from __future__ import annotations +from collections.abc import Sequence from typing import Annotated from collections.abc import Sequence