Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 24 additions & 10 deletions scripts/generate_ergonomic_coercion.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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}",')
Expand Down
62 changes: 62 additions & 0 deletions scripts/post_generate_fixes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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()
Expand Down
9 changes: 6 additions & 3 deletions src/adcp/types/_ergonomic.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -500,7 +503,7 @@ def _apply_coercion() -> None:
GetMediaBuyDeliveryResponse,
"media_buy_deliveries",
Annotated[
list[MediaBuyDelivery],
Sequence[MediaBuyDelivery],
BeforeValidator(coerce_subclass_list(MediaBuyDelivery)),
],
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

from __future__ import annotations

from collections.abc import Sequence
from typing import Annotated
from collections.abc import Sequence

Expand Down
Loading