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)