diff --git a/scripts/post_generate_fixes.py b/scripts/post_generate_fixes.py index 134623d68..13d068cb7 100644 --- a/scripts/post_generate_fixes.py +++ b/scripts/post_generate_fixes.py @@ -799,6 +799,260 @@ def _first_subscript_arg(node: ast.Subscript) -> ast.AST | None: return slice_node +# --------------------------------------------------------------------------- +# #624: widen documented extension-point list[X] fields to Sequence[X]. +# +# Adopters who follow Critical Pattern #1 (subclass a library response type +# and override the parent's list field with a more specific element type) +# hit `# type: ignore[assignment]` on every override under mypy --strict — +# list is invariant in its element type. Sequence is covariant, so a +# Sequence[Parent] parent permits list[Child] override cleanly. +# +# Scope is intentionally narrow: only fields the SDK documents as +# extension points (response payloads adopters routinely subclass, plus +# request bodies that compose extendable sub-records like packages and +# creatives). Internal scalars stay as list. +# +# Allowlist format: (class_name, field_name). datamodel-codegen emits +# bundled response files that each inline copies of subordinate types +# (Placement, TargetingOverlay, etc.); the rewriter walks every generated +# .py file and applies the substitution to every emission of the named +# (class, field) pair so all copies stay consistent. + +_SEQUENCE_EXTENSION_POINTS: list[tuple[str, str]] = [ + # Response payloads adopters subclass to add internal-only fields. + # `UpdateMediaBuySuccessResponse` is the success variant of the + # `UpdateMediaBuyResponse` discriminated union — emitted as + # `UpdateMediaBuyResponse1` (v3.0) and `UpdateMediaBuyResponse3` + # (v3.0.6 bundled). + ("UpdateMediaBuyResponse1", "affected_packages"), + ("UpdateMediaBuyResponse3", "affected_packages"), + ("GetMediaBuyDeliveryResponse", "media_buy_deliveries"), + ("GetCreativeDeliveryResponse", "creatives"), + ("Signal", "deployments"), + ("GetSignalsResponse", "signals"), + ("GetMediaBuysResponse", "media_buys"), + ("ListCreativesResponse", "creatives"), + # Request bodies that carry extendable sub-records — adopters subclass + # the inner record type and need to override the list element type. + ("PackageRequest", "creatives"), + ("CreateMediaBuyRequest", "packages"), + ("UpdateMediaBuyRequest", "packages"), + # Cross-cutting record types referenced from multiple responses; each + # bundled response file inlines its own copy. The walker rewrites + # every emission. + ("Placement", "format_ids"), + ("TargetingOverlay", "geo_countries_exclude"), + ("TargetingOverlay", "geo_regions_exclude"), + ("TargetingOverlay", "geo_metros_exclude"), + ("TargetingOverlay", "geo_postal_areas_exclude"), +] + + +def widen_extension_point_lists_to_sequence(): + """Rewrite ``list[X]`` to ``Sequence[X]`` on documented extension-point fields. + + Walks every generated ``.py`` file under :data:`OUTPUT_DIR`. For each + file, applies every ``(class, field)`` pair in + :data:`_SEQUENCE_EXTENSION_POINTS` that matches a class declaration + in that file. The same ``(class, field)`` pair commonly appears in + multiple files because bundled response emission inlines copies of + subordinate types — every emission is rewritten so all paths stay + consistent. Each rewritten file gets ``from collections.abc import + Sequence`` added if it isn't already present. + + Pairs that produce zero rewrites across the whole tree emit a WARN + so allowlist drift surfaces fast (a renamed field or removed class + means the override pattern this entry was protecting no longer + exists). + + See `adcp-client-python#624 `_ + for the design rationale and the spike that validated the Pydantic + plugin accepts ``Sequence[Parent]`` parent + ``list[Child]`` child + override under mypy --strict. + """ + print("Widening extension-point list[X] fields to Sequence[X] (#624)...") + + # Track total rewrites per (class, field) — a pair with zero hits is + # a stale allowlist entry and surfaces as a WARN. + # Track per-pair state across all files: + # rewrites: how many list[X] sites were rewritten this run + # already_widened: how many sites are already in Sequence[X] form + # A pair with rewrites == 0 AND already_widened == 0 is genuinely stale + # (field renamed/removed) and warrants a WARN. A pair with already_widened + # > 0 is silent — that's the steady-state idempotent run. + rewrites_per_pair: dict[tuple[str, str], int] = {pair: 0 for pair in _SEQUENCE_EXTENSION_POINTS} + already_per_pair: dict[tuple[str, str], int] = {pair: 0 for pair in _SEQUENCE_EXTENSION_POINTS} + files_touched = 0 + total_widened = 0 + + for file_path in sorted(OUTPUT_DIR.rglob("*.py")): + original = file_path.read_text() + content = original + widened_in_file = 0 + + for class_name, field_name in _SEQUENCE_EXTENSION_POINTS: + # Quick filter — skip files that don't declare this class. + if f"class {class_name}(" not in content and f"class {class_name}:" not in content: + continue + new_content, did_widen = _widen_field_annotation(content, class_name, field_name) + if did_widen: + content = new_content + widened_in_file += 1 + rewrites_per_pair[(class_name, field_name)] += 1 + elif _field_already_widened(content, class_name, field_name): + already_per_pair[(class_name, field_name)] += 1 + + if widened_in_file == 0: + continue + + content = _ensure_sequence_import(content) + file_path.write_text(content) + files_touched += 1 + total_widened += widened_in_file + print(f" ✓ {file_path.relative_to(OUTPUT_DIR)}: widened {widened_in_file} field(s)") + + stale = [ + pair + for pair in _SEQUENCE_EXTENSION_POINTS + if rewrites_per_pair[pair] == 0 and already_per_pair[pair] == 0 + ] + for class_name, field_name in stale: + print( + f" WARN: {class_name}.{field_name} — neither list[X] nor Sequence[X] " + "found in any generated file (field renamed or removed?)" + ) + + if total_widened == 0: + print(" No extension-point fields to widen") + else: + print( + f" ✓ Widened {total_widened} extension-point field(s) " + f"across {files_touched} file(s)" + ) + + +def _widen_field_annotation(content: str, class_name: str, field_name: str) -> tuple[str, bool]: + """Rewrite ``list[X]`` → ``Sequence[X]`` in one field's annotation. + + Locates ``class {class_name}(...):`` then walks forward to the first + `` {field_name}:`` line at class-body indentation, **bounded to the + target class** so a same-named field on a later class in the same + file cannot mis-match. Within the AnnAssign's annotation block (which + may span multiple lines for ``Annotated[..., Field(...)]``), replaces + the first ``list[`` with ``Sequence[``. Idempotent — a second pass + over already-widened content is a no-op. + """ + # Anchor on the class definition. + class_pattern = re.compile(rf"^class {re.escape(class_name)}\b", re.MULTILINE) + class_match = class_pattern.search(content) + if class_match is None: + return content, False + + # Bound the search region to the current class body. Scanning past the + # next `^class ` would let `re.search` mis-target a same-named field + # on a sibling class in the same file (the lookahead in + # field_start_pattern terminates a *match*, but `re.search` is free to + # scan past the first class's boundary looking for a hit). + class_body_start = class_match.end() + next_class = re.compile(r"^class ", re.MULTILINE).search(content, class_body_start) + region_end = next_class.start() if next_class is not None else len(content) + region = content[class_body_start:region_end] + + # The annotation block runs from the field name to the next class-body + # statement at 4-space indentation (next field, model_config, or method). + field_start_pattern = re.compile( + rf"^( {re.escape(field_name)}: )(.*?)(?=^ [a-zA-Z_]|\Z)", + re.MULTILINE | re.DOTALL, + ) + field_match = field_start_pattern.search(region) + if field_match is None: + return content, False + + annotation_block = field_match.group(2) + # Replace the first list[ inside the annotation only. Generated + # annotations always have `list[X]` as the outer container; the + # narrow scope of the allowlist (no `dict[str, list[X]]` entries) + # makes this safe in practice. If a future entry has nested list, + # this needs to anchor on the outer container explicitly. + new_annotation = re.sub(r"\blist\[", "Sequence[", annotation_block, count=1) + if new_annotation == annotation_block: + return content, False + + # Stitch back. .start()/.end() are relative to `region`; convert to + # absolute offsets in `content`. + abs_start = class_body_start + field_match.start(2) + abs_end = class_body_start + field_match.end(2) + new_content = content[:abs_start] + new_annotation + content[abs_end:] + return new_content, True + + +def _field_already_widened(content: str, class_name: str, field_name: str) -> bool: + """Return True when the named field's annotation is already ``Sequence[X]``. + + Used to silence the WARN on idempotent re-runs: a pair that's already + widened is the steady state, not allowlist drift. + """ + class_match = re.search(rf"^class {re.escape(class_name)}\b", content, re.MULTILINE) + if class_match is None: + return False + class_body_start = class_match.end() + next_class = re.compile(r"^class ", re.MULTILINE).search(content, class_body_start) + region_end = next_class.start() if next_class is not None else len(content) + region = content[class_body_start:region_end] + field_match = re.search( + rf"^( {re.escape(field_name)}: )(.*?)(?=^ [a-zA-Z_]|\Z)", + region, + re.MULTILINE | re.DOTALL, + ) + if field_match is None: + return False + return "Sequence[" in field_match.group(2) + + +def _ensure_sequence_import(content: str) -> str: + """Add ``from collections.abc import Sequence`` if not already present. + + Inserts after the ``from __future__ import annotations`` line so the + import sits with sibling stdlib imports rather than landing at the top + of the file. + """ + if "from collections.abc import Sequence" in content: + return content + # If `collections.abc` is already imported, extend the import line. + extend_pattern = re.compile(r"^from collections\.abc import ([^\n]+)$", re.MULTILINE) + match = extend_pattern.search(content) + if match is not None: + existing = match.group(1) + # Maintain alphabetical order if the existing import is sorted. + names = sorted({*[n.strip() for n in existing.split(",")], "Sequence"}) + new_line = f"from collections.abc import {', '.join(names)}" + return content[: match.start()] + new_line + content[match.end() :] + + # Otherwise insert after the typing imports block. Codegen always emits + # ``from typing import Annotated`` near the top, so anchor on it. + typing_pattern = re.compile(r"^from typing import [^\n]+$", re.MULTILINE) + match = typing_pattern.search(content) + if match is not None: + return ( + content[: match.end()] + + "\nfrom collections.abc import Sequence" + + content[match.end() :] + ) + + # Fallback: prepend after the `from __future__` line. + future_pattern = re.compile(r"^from __future__ import annotations$", re.MULTILINE) + match = future_pattern.search(content) + if match is not None: + return ( + content[: match.end()] + + "\n\nfrom collections.abc import Sequence" + + content[match.end() :] + ) + + return "from collections.abc import Sequence\n\n" + content + + def main(): """Apply all post-generation fixes.""" print("Applying post-generation fixes...") @@ -816,6 +1070,7 @@ def main(): fix_reuse_model_discriminator_bug() restore_format_category_deprecation_shim() inject_literal_discriminator_defaults() + widen_extension_point_lists_to_sequence() print("\n✓ Post-generation fixes complete\n") diff --git a/src/adcp/types/generated_poc/bundled/creative/get_creative_delivery_response.py b/src/adcp/types/generated_poc/bundled/creative/get_creative_delivery_response.py index 8bd6f86b2..1a8337e9a 100644 --- a/src/adcp/types/generated_poc/bundled/creative/get_creative_delivery_response.py +++ b/src/adcp/types/generated_poc/bundled/creative/get_creative_delivery_response.py @@ -6,6 +6,7 @@ from enum import Enum, IntEnum from typing import Annotated, Any, Literal +from collections.abc import Sequence from adcp.types.base import AdCPBaseModel from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, RootModel, StringConstraints @@ -3612,7 +3613,7 @@ class GetCreativeDeliveryResponse(AdCPBaseModel): ] reporting_period: Annotated[ReportingPeriod, Field(description='Date range for the report.')] creatives: Annotated[ - list[Creative], Field(description='Creative delivery data with variant breakdowns') + Sequence[Creative], Field(description='Creative delivery data with variant breakdowns') ] pagination: Annotated[ Pagination | None, diff --git a/src/adcp/types/generated_poc/bundled/creative/list_creatives_response.py b/src/adcp/types/generated_poc/bundled/creative/list_creatives_response.py index 5119d6dcf..df2422ecf 100644 --- a/src/adcp/types/generated_poc/bundled/creative/list_creatives_response.py +++ b/src/adcp/types/generated_poc/bundled/creative/list_creatives_response.py @@ -6,6 +6,7 @@ from enum import Enum, IntEnum from typing import Annotated, Any, Literal +from collections.abc import Sequence from adcp.types.base import AdCPBaseModel from pydantic import AnyUrl, AwareDatetime, ConfigDict, EmailStr, Field, RootModel, StringConstraints @@ -3810,7 +3811,7 @@ class ListCreativesResponse(AdCPBaseModel): ), ] creatives: Annotated[ - list[Creative], Field(description='Array of creative assets matching the query') + Sequence[Creative], Field(description='Array of creative assets matching the query') ] format_summary: Annotated[ dict[Annotated[str, StringConstraints(pattern=r'^[a-zA-Z0-9_-]+$')], int] | None, diff --git a/src/adcp/types/generated_poc/bundled/media_buy/create_media_buy_request.py b/src/adcp/types/generated_poc/bundled/media_buy/create_media_buy_request.py index 255f1b192..cf4104588 100644 --- a/src/adcp/types/generated_poc/bundled/media_buy/create_media_buy_request.py +++ b/src/adcp/types/generated_poc/bundled/media_buy/create_media_buy_request.py @@ -6,6 +6,7 @@ from enum import Enum, IntEnum from typing import Annotated, Any, Literal +from collections.abc import Sequence from adcp.types.base import AdCPBaseModel from pydantic import AnyUrl, AwareDatetime, ConfigDict, EmailStr, Field, RootModel, StringConstraints @@ -2576,7 +2577,7 @@ class TargetingOverlay(AdCPBaseModel): ), ] = None geo_countries_exclude: Annotated[ - list[GeoCountriesExcludeItem] | None, + Sequence[GeoCountriesExcludeItem] | None, Field( description="Exclude specific countries from delivery. ISO 3166-1 alpha-2 codes (e.g., 'US', 'GB', 'DE').", min_length=1, @@ -2590,7 +2591,7 @@ class TargetingOverlay(AdCPBaseModel): ), ] = None geo_regions_exclude: Annotated[ - list[GeoRegionsExcludeItem] | None, + Sequence[GeoRegionsExcludeItem] | None, Field( description="Exclude specific regions/states from delivery. ISO 3166-2 subdivision codes (e.g., 'US-CA', 'GB-SCT').", min_length=1, @@ -2604,7 +2605,7 @@ class TargetingOverlay(AdCPBaseModel): ), ] = None geo_metros_exclude: Annotated[ - list[GeoMetrosExcludeItem] | None, + Sequence[GeoMetrosExcludeItem] | None, Field( description='Exclude specific metro areas from delivery. Each entry specifies the classification system and excluded values. Seller must declare supported systems in get_adcp_capabilities.', min_length=1, @@ -2618,7 +2619,7 @@ class TargetingOverlay(AdCPBaseModel): ), ] = None geo_postal_areas_exclude: Annotated[ - list[GeoPostalAreasExcludeItem] | None, + Sequence[GeoPostalAreasExcludeItem] | None, Field( description='Exclude specific postal areas from delivery. Each entry specifies the postal system and excluded values. Seller must declare supported systems in get_adcp_capabilities.', min_length=1, @@ -5030,7 +5031,7 @@ class CreateMediaBuyRequest(AdCPBaseModel): ), ] = None packages: Annotated[ - list[Package] | None, + Sequence[Package] | None, Field( description="Array of package configurations. Required when not using proposal_id. When executing a proposal, this can be omitted and packages will be derived from the proposal's allocations.", min_length=1, diff --git a/src/adcp/types/generated_poc/bundled/media_buy/create_media_buy_response.py b/src/adcp/types/generated_poc/bundled/media_buy/create_media_buy_response.py index 299d94f98..026298b80 100644 --- a/src/adcp/types/generated_poc/bundled/media_buy/create_media_buy_response.py +++ b/src/adcp/types/generated_poc/bundled/media_buy/create_media_buy_response.py @@ -6,6 +6,7 @@ from enum import Enum from typing import Annotated, Any, Literal +from collections.abc import Sequence from adcp.types.base import AdCPBaseModel from pydantic import AnyUrl, AwareDatetime, ConfigDict, EmailStr, Field, RootModel @@ -2540,7 +2541,7 @@ class TargetingOverlay(AdCPBaseModel): ), ] = None geo_countries_exclude: Annotated[ - list[GeoCountriesExcludeItem] | None, + Sequence[GeoCountriesExcludeItem] | None, Field( description="Exclude specific countries from delivery. ISO 3166-1 alpha-2 codes (e.g., 'US', 'GB', 'DE').", min_length=1, @@ -2554,7 +2555,7 @@ class TargetingOverlay(AdCPBaseModel): ), ] = None geo_regions_exclude: Annotated[ - list[GeoRegionsExcludeItem] | None, + Sequence[GeoRegionsExcludeItem] | None, Field( description="Exclude specific regions/states from delivery. ISO 3166-2 subdivision codes (e.g., 'US-CA', 'GB-SCT').", min_length=1, @@ -2568,7 +2569,7 @@ class TargetingOverlay(AdCPBaseModel): ), ] = None geo_metros_exclude: Annotated[ - list[GeoMetrosExcludeItem] | None, + Sequence[GeoMetrosExcludeItem] | None, Field( description='Exclude specific metro areas from delivery. Each entry specifies the classification system and excluded values. Seller must declare supported systems in get_adcp_capabilities.', min_length=1, @@ -2582,7 +2583,7 @@ class TargetingOverlay(AdCPBaseModel): ), ] = None geo_postal_areas_exclude: Annotated[ - list[GeoPostalAreasExcludeItem] | None, + Sequence[GeoPostalAreasExcludeItem] | None, Field( description='Exclude specific postal areas from delivery. Each entry specifies the postal system and excluded values. Seller must declare supported systems in get_adcp_capabilities.', min_length=1, diff --git a/src/adcp/types/generated_poc/bundled/media_buy/get_media_buy_delivery_response.py b/src/adcp/types/generated_poc/bundled/media_buy/get_media_buy_delivery_response.py index d9d1e5b37..5a4e31784 100644 --- a/src/adcp/types/generated_poc/bundled/media_buy/get_media_buy_delivery_response.py +++ b/src/adcp/types/generated_poc/bundled/media_buy/get_media_buy_delivery_response.py @@ -6,6 +6,7 @@ from enum import Enum from typing import Annotated, Any +from collections.abc import Sequence from adcp.types.base import AdCPBaseModel from pydantic import AwareDatetime, ConfigDict, Field @@ -2400,7 +2401,7 @@ class GetMediaBuyDeliveryResponse(AdCPBaseModel): ), ] = None media_buy_deliveries: Annotated[ - list[MediaBuyDelivery], + Sequence[MediaBuyDelivery], Field( description='Array of delivery data for media buys. When used in webhook notifications, may contain multiple media buys aggregated by publisher. When used in get_media_buy_delivery API responses, typically contains requested media buys.' ), diff --git a/src/adcp/types/generated_poc/bundled/media_buy/get_media_buys_response.py b/src/adcp/types/generated_poc/bundled/media_buy/get_media_buys_response.py index ed4ba83cf..8123eec69 100644 --- a/src/adcp/types/generated_poc/bundled/media_buy/get_media_buys_response.py +++ b/src/adcp/types/generated_poc/bundled/media_buy/get_media_buys_response.py @@ -6,6 +6,7 @@ from enum import Enum from typing import Annotated, Any +from collections.abc import Sequence from adcp.types.base import AdCPBaseModel from pydantic import AnyUrl, AwareDatetime, ConfigDict, EmailStr, Field, RootModel @@ -1590,7 +1591,7 @@ class TargetingOverlay(AdCPBaseModel): ), ] = None geo_countries_exclude: Annotated[ - list[GeoCountriesExcludeItem] | None, + Sequence[GeoCountriesExcludeItem] | None, Field( description="Exclude specific countries from delivery. ISO 3166-1 alpha-2 codes (e.g., 'US', 'GB', 'DE').", min_length=1, @@ -1604,7 +1605,7 @@ class TargetingOverlay(AdCPBaseModel): ), ] = None geo_regions_exclude: Annotated[ - list[GeoRegionsExcludeItem] | None, + Sequence[GeoRegionsExcludeItem] | None, Field( description="Exclude specific regions/states from delivery. ISO 3166-2 subdivision codes (e.g., 'US-CA', 'GB-SCT').", min_length=1, @@ -1618,7 +1619,7 @@ class TargetingOverlay(AdCPBaseModel): ), ] = None geo_metros_exclude: Annotated[ - list[GeoMetrosExcludeItem] | None, + Sequence[GeoMetrosExcludeItem] | None, Field( description='Exclude specific metro areas from delivery. Each entry specifies the classification system and excluded values. Seller must declare supported systems in get_adcp_capabilities.', min_length=1, @@ -1632,7 +1633,7 @@ class TargetingOverlay(AdCPBaseModel): ), ] = None geo_postal_areas_exclude: Annotated[ - list[GeoPostalAreasExcludeItem] | None, + Sequence[GeoPostalAreasExcludeItem] | None, Field( description='Exclude specific postal areas from delivery. Each entry specifies the postal system and excluded values. Seller must declare supported systems in get_adcp_capabilities.', min_length=1, @@ -2143,7 +2144,7 @@ class GetMediaBuysResponse(AdCPBaseModel): extra='allow', ) media_buys: Annotated[ - list[MediaBuy], + Sequence[MediaBuy], Field( description='Array of media buys with status, creative approval state, and optional delivery snapshots' ), diff --git a/src/adcp/types/generated_poc/bundled/media_buy/get_products_response.py b/src/adcp/types/generated_poc/bundled/media_buy/get_products_response.py index 9395f6fd3..fd210fce5 100644 --- a/src/adcp/types/generated_poc/bundled/media_buy/get_products_response.py +++ b/src/adcp/types/generated_poc/bundled/media_buy/get_products_response.py @@ -6,6 +6,7 @@ from enum import Enum from typing import Annotated, Any, Literal +from collections.abc import Sequence from adcp.types.base import AdCPBaseModel from pydantic import AnyUrl, AwareDatetime, ConfigDict, EmailStr, Field, RootModel @@ -203,7 +204,7 @@ class Placement(AdCPBaseModel): ), ] = None format_ids: Annotated[ - list[FormatId] | None, + Sequence[FormatId] | None, Field( description='Format IDs supported by this specific placement. Can include: (1) concrete format_ids (fixed dimensions), (2) template format_ids without parameters (accepts any dimensions/duration), or (3) parameterized format_ids (specific dimension/duration constraints).', min_length=1, diff --git a/src/adcp/types/generated_poc/bundled/media_buy/package_request.py b/src/adcp/types/generated_poc/bundled/media_buy/package_request.py index ceb3a6b34..3fd9368da 100644 --- a/src/adcp/types/generated_poc/bundled/media_buy/package_request.py +++ b/src/adcp/types/generated_poc/bundled/media_buy/package_request.py @@ -6,6 +6,7 @@ from enum import Enum, IntEnum from typing import Annotated, Any, Literal +from collections.abc import Sequence from adcp.types.base import AdCPBaseModel from pydantic import AnyUrl, AwareDatetime, ConfigDict, EmailStr, Field, RootModel, StringConstraints @@ -2163,7 +2164,7 @@ class TargetingOverlay(AdCPBaseModel): ), ] = None geo_countries_exclude: Annotated[ - list[GeoCountriesExcludeItem] | None, + Sequence[GeoCountriesExcludeItem] | None, Field( description="Exclude specific countries from delivery. ISO 3166-1 alpha-2 codes (e.g., 'US', 'GB', 'DE').", min_length=1, @@ -2177,7 +2178,7 @@ class TargetingOverlay(AdCPBaseModel): ), ] = None geo_regions_exclude: Annotated[ - list[GeoRegionsExcludeItem] | None, + Sequence[GeoRegionsExcludeItem] | None, Field( description="Exclude specific regions/states from delivery. ISO 3166-2 subdivision codes (e.g., 'US-CA', 'GB-SCT').", min_length=1, @@ -2191,7 +2192,7 @@ class TargetingOverlay(AdCPBaseModel): ), ] = None geo_metros_exclude: Annotated[ - list[GeoMetrosExcludeItem] | None, + Sequence[GeoMetrosExcludeItem] | None, Field( description='Exclude specific metro areas from delivery. Each entry specifies the classification system and excluded values. Seller must declare supported systems in get_adcp_capabilities.', min_length=1, @@ -2205,7 +2206,7 @@ class TargetingOverlay(AdCPBaseModel): ), ] = None geo_postal_areas_exclude: Annotated[ - list[GeoPostalAreasExcludeItem] | None, + Sequence[GeoPostalAreasExcludeItem] | None, Field( description='Exclude specific postal areas from delivery. Each entry specifies the postal system and excluded values. Seller must declare supported systems in get_adcp_capabilities.', min_length=1, @@ -4400,7 +4401,7 @@ class PackageRequest(AdCPBaseModel): ), ] = None creatives: Annotated[ - list[Creative] | None, + Sequence[Creative] | None, Field( description='Upload new creative assets and assign to this package (creatives will be added to library). Use creative_assignments instead for existing library creatives.', max_length=100, diff --git a/src/adcp/types/generated_poc/bundled/media_buy/update_media_buy_request.py b/src/adcp/types/generated_poc/bundled/media_buy/update_media_buy_request.py index 8d1a4a291..113b820f8 100644 --- a/src/adcp/types/generated_poc/bundled/media_buy/update_media_buy_request.py +++ b/src/adcp/types/generated_poc/bundled/media_buy/update_media_buy_request.py @@ -6,6 +6,7 @@ from enum import Enum, IntEnum from typing import Annotated, Any, Literal +from collections.abc import Sequence from adcp.types.base import AdCPBaseModel from pydantic import AnyUrl, AwareDatetime, ConfigDict, EmailStr, Field, RootModel, StringConstraints @@ -2568,7 +2569,7 @@ class TargetingOverlay(AdCPBaseModel): ), ] = None geo_countries_exclude: Annotated[ - list[GeoCountriesExcludeItem] | None, + Sequence[GeoCountriesExcludeItem] | None, Field( description="Exclude specific countries from delivery. ISO 3166-1 alpha-2 codes (e.g., 'US', 'GB', 'DE').", min_length=1, @@ -2582,7 +2583,7 @@ class TargetingOverlay(AdCPBaseModel): ), ] = None geo_regions_exclude: Annotated[ - list[GeoRegionsExcludeItem] | None, + Sequence[GeoRegionsExcludeItem] | None, Field( description="Exclude specific regions/states from delivery. ISO 3166-2 subdivision codes (e.g., 'US-CA', 'GB-SCT').", min_length=1, @@ -2596,7 +2597,7 @@ class TargetingOverlay(AdCPBaseModel): ), ] = None geo_metros_exclude: Annotated[ - list[GeoMetrosExcludeItem] | None, + Sequence[GeoMetrosExcludeItem] | None, Field( description='Exclude specific metro areas from delivery. Each entry specifies the classification system and excluded values. Seller must declare supported systems in get_adcp_capabilities.', min_length=1, @@ -2610,7 +2611,7 @@ class TargetingOverlay(AdCPBaseModel): ), ] = None geo_postal_areas_exclude: Annotated[ - list[GeoPostalAreasExcludeItem] | None, + Sequence[GeoPostalAreasExcludeItem] | None, Field( description='Exclude specific postal areas from delivery. Each entry specifies the postal system and excluded values. Seller must declare supported systems in get_adcp_capabilities.', min_length=1, @@ -7596,7 +7597,7 @@ class UpdateMediaBuyRequest(AdCPBaseModel): AwareDatetime | None, Field(description='New end date/time in ISO 8601 format') ] = None packages: Annotated[ - list[Package] | None, + Sequence[Package] | None, Field(description='Package-specific updates for existing packages', min_length=1), ] = None invoice_recipient: Annotated[ diff --git a/src/adcp/types/generated_poc/bundled/media_buy/update_media_buy_response.py b/src/adcp/types/generated_poc/bundled/media_buy/update_media_buy_response.py index 3a208556d..d6795fa80 100644 --- a/src/adcp/types/generated_poc/bundled/media_buy/update_media_buy_response.py +++ b/src/adcp/types/generated_poc/bundled/media_buy/update_media_buy_response.py @@ -6,6 +6,7 @@ from enum import Enum from typing import Annotated, Any, Literal +from collections.abc import Sequence from adcp.types.base import AdCPBaseModel from pydantic import AnyUrl, AwareDatetime, ConfigDict, EmailStr, Field, RootModel @@ -1871,7 +1872,7 @@ class TargetingOverlay(AdCPBaseModel): ), ] = None geo_countries_exclude: Annotated[ - list[GeoCountriesExcludeItem] | None, + Sequence[GeoCountriesExcludeItem] | None, Field( description="Exclude specific countries from delivery. ISO 3166-1 alpha-2 codes (e.g., 'US', 'GB', 'DE').", min_length=1, @@ -1885,7 +1886,7 @@ class TargetingOverlay(AdCPBaseModel): ), ] = None geo_regions_exclude: Annotated[ - list[GeoRegionsExcludeItem] | None, + Sequence[GeoRegionsExcludeItem] | None, Field( description="Exclude specific regions/states from delivery. ISO 3166-2 subdivision codes (e.g., 'US-CA', 'GB-SCT').", min_length=1, @@ -1899,7 +1900,7 @@ class TargetingOverlay(AdCPBaseModel): ), ] = None geo_metros_exclude: Annotated[ - list[GeoMetrosExcludeItem] | None, + Sequence[GeoMetrosExcludeItem] | None, Field( description='Exclude specific metro areas from delivery. Each entry specifies the classification system and excluded values. Seller must declare supported systems in get_adcp_capabilities.', min_length=1, @@ -1913,7 +1914,7 @@ class TargetingOverlay(AdCPBaseModel): ), ] = None geo_postal_areas_exclude: Annotated[ - list[GeoPostalAreasExcludeItem] | None, + Sequence[GeoPostalAreasExcludeItem] | None, Field( description='Exclude specific postal areas from delivery. Each entry specifies the postal system and excluded values. Seller must declare supported systems in get_adcp_capabilities.', min_length=1, @@ -2388,7 +2389,7 @@ class UpdateMediaBuyResponse3(AdCPBaseModel): ), ] = None affected_packages: Annotated[ - list[AffectedPackage] | None, + Sequence[AffectedPackage] | None, Field(description='Array of packages that were modified with complete state information'), ] = None valid_actions: Annotated[ diff --git a/src/adcp/types/generated_poc/bundled/signals/get_signals_response.py b/src/adcp/types/generated_poc/bundled/signals/get_signals_response.py index 94c40b42b..116d78a7e 100644 --- a/src/adcp/types/generated_poc/bundled/signals/get_signals_response.py +++ b/src/adcp/types/generated_poc/bundled/signals/get_signals_response.py @@ -6,6 +6,7 @@ from enum import Enum from typing import Annotated, Any, Literal +from collections.abc import Sequence from adcp.types.base import AdCPBaseModel from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, RootModel @@ -419,7 +420,7 @@ class Signal(AdCPBaseModel): float, Field(description='Percentage of audience coverage', ge=0.0, le=100.0) ] deployments: Annotated[ - list[Deployments | Deployments3], Field(description='Array of deployment targets') + Sequence[Deployments | Deployments3], Field(description='Array of deployment targets') ] pricing_options: Annotated[ list[PricingOption], @@ -538,7 +539,7 @@ class GetSignalsResponse(AdCPBaseModel): model_config = ConfigDict( extra='allow', ) - signals: Annotated[list[Signal], Field(description='Array of matching signals')] + signals: Annotated[Sequence[Signal], Field(description='Array of matching signals')] errors: Annotated[ list[Error] | None, Field( diff --git a/src/adcp/types/generated_poc/core/placement.py b/src/adcp/types/generated_poc/core/placement.py index 581aef4dd..ca7ebf037 100644 --- a/src/adcp/types/generated_poc/core/placement.py +++ b/src/adcp/types/generated_poc/core/placement.py @@ -5,6 +5,7 @@ from __future__ import annotations from typing import Annotated +from collections.abc import Sequence from adcp.types.base import AdCPBaseModel from pydantic import ConfigDict, Field @@ -35,7 +36,7 @@ class Placement(AdCPBaseModel): ), ] = None format_ids: Annotated[ - list[format_id.FormatReferenceStructuredObject] | None, + Sequence[format_id.FormatReferenceStructuredObject] | None, Field( description='Format IDs supported by this specific placement. Can include: (1) concrete format_ids (fixed dimensions), (2) template format_ids without parameters (accepts any dimensions/duration), or (3) parameterized format_ids (specific dimension/duration constraints).', min_length=1, diff --git a/src/adcp/types/generated_poc/core/targeting.py b/src/adcp/types/generated_poc/core/targeting.py index 92187c3a6..e63e7bcf1 100644 --- a/src/adcp/types/generated_poc/core/targeting.py +++ b/src/adcp/types/generated_poc/core/targeting.py @@ -6,6 +6,7 @@ from enum import Enum from typing import Annotated, Any +from collections.abc import Sequence from adcp.types.base import AdCPBaseModel from pydantic import ConfigDict, Field, RootModel @@ -393,7 +394,7 @@ class TargetingOverlay(AdCPBaseModel): ), ] = None geo_countries_exclude: Annotated[ - list[GeoCountriesExcludeItem] | None, + Sequence[GeoCountriesExcludeItem] | None, Field( description="Exclude specific countries from delivery. ISO 3166-1 alpha-2 codes (e.g., 'US', 'GB', 'DE').", min_length=1, @@ -407,7 +408,7 @@ class TargetingOverlay(AdCPBaseModel): ), ] = None geo_regions_exclude: Annotated[ - list[GeoRegionsExcludeItem] | None, + Sequence[GeoRegionsExcludeItem] | None, Field( description="Exclude specific regions/states from delivery. ISO 3166-2 subdivision codes (e.g., 'US-CA', 'GB-SCT').", min_length=1, @@ -421,7 +422,7 @@ class TargetingOverlay(AdCPBaseModel): ), ] = None geo_metros_exclude: Annotated[ - list[GeoMetrosExcludeItem] | None, + Sequence[GeoMetrosExcludeItem] | None, Field( description='Exclude specific metro areas from delivery. Each entry specifies the classification system and excluded values. Seller must declare supported systems in get_adcp_capabilities.', min_length=1, @@ -435,7 +436,7 @@ class TargetingOverlay(AdCPBaseModel): ), ] = None geo_postal_areas_exclude: Annotated[ - list[GeoPostalAreasExcludeItem] | None, + Sequence[GeoPostalAreasExcludeItem] | None, Field( description='Exclude specific postal areas from delivery. Each entry specifies the postal system and excluded values. Seller must declare supported systems in get_adcp_capabilities.', min_length=1, diff --git a/src/adcp/types/generated_poc/creative/get_creative_delivery_response.py b/src/adcp/types/generated_poc/creative/get_creative_delivery_response.py index 3c8c09dbf..f7f1682b5 100644 --- a/src/adcp/types/generated_poc/creative/get_creative_delivery_response.py +++ b/src/adcp/types/generated_poc/creative/get_creative_delivery_response.py @@ -5,6 +5,7 @@ from __future__ import annotations from typing import Annotated +from collections.abc import Sequence from adcp.types.base import AdCPBaseModel from pydantic import AwareDatetime, ConfigDict, Field @@ -103,7 +104,7 @@ class GetCreativeDeliveryResponse(AdCPBaseModel): ] reporting_period: Annotated[ReportingPeriod, Field(description='Date range for the report.')] creatives: Annotated[ - list[Creative], Field(description='Creative delivery data with variant breakdowns') + Sequence[Creative], Field(description='Creative delivery data with variant breakdowns') ] pagination: Annotated[ Pagination | None, diff --git a/src/adcp/types/generated_poc/creative/list_creatives_response.py b/src/adcp/types/generated_poc/creative/list_creatives_response.py index dcf3dc263..c8a0e290f 100644 --- a/src/adcp/types/generated_poc/creative/list_creatives_response.py +++ b/src/adcp/types/generated_poc/creative/list_creatives_response.py @@ -5,6 +5,7 @@ from __future__ import annotations from typing import Annotated +from collections.abc import Sequence from adcp.types.base import AdCPBaseModel from pydantic import AwareDatetime, ConfigDict, Field, StringConstraints @@ -190,7 +191,7 @@ class ListCreativesResponse(AdCPBaseModel): ] pagination: pagination_response.PaginationResponse creatives: Annotated[ - list[Creative], Field(description='Array of creative assets matching the query') + Sequence[Creative], Field(description='Array of creative assets matching the query') ] format_summary: Annotated[ dict[Annotated[str, StringConstraints(pattern=r'^[a-zA-Z0-9_-]+$')], int] | None, diff --git a/src/adcp/types/generated_poc/media_buy/create_media_buy_request.py b/src/adcp/types/generated_poc/media_buy/create_media_buy_request.py index 88ccb79c5..0060530a6 100644 --- a/src/adcp/types/generated_poc/media_buy/create_media_buy_request.py +++ b/src/adcp/types/generated_poc/media_buy/create_media_buy_request.py @@ -6,6 +6,7 @@ from enum import Enum from typing import Annotated +from collections.abc import Sequence from adcp.types.base import AdCPBaseModel from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field @@ -170,7 +171,7 @@ class CreateMediaBuyRequest(AdCPBaseModel): ), ] = None packages: Annotated[ - list[package_request.PackageRequest] | None, + Sequence[package_request.PackageRequest] | None, Field( description="Array of package configurations. Required when not using proposal_id. When executing a proposal, this can be omitted and packages will be derived from the proposal's allocations.", min_length=1, 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 cf7ae5a33..256b62674 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 @@ -6,6 +6,7 @@ from enum import Enum from typing import Annotated, Any +from collections.abc import Sequence from adcp.types.base import AdCPBaseModel from pydantic import AwareDatetime, ConfigDict, Field @@ -556,7 +557,7 @@ class GetMediaBuyDeliveryResponse(AdCPBaseModel): ), ] = None media_buy_deliveries: Annotated[ - list[MediaBuyDelivery], + Sequence[MediaBuyDelivery], Field( description='Array of delivery data for media buys. When used in webhook notifications, may contain multiple media buys aggregated by publisher. When used in get_media_buy_delivery API responses, typically contains requested media buys.' ), 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 20f963924..0d405c0f9 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 @@ -6,6 +6,7 @@ from enum import Enum from typing import Annotated +from collections.abc import Sequence from adcp.types.base import AdCPBaseModel from pydantic import AwareDatetime, ConfigDict, Field @@ -346,7 +347,7 @@ class GetMediaBuysResponse(AdCPBaseModel): extra='allow', ) media_buys: Annotated[ - list[MediaBuy], + Sequence[MediaBuy], Field( description='Array of media buys with status, creative approval state, and optional delivery snapshots' ), diff --git a/src/adcp/types/generated_poc/media_buy/package_request.py b/src/adcp/types/generated_poc/media_buy/package_request.py index 7bca5b9a6..68c889ce5 100644 --- a/src/adcp/types/generated_poc/media_buy/package_request.py +++ b/src/adcp/types/generated_poc/media_buy/package_request.py @@ -5,6 +5,7 @@ from __future__ import annotations from typing import Annotated +from collections.abc import Sequence from adcp.types.base import AdCPBaseModel from pydantic import AwareDatetime, ConfigDict, Field @@ -113,7 +114,7 @@ class PackageRequest(AdCPBaseModel): ), ] = None creatives: Annotated[ - list[creative_asset.CreativeAsset] | None, + Sequence[creative_asset.CreativeAsset] | None, Field( description='Upload new creative assets and assign to this package (creatives will be added to library). Use creative_assignments instead for existing library creatives.', max_length=100, diff --git a/src/adcp/types/generated_poc/media_buy/update_media_buy_request.py b/src/adcp/types/generated_poc/media_buy/update_media_buy_request.py index 20115716c..cd600b18e 100644 --- a/src/adcp/types/generated_poc/media_buy/update_media_buy_request.py +++ b/src/adcp/types/generated_poc/media_buy/update_media_buy_request.py @@ -5,6 +5,7 @@ from __future__ import annotations from typing import Annotated, Literal +from collections.abc import Sequence from adcp.types.base import AdCPBaseModel from pydantic import AwareDatetime, ConfigDict, Field @@ -66,7 +67,7 @@ class UpdateMediaBuyRequest(AdCPBaseModel): AwareDatetime | None, Field(description='New end date/time in ISO 8601 format') ] = None packages: Annotated[ - list[package_update.PackageUpdate] | None, + Sequence[package_update.PackageUpdate] | None, Field(description='Package-specific updates for existing packages', min_length=1), ] = None invoice_recipient: Annotated[ 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 949437e90..1b57f77a4 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 @@ -5,6 +5,7 @@ from __future__ import annotations from typing import Annotated +from collections.abc import Sequence from adcp.types.base import AdCPBaseModel from pydantic import AwareDatetime, ConfigDict, Field @@ -58,7 +59,7 @@ class UpdateMediaBuyResponse1(AdCPBaseModel): ), ] = None affected_packages: Annotated[ - list[package.Package] | None, + Sequence[package.Package] | None, Field(description='Array of packages that were modified with complete state information'), ] = None valid_actions: Annotated[ diff --git a/src/adcp/types/generated_poc/signals/get_signals_response.py b/src/adcp/types/generated_poc/signals/get_signals_response.py index 442777f65..8a078fb22 100644 --- a/src/adcp/types/generated_poc/signals/get_signals_response.py +++ b/src/adcp/types/generated_poc/signals/get_signals_response.py @@ -5,6 +5,7 @@ from __future__ import annotations from typing import Annotated +from collections.abc import Sequence from adcp.types.base import AdCPBaseModel from pydantic import ConfigDict, Field @@ -67,7 +68,7 @@ class Signal(AdCPBaseModel): float, Field(description='Percentage of audience coverage', ge=0.0, le=100.0) ] deployments: Annotated[ - list[deployment.Deployment], Field(description='Array of deployment targets') + Sequence[deployment.Deployment], Field(description='Array of deployment targets') ] pricing_options: Annotated[ list[vendor_pricing_option.VendorPricingOption], @@ -82,7 +83,7 @@ class GetSignalsResponse(AdCPBaseModel): model_config = ConfigDict( extra='allow', ) - signals: Annotated[list[Signal], Field(description='Array of matching signals')] + signals: Annotated[Sequence[Signal], Field(description='Array of matching signals')] errors: Annotated[ list[error.Error] | None, Field( diff --git a/tests/type_checks/extend_response_with_sequence.py b/tests/type_checks/extend_response_with_sequence.py new file mode 100644 index 000000000..343d2d335 --- /dev/null +++ b/tests/type_checks/extend_response_with_sequence.py @@ -0,0 +1,66 @@ +"""Adopter pattern: extend a library response type with a more-specific element. + +Critical Pattern #1 — subclass a library type, override a parent's +``list[X]`` field with ``list[ChildX]`` where ``ChildX`` carries +extra adopter-internal fields excluded from the wire. + +Before #624 this required ``# type: ignore[assignment]`` on every override +because ``list[T]`` is invariant in T. After #624 the SDK ships the +parent annotation as ``Sequence[T]`` (covariant), so the override +typechecks under mypy --strict with zero ignores while keeping +``.append()`` ergonomics on the child class. + +**Scope this test pins down:** Optional list fields where the parent's +element type matches the public alias (``adcp.types.X``). The widening +also applies to required-list fields, but a separate codegen issue +(``X`` emitted multiple times across response files; the public alias +resolves to one emission while ``ResponseFoo.bar: Sequence[X]`` +references a *different* local emission with the same name) means +required-field overrides hit a type-identity mismatch *before* variance +matters. That's tracked separately — Sequence widening is necessary +but not sufficient for those cases. + +**Documented limitation: Optional widening is rejected** — overriding +a required parent (``Sequence[X]``) with an optional child +(``list[Y] | None``) is a genuine Liskov-incompatibility (parent +promises present; child weakens to ``None``-allowed). Adopters must +keep optionality identical between parent and child. +""" + +from __future__ import annotations + +from pydantic import Field + +from adcp.types import Package +from adcp.types.generated_poc.media_buy.update_media_buy_response import ( + UpdateMediaBuyResponse1, +) + +# --- Optional parent → Optional child (narrower element) --- + + +class _InternalPackage(Package): + """Adopter extension — carries fields excluded from the wire envelope.""" + + internal_state: str | None = Field(default=None, exclude=True) + + +class _ExtendedUpdateMediaBuyResponse(UpdateMediaBuyResponse1): + """Adopter override — narrower element type on the response field. + + Library declares ``affected_packages: Sequence[Package] | None``. + Adopter declares ``list[_InternalPackage] | None`` here, which is a + valid subtype under Sequence's covariance — no ``# type: ignore``. + """ + + affected_packages: list[_InternalPackage] | None = None + + +# Construction + .append() prove runtime ergonomics survive the widening. +resp = _ExtendedUpdateMediaBuyResponse( + media_buy_id="mb_1", + affected_packages=[_InternalPackage(package_id="p1", internal_state="active")], +) +assert resp.affected_packages is not None +resp.affected_packages.append(_InternalPackage(package_id="p2")) +assert len(resp.affected_packages) == 2