diff --git a/SCHEMA_DELTAS.md b/SCHEMA_DELTAS.md
index 6bc7f45a0..b76c65f79 100644
--- a/SCHEMA_DELTAS.md
+++ b/SCHEMA_DELTAS.md
@@ -1,21 +1,6 @@
# Generated-types delta
-## Files added
-
-- `core/match_key.py` — MatchKey
-- `enums/match_key_use.py` — MatchKeyUse
-
## Field changes
-- `bundled/protocol/get_adcp_capabilities_response.py`
- - **classes added**: BrandKitOverride11, PropertyType, SupportedMatchKey, Use, Vendor9
- - **classes removed**: Surface
- - `MediaBuy`: `+supported_match_keys`
-- `core/product_identity.py`
- - `ProductIdentity`: `+match_keys`
-- `protocol/get_adcp_capabilities_response.py`
- - `MediaBuy`: `+supported_match_keys`
-- `trusted_match/provider_registration.py`
- - **classes added**: MatchKey, Use
- - `TmpProviderRegistration1`: `+match_keys`
- - `TmpProviderRegistration2`: `+match_keys`
+- `core/wholesale_feed_event.py`
+ - **classes removed**: Range
diff --git a/docs/shared-type-names.md b/docs/shared-type-names.md
index b4082b6aa..f9d2a6875 100644
--- a/docs/shared-type-names.md
+++ b/docs/shared-type-names.md
@@ -5,10 +5,10 @@ schemas legitimately declare a class called `QuerySummary` or `Creative`.
`adcp.types` binds one of them per name. Import from the domain module to
name the one you mean.
-515 names, 1680 variants.
+515 names, 1679 variants.
Auto-generated by `scripts/consolidate_exports.py`. DO NOT EDIT MANUALLY.
-Generation date: 2026-11-08 02:42:08 UTC
+Generation date: 2026-11-09 22:26:05 UTC
| Type name | Import one of |
| --- | --- |
@@ -371,7 +371,7 @@ Generation date: 2026-11-08 02:42:08 UTC
| `Qualifier` | `adcp.types.domains.core.committed_metric.Qualifier`
`adcp.types.domains.core.delivery_metric_aggregate.Qualifier`
`adcp.types.domains.core.missing_metric.Qualifier`
`adcp.types.domains.core.package_delivery_metric_value.Qualifier`
`adcp.types.domains.core.performance_feedback.Qualifier`
`adcp.types.domains.core.performance_feedback_metric.Qualifier`
`adcp.types.domains.core.vendor_metric_value.Qualifier`
`adcp.types.domains.media_buy.package_forecast_input.Qualifier`
`adcp.types.domains.media_buy.package_request.Qualifier` |
| `QuerySummary` | `adcp.types.domains.core.tasks_list_response.QuerySummary`
`adcp.types.domains.creative.list_creatives_response.QuerySummary`
`adcp.types.domains.protocol.list_tasks_response.QuerySummary` |
| `Radius` | `adcp.types.domains.core.catchment.Radius`
`adcp.types.domains.core.product_filters.Radius`
`adcp.types.domains.core.product_offer_filters.Radius`
`adcp.types.domains.core.targeting.Radius`
`adcp.types.domains.core.targeting_input.Radius` |
-| `Range` | `adcp.types.domains.core.audience_characteristic.Range`
`adcp.types.domains.core.signal_definition.Range`
`adcp.types.domains.core.signal_listing.Range`
`adcp.types.domains.core.wholesale_feed_event.Range`
`adcp.types.domains.property.property_feature_definition.Range`
`adcp.types.domains.protocol.get_adcp_capabilities_response.Range`
`adcp.types.domains.signals.get_signals_response.Range` |
+| `Range` | `adcp.types.domains.core.audience_characteristic.Range`
`adcp.types.domains.core.signal_definition.Range`
`adcp.types.domains.core.signal_listing.Range`
`adcp.types.domains.property.property_feature_definition.Range`
`adcp.types.domains.protocol.get_adcp_capabilities_response.Range`
`adcp.types.domains.signals.get_signals_response.Range` |
| `ReachWindow` | `adcp.types.domains.compliance.comply_test_controller_request.ReachWindow`
`adcp.types.domains.core.delivery_metrics.ReachWindow` |
| `Reason` | `adcp.types.domains.adagents.Reason`
`adcp.types.domains.core.async_response_refs.creative.sync_creatives_async_response_input_required.Reason`
`adcp.types.domains.core.async_response_refs.media_buy.build_creative_async_response_input_required.Reason`
`adcp.types.domains.core.async_response_refs.media_buy.create_media_buy_async_response_input_required.Reason`
`adcp.types.domains.core.async_response_refs.media_buy.get_products_async_response_input_required.Reason`
`adcp.types.domains.core.async_response_refs.media_buy.sync_catalogs_async_response_input_required.Reason`
`adcp.types.domains.core.async_response_refs.media_buy.update_media_buy_async_response_input_required.Reason`
`adcp.types.domains.core.brand_response_authorization_result.Reason`
`adcp.types.domains.core.capabilities_changed_webhook.Reason`
`adcp.types.domains.core.catalog_item_availability_update.Reason`
`adcp.types.domains.core.principal_changed_webhook.Reason`
`adcp.types.domains.core.reporting_coverage.Reason`
`adcp.types.domains.core.reporting_subscription_state.Reason`
`adcp.types.domains.creative.sync_creatives_async_response_input_required.Reason`
`adcp.types.domains.error_details.agent_permission_denied.Reason`
`adcp.types.domains.error_details.execution_requirement_unmet.Reason`
`adcp.types.domains.media_buy.build_creative_async_response_input_required.Reason`
`adcp.types.domains.media_buy.create_media_buy_async_response_input_required.Reason`
`adcp.types.domains.media_buy.get_products_async_response_input_required.Reason`
`adcp.types.domains.media_buy.sync_catalogs_async_response_input_required.Reason`
`adcp.types.domains.media_buy.update_media_buy_async_response_input_required.Reason`
`adcp.types.domains.sponsored_intelligence.si_terminate_session_request.Reason` |
| `ReasonCode` | `adcp.types.domains.core.account_status_changed_webhook.ReasonCode`
`adcp.types.domains.core.attestation_evaluation.ReasonCode`
`adcp.types.domains.core.reporting_adjustment.ReasonCode` |
diff --git a/scripts/post_generate_fixes.py b/scripts/post_generate_fixes.py
index e2c597f1d..59c9dc28a 100644
--- a/scripts/post_generate_fixes.py
+++ b/scripts/post_generate_fixes.py
@@ -5135,56 +5135,153 @@ def fix_mcp_webhook_operation_id_optional() -> None:
print(" core/mcp_webhook_payload.py: made operation_id optional")
+class _DropDescriptions(ast.NodeTransformer):
+ """Strip ``description=`` from ``Field(...)`` and unwrap a bare ``Annotated[T]``."""
+
+ def visit_Call(self, node: ast.Call) -> ast.AST:
+ self.generic_visit(node)
+ if isinstance(node.func, ast.Name) and node.func.id == "Field":
+ node.keywords = [k for k in node.keywords if k.arg != "description"]
+ return node
+
+ def visit_Subscript(self, node: ast.Subscript) -> ast.AST:
+ self.generic_visit(node)
+ if not (isinstance(node.value, ast.Name) and node.value.id == "Annotated"):
+ return node
+ elts = list(node.slice.elts) if isinstance(node.slice, ast.Tuple) else [node.slice]
+ kept = [
+ e
+ for e in elts[1:]
+ if not (
+ isinstance(e, ast.Call)
+ and isinstance(e.func, ast.Name)
+ and e.func.id == "Field"
+ and not e.args
+ and not e.keywords
+ )
+ ]
+ if not kept:
+ return elts[0]
+ node.slice = ast.Tuple(elts=[elts[0], *kept], ctx=ast.Load())
+ return node
+
+
+def _range_shape(node: ast.ClassDef) -> str:
+ """What a generated ``Range`` class validates, with descriptions removed.
+
+ The copies datamodel-code-generator emits differ from ``SignalListing.Range``
+ only in field descriptions. Bases, ``model_config`` (so an open schema
+ differs from a closed one), field types, constraints and defaults all count.
+ """
+ stripped = _DropDescriptions().visit(ast.parse(ast.unparse(node)).body[0])
+ assert isinstance(stripped, ast.ClassDef)
+ stripped.body = [
+ stmt
+ for stmt in stripped.body
+ if not (isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Constant))
+ ]
+ return ast.unparse(stripped)
+
+
+def _top_level_class(tree: ast.Module, name: str) -> ast.ClassDef | None:
+ return next(
+ (node for node in tree.body if isinstance(node, ast.ClassDef) and node.name == name),
+ None,
+ )
+
+
+def _extends_signal_listing(node: ast.ClassDef) -> bool:
+ return any(
+ (isinstance(base, ast.Name) and base.id == "SignalListing")
+ or (isinstance(base, ast.Attribute) and base.attr == "SignalListing")
+ for base in node.bases
+ )
+
+
def fix_signal_listing_range_subclasses() -> None:
- """Reuse SignalListing.Range for generated subclasses that redeclare range."""
- replacements = {
- OUTPUT_DIR
- / "signals"
- / "get_signals_response.py": [
- (
- "from ..core.signal_listing import SignalListing\n",
- "from ..core.signal_listing import Range, SignalListing\n",
- ),
- (
- "\n\nclass Range(AdCPBaseModel):\n"
- " model_config = ConfigDict(\n"
- " extra='forbid',\n"
- " )\n"
- " min: Annotated[float, Field(description='Minimum value (inclusive)')]\n"
- " max: Annotated[float, Field(description='Maximum value (inclusive)')]\n",
- "",
- ),
- ],
- OUTPUT_DIR
- / "core"
- / "wholesale_feed_event.py": [
- (
- "from .signal_listing import SignalListing\n",
- "from .signal_listing import Range, SignalListing\n",
- ),
- (
- "\n\nclass Range(AdCPBaseModel):\n"
- " model_config = ConfigDict(\n"
- " extra='forbid',\n"
- " )\n"
- " min: float\n"
- " max: float\n",
- "",
- ),
- ],
- }
+ """Reuse SignalListing.Range in every module that subclasses SignalListing.
+
+ A class that extends ``core/signal-listing.json`` redeclares ``range``, and
+ the generator gives that redeclaration its own ``Range`` class: identical to
+ ``SignalListing.Range`` but a different type, so the subclass's ``range`` no
+ longer has its parent's type and a ``SignalListing.Range`` does not validate
+ there. Every generated module with a ``SignalListing`` subclass is checked.
+ Its ``Range`` is compared with ``SignalListing.Range`` by what it validates,
+ deleted, and imported from ``signal_listing`` instead, so the pass keeps
+ working when the generator changes descriptions, scalar types or the schema's
+ ``additionalProperties``. It used to match the generated text of two named
+ files, ``extra='forbid'`` and all, and reported success once that text drifted.
+
+ A module that declares ``Range`` without subclassing ``SignalListing`` keeps
+ it, as every schema keeps its own classes: ``signals/get_signals_response.py``
+ has inlined its signal since 3.3 and has no parent type to match.
+
+ Raises when such a ``Range`` no longer validates like ``SignalListing.Range``
+ (deleting it would change the wire contract), when the module imports
+ ``SignalListing`` in a form the shared ``Range`` cannot be added to, or when
+ a duplicate survives: a silent miss is a regeneration failure.
+ """
+ listing_path = OUTPUT_DIR / "core" / "signal_listing.py"
+ if not listing_path.exists():
+ print(" signal_listing.py not found (skipping SignalListing.Range reuse)")
+ return
+ listing = _top_level_class(ast.parse(listing_path.read_text()), "Range")
fixed = 0
- for path, path_replacements in replacements.items():
- if not path.exists():
+ for path in sorted(OUTPUT_DIR.rglob("*.py")):
+ if path == listing_path:
continue
source = path.read_text()
- updated = source
- for old, new in path_replacements:
- updated = updated.replace(old, new, 1)
- if updated != source:
- path.write_text(updated)
- fixed += 1
+ if "SignalListing" not in source:
+ continue
+ tree = ast.parse(source)
+ local = _top_level_class(tree, "Range")
+ if local is None or not any(
+ isinstance(node, ast.ClassDef) and _extends_signal_listing(node) for node in tree.body
+ ):
+ continue
+ relative = path.relative_to(OUTPUT_DIR).as_posix()
+ if listing is None:
+ raise RuntimeError(
+ f"{relative}: subclasses SignalListing and declares Range, but "
+ "core/signal_listing.py no longer declares Range"
+ )
+ if _range_shape(local) != _range_shape(listing):
+ raise RuntimeError(
+ f"{relative}: Range no longer matches SignalListing.Range, so it cannot be "
+ f"reused; revisit fix_signal_listing_range_subclasses.\n"
+ f" local: {_range_shape(local)!r}\n"
+ f" listing: {_range_shape(listing)!r}"
+ )
+ imported = next(
+ (
+ node
+ for node in tree.body
+ if isinstance(node, ast.ImportFrom)
+ and (node.module or "").split(".")[-1] == "signal_listing"
+ and any(a.name == "SignalListing" and a.asname is None for a in node.names)
+ ),
+ None,
+ )
+ if imported is None:
+ raise RuntimeError(
+ f"{relative}: subclasses SignalListing without a "
+ "'from ...signal_listing import SignalListing' to add Range to"
+ )
+
+ lines = source.splitlines(keepends=True)
+ assert local.end_lineno is not None
+ start = sum(map(len, lines[: local.lineno - 1]))
+ end = sum(map(len, lines[: local.end_lineno]))
+ if source[start - 2 : start] == "\n\n":
+ start -= 2
+ source = source[:start] + source[end:]
+ source = add_to_import(source, "." * imported.level + (imported.module or ""), "Range")
+
+ if _top_level_class(ast.parse(source), "Range") is not None:
+ raise RuntimeError(f"{relative}: a duplicate Range class survived the reuse pass")
+ path.write_text(source)
+ fixed += 1
if fixed:
print(f" Reused SignalListing.Range in {fixed} subclass file(s)")
diff --git a/scripts/post_generation_manifest.json b/scripts/post_generation_manifest.json
index ba0f764a8..760931f4a 100644
--- a/scripts/post_generation_manifest.json
+++ b/scripts/post_generation_manifest.json
@@ -75,7 +75,7 @@
"preserve_wire_text_on_normalizing_fields": true,
"insert_task_message_markers": true,
"point_integer_fields_at_the_schema_integer_type": true,
- "remove_imports_shadowed_by_a_local_class": true,
+ "remove_imports_shadowed_by_a_local_class": "Its only target was core/wholesale_feed_event.py, where fix_signal_listing_range_subclasses added a Range import but missed the duplicate Range class it should have deleted. That pass now deletes the class, so no import is shadowed. Kept as a guard for the generator's import-and-redeclare pattern.",
"remove_unused_pydantic_field_imports": true,
"emit_open_asset_unions": true,
"declare_root_schema_module_exports": true,
diff --git a/src/adcp/types/domains/core/wholesale_feed_event.py b/src/adcp/types/domains/core/wholesale_feed_event.py
index bd3985b1d..9858e8ad2 100644
--- a/src/adcp/types/domains/core/wholesale_feed_event.py
+++ b/src/adcp/types/domains/core/wholesale_feed_event.py
@@ -1,6 +1,6 @@
# generated by datamodel-codegen:
# filename: core/wholesale_feed_event.json
-# timestamp: 2026-10-29T01:56:42+00:00
+# timestamp: 2026-11-09T22:24:53+00:00
from __future__ import annotations
@@ -20,7 +20,7 @@
from . import signal_id as signal_id_1
from . import signal_ref as signal_ref_1
from . import vendor_pricing_option
-from .signal_listing import SignalListing
+from .signal_listing import Range, SignalListing
class EventType(StrEnum):
@@ -90,14 +90,6 @@ class RemovalReason(StrEnum):
policy_takedown = 'policy_takedown'
-class Range(AdCPBaseModel):
- model_config = ConfigDict(
- extra='forbid',
- )
- min: StrictFloat
- max: StrictFloat
-
-
class Payload(AdCPBaseModel):
model_config = ConfigDict(
extra='allow',
diff --git a/tests/test_signal_listing_range_reuse.py b/tests/test_signal_listing_range_reuse.py
new file mode 100644
index 000000000..9a8305801
--- /dev/null
+++ b/tests/test_signal_listing_range_reuse.py
@@ -0,0 +1,303 @@
+"""``fix_signal_listing_range_subclasses`` reuses ``SignalListing.Range`` by shape.
+
+A class that extends ``core/signal-listing.json`` gets its own generated
+``Range`` class, identical to ``SignalListing.Range`` but a different type. The
+post-generation pass used to find that class by matching the generated text of
+two named files, ``extra='forbid'`` and all. The generator drifted
+(``StrictFloat``, new descriptions), the text stopped matching, the duplicate
+classes stayed, and the pass still printed success. adcp#8866 opens the schema
+(``extra='allow'``), which would have kept the text match missing.
+
+The pass now scans every generated module for a ``SignalListing`` subclass that
+declares ``Range``, compares the copy with ``SignalListing.Range`` by what it
+validates (bases, ``model_config``, field types, constraints, defaults; not
+descriptions), deletes it, and adds ``Range`` to the module's
+``signal_listing`` import. It raises when a copy diverges, when the import
+cannot be extended, or when a ``Range`` survives. A module that declares
+``Range`` without subclassing ``SignalListing`` (``get_signals_response``
+inlines its signal) keeps its own.
+"""
+
+from __future__ import annotations
+
+import ast
+from pathlib import Path
+
+import pytest
+
+from scripts import post_generate_fixes as fixes
+
+FORBID = " model_config = ConfigDict(\n extra='forbid',\n )\n"
+ALLOW = " model_config = ConfigDict(\n extra='allow',\n )\n"
+
+HEADER = "from __future__ import annotations\n\n"
+
+# Subclass modules in two domains, to show the scan is not tied to file names.
+SUBCLASSES = {
+ "core/wholesale_feed_event.py": "from .signal_listing import SignalListing\n",
+ "media_buy/some_feed.py": "from ..core.signal_listing import SignalListing\n",
+}
+INLINE = "signals/get_signals_response.py"
+IMPORTS = (
+ "from typing import Annotated\n\n"
+ "from adcp.types.base import AdCPBaseModel, NoExplicitNull\n"
+ "from pydantic import ConfigDict, Field, StrictFloat\n\n"
+)
+
+
+def _range(config: str = FORBID, *, described: bool = False, body: str | None = None) -> str:
+ if body is None:
+ if described:
+ body = (
+ " min: Annotated[StrictFloat, Field(description='Minimum value, inclusive.')]\n"
+ " max: Annotated[StrictFloat, Field(description='Maximum value, inclusive.')]\n"
+ )
+ else:
+ body = " min: StrictFloat\n max: StrictFloat\n"
+ return f"class Range(AdCPBaseModel):\n{config}{body}"
+
+
+def _listing(range_class: str | None) -> str:
+ text = HEADER + IMPORTS + "\n"
+ if range_class:
+ text += range_class + "\n\n"
+ text += (
+ "class SignalListing(AdCPBaseModel):\n"
+ " model_config = ConfigDict(\n extra='allow',\n )\n"
+ " range: Annotated[Range | None, NoExplicitNull] = None\n"
+ )
+ return text
+
+
+def _subclass(
+ range_class: str | None,
+ import_line: str = "from .signal_listing import SignalListing\n",
+ base: str = "SignalListing",
+) -> str:
+ text = HEADER + IMPORTS + import_line + "\n\n"
+ if range_class:
+ text += range_class + "\n\n"
+ text += f"class Signal({base}):\n range: Annotated[Range | None, NoExplicitNull] = None\n"
+ return text
+
+
+def _inline(range_class: str) -> str:
+ return (
+ HEADER
+ + IMPORTS
+ + "from ..core.signal_listing import SignalListing\n\n\n"
+ + range_class
+ + "\n\n"
+ + "class Signal(AdCPBaseModel):\n"
+ + " range: Annotated[Range | None, NoExplicitNull] = None\n"
+ )
+
+
+@pytest.fixture
+def generated(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
+ directory = tmp_path / "domains"
+ for sub in ("core", "signals", "media_buy"):
+ (directory / sub).mkdir(parents=True)
+ monkeypatch.setattr(fixes, "OUTPUT_DIR", directory)
+ return directory
+
+
+def _put(generated: Path, listing: str, modules: dict[str, str]) -> dict[str, Path]:
+ (generated / "core" / "signal_listing.py").write_text(listing)
+ paths = {}
+ for name, text in modules.items():
+ paths[name] = generated / name
+ paths[name].write_text(text)
+ return paths
+
+
+def _assert_reused(paths: dict[str, Path]) -> None:
+ for name, path in paths.items():
+ source = path.read_text()
+ tree = ast.parse(source)
+ compile(source, name, "exec")
+ assert not any(isinstance(n, ast.ClassDef) and n.name == "Range" for n in tree.body), name
+ assert "import Range, SignalListing\n" in source, name
+ assert "_SignalListingRange" not in source, name
+
+
+def _scenario(generated: Path, config: str) -> dict[str, Path]:
+ return _put(
+ generated,
+ _listing(_range(config, described=True)),
+ {
+ "core/wholesale_feed_event.py": _subclass(_range(config)),
+ "media_buy/some_feed.py": _subclass(
+ _range(config, described=True),
+ "from ..core.signal_listing import SignalListing\n",
+ ),
+ },
+ )
+
+
+def test_closed_copies_of_the_beta5_shape_are_reused(generated: Path) -> None:
+ paths = _scenario(generated, FORBID)
+ fixes.fix_signal_listing_range_subclasses()
+ _assert_reused(paths)
+
+
+def test_open_copies_of_the_8866_shape_are_reused(generated: Path) -> None:
+ paths = _scenario(generated, ALLOW)
+ fixes.fix_signal_listing_range_subclasses()
+ _assert_reused(paths)
+
+
+def test_copies_with_no_model_config_are_reused(generated: Path) -> None:
+ paths = _scenario(generated, "")
+ fixes.fix_signal_listing_range_subclasses()
+ _assert_reused(paths)
+
+
+def test_description_only_differences_do_not_count(generated: Path) -> None:
+ listing = _range(
+ FORBID,
+ body=(
+ " min: Annotated[StrictFloat, Field(description='Lower bound.')]\n"
+ " max: Annotated[StrictFloat, Field(description='Upper bound.')]\n"
+ ),
+ )
+ paths = _put(
+ generated,
+ _listing(listing),
+ {"core/wholesale_feed_event.py": _subclass(_range(FORBID))},
+ )
+ fixes.fix_signal_listing_range_subclasses()
+ _assert_reused(paths)
+
+
+DIVERGENT = {
+ "listing open, copy closed": (
+ _range(ALLOW, described=True),
+ _range(FORBID, described=True),
+ ),
+ "field type": (
+ _range(FORBID, described=True),
+ _range(FORBID, body=" min: int\n max: StrictFloat\n"),
+ ),
+ "extra field": (
+ _range(FORBID, described=True),
+ _range(FORBID, body=" min: StrictFloat\n max: StrictFloat\n step: StrictFloat\n"),
+ ),
+ "field constraint": (
+ _range(FORBID, described=True),
+ _range(
+ FORBID,
+ body=(
+ " min: Annotated[StrictFloat, Field(ge=0)]\n"
+ " max: Annotated[StrictFloat, Field(description='Maximum value, inclusive.')]\n"
+ ),
+ ),
+ ),
+}
+
+
+@pytest.mark.parametrize("listing,copy", DIVERGENT.values(), ids=DIVERGENT.keys())
+@pytest.mark.parametrize("target", sorted(SUBCLASSES))
+def test_a_divergent_copy_raises_and_leaves_the_file_alone(
+ generated: Path, listing: str, copy: str, target: str
+) -> None:
+ paths = _put(
+ generated,
+ _listing(listing),
+ {target: _subclass(copy, SUBCLASSES[target])},
+ )
+ before = paths[target].read_text()
+ with pytest.raises(RuntimeError, match="no longer matches SignalListing.Range"):
+ fixes.fix_signal_listing_range_subclasses()
+ assert paths[target].read_text() == before
+
+
+def test_a_module_that_does_not_subclass_keeps_its_own_range(generated: Path) -> None:
+ # get_signals_response inlines its signal: no parent type, so no reuse and no raise,
+ # even when its Range differs from SignalListing.Range.
+ for copy in (_range(FORBID, described=True), _range(ALLOW, body=" min: int\n")):
+ inline = _inline(copy)
+ paths = _put(generated, _listing(_range(FORBID, described=True)), {INLINE: inline})
+ fixes.fix_signal_listing_range_subclasses()
+ assert paths[INLINE].read_text() == inline
+
+
+def test_running_twice_is_idempotent(generated: Path) -> None:
+ paths = _scenario(generated, FORBID)
+ fixes.fix_signal_listing_range_subclasses()
+ first = {name: path.read_text() for name, path in paths.items()}
+ fixes.fix_signal_listing_range_subclasses()
+ assert {name: path.read_text() for name, path in paths.items()} == first
+
+
+def test_a_subclass_without_range_is_unchanged(generated: Path) -> None:
+ text = _subclass(None)
+ paths = _put(generated, _listing(_range(FORBID)), {"core/wholesale_feed_event.py": text})
+ fixes.fix_signal_listing_range_subclasses()
+ assert paths["core/wholesale_feed_event.py"].read_text() == text
+
+
+def test_a_subclass_range_without_a_listing_range_raises(generated: Path) -> None:
+ paths = _put(
+ generated,
+ _listing(None),
+ {"core/wholesale_feed_event.py": _subclass(_range(FORBID))},
+ )
+ before = paths["core/wholesale_feed_event.py"].read_text()
+ with pytest.raises(RuntimeError, match="core/signal_listing.py no longer declares Range"):
+ fixes.fix_signal_listing_range_subclasses()
+ assert paths["core/wholesale_feed_event.py"].read_text() == before
+
+
+def test_a_parenthesized_import_gets_range_inside_it(generated: Path) -> None:
+ paths = _put(
+ generated,
+ _listing(_range(FORBID)),
+ {
+ "core/wholesale_feed_event.py": _subclass(
+ _range(FORBID),
+ import_line="from .signal_listing import (\n SignalListing,\n)\n",
+ )
+ },
+ )
+ fixes.fix_signal_listing_range_subclasses()
+ source = paths["core/wholesale_feed_event.py"].read_text()
+ compile(source, "wholesale_feed_event.py", "exec")
+ assert "from .signal_listing import (\n Range,\n SignalListing,\n)\n" in source
+ assert "class Range" not in source
+
+
+def test_an_attribute_base_without_a_direct_import_raises(generated: Path) -> None:
+ text = _subclass(
+ _range(FORBID),
+ import_line="from . import signal_listing as signal_listing_1\n",
+ base="signal_listing_1.SignalListing",
+ )
+ paths = _put(generated, _listing(_range(FORBID)), {"core/wholesale_feed_event.py": text})
+ with pytest.raises(
+ RuntimeError,
+ match="subclasses SignalListing without a 'from ...signal_listing import SignalListing'",
+ ):
+ fixes.fix_signal_listing_range_subclasses()
+ assert paths["core/wholesale_feed_event.py"].read_text() == text
+
+
+def test_the_generated_tree_shares_range_only_where_it_subclasses() -> None:
+ from adcp.types.domains import signals
+ from adcp.types.domains.core import signal_listing, wholesale_feed_event
+ from adcp.types.domains.signals import get_signals_response
+
+ assert wholesale_feed_event.Range is signal_listing.Range
+ assert (
+ wholesale_feed_event.Signal.model_fields["range"].annotation
+ == signal_listing.SignalListing.model_fields["range"].annotation
+ )
+ assert get_signals_response.Range is not signal_listing.Range
+ assert signals.Range is get_signals_response.Range
+
+ def declares_range(module: object) -> bool:
+ tree = ast.parse(Path(module.__file__).read_text()) # type: ignore[attr-defined]
+ return any(isinstance(n, ast.ClassDef) and n.name == "Range" for n in tree.body)
+
+ assert not declares_range(wholesale_feed_event)
+ assert declares_range(get_signals_response)