From 5a5d5f6fe86df854e3c2a296883bbd4c86b48ba1 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sun, 18 Oct 2026 03:35:15 +0000 Subject: [PATCH] feat(types)!: remove the adcp.types.generated_poc alias in v10 The 9.x line served the pre-9.0 adcp.types.generated_poc import path through a deprecation alias, with REMOVED_IN_MAJOR = 10. next now releases 10.0.0-alpha.N, and the alias's own sunset test fails on the 10.0.0-alpha.0 release PR. This removes the alias as planned: the _generated_poc_alias module, its install call in adcp.types, and its test module. Migration tests now assert the old path is gone; the docs and codemod checks are kept. Adopters still on the old path run adcp migrate v3-to-v4 --apply, which rewrites the imports to adcp.types.domains. BREAKING CHANGE: adcp.types.generated_poc no longer imports. Use adcp.types.domains, or run adcp migrate v3-to-v4 --apply. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/adcp/migrate/v3_to_v4.py | 6 +- src/adcp/types/__init__.py | 9 - src/adcp/types/_generated_poc_alias.py | 291 ----------- tests/test_generated_poc_alias.py | 654 ------------------------- tests/test_migrate_v3_to_v4.py | 57 +-- 5 files changed, 22 insertions(+), 995 deletions(-) delete mode 100644 src/adcp/types/_generated_poc_alias.py delete mode 100644 tests/test_generated_poc_alias.py diff --git a/src/adcp/migrate/v3_to_v4.py b/src/adcp/migrate/v3_to_v4.py index 6c80f75e0..52536f87c 100644 --- a/src/adcp/migrate/v3_to_v4.py +++ b/src/adcp/migrate/v3_to_v4.py @@ -142,7 +142,8 @@ # the rewrite is a prefix rename, with one exception: a root discovery schema # whose basename collided with a task-schema directory split into # ``_discovery`` (its own classes) and ```` (the aggregator). -# ``adcp.types._generated_poc_alias`` is the runtime half of the same contract. +# 9.x also served the old path at runtime through a deprecation alias; v10 +# removed it, so this codemod is now the only migration path. DEPRECATED_TYPES_ROOT = "adcp.types.generated_poc" CANONICAL_TYPES_ROOT = "adcp.types.domains" ROOT_DISCOVERY_SUFFIX = "_discovery" @@ -167,8 +168,7 @@ def _split_halves(stem: str) -> tuple[str, str] | None: Answered by what the installed SDK ships rather than a table here, so a schema added to ``scripts/generate_types.py``'s ``ROOT_DISCOVERY_SCHEMAS`` tomorrow is resolved without touching this file. The ``_discovery`` suffix is - the generator's own, the same one ``adcp.types._generated_poc_alias`` serves - at runtime; ``tests/test_migrate_v3_to_v4.py`` pins the two spellings equal. + the generator's own (``scripts/generate_types.py``). """ if not stem or "." in stem: return None diff --git a/src/adcp/types/__init__.py b/src/adcp/types/__init__.py index 71232446f..ebad49b5b 100644 --- a/src/adcp/types/__init__.py +++ b/src/adcp/types/__init__.py @@ -55,19 +55,10 @@ class name, or a numbered variant has no semantic alias — import it by its import importlib.util from typing import TYPE_CHECKING -from adcp.types import _generated_poc_alias - -# Serves the pre-9.0 ``adcp.types.generated_poc.*`` paths from the modules they -# moved to, as the SAME module objects, with a ``DeprecationWarning``. Removed -# in v10. Installing a finder imports no generated module, so this costs the -# lazy surface nothing. -_generated_poc_alias.install() - __pdoc__ = { "generated_poc": False, "mypy_plugin": False, "_eager": False, - "_generated_poc_alias": False, } __all__ = [ diff --git a/src/adcp/types/_generated_poc_alias.py b/src/adcp/types/_generated_poc_alias.py deleted file mode 100644 index 12203e64b..000000000 --- a/src/adcp/types/_generated_poc_alias.py +++ /dev/null @@ -1,291 +0,0 @@ -"""The pre-9.0 generated tree's import path, aliased to the module it moved to. - -The generator used to write its models to ``adcp.types.generated_poc`` and the -public surface re-exported them. It now writes them to ``adcp.types.domains`` -directly: the module that defines a class is the module adopters import it -from, so there is no private tree left to re-export. - -Almost every module stem moved unchanged, so the migration is mostly a prefix -rename:: - - -from adcp.types.generated_poc.media_buy.package_request import PackageRequest - +from adcp.types.domains.media_buy.package_request import PackageRequest - -``adcp migrate v3-to-v4 --apply`` rewrites those lines (and resolves the -split stem below per imported name); ``adcp.types`` remains the first choice, -and a domain path is for a name the flat namespace cannot bind, as -``docs/type-surface.md`` describes. - -**A root discovery schema is the exception, and a prefix rename loses it.** -``scripts/generate_types.py``'s ``ROOT_DISCOVERY_SCHEMAS`` names every schema -whose basename collides with a task-schema DIRECTORY: ``brand.json`` against -``brand/*.json``. Pre-9.0 the generator wrote such a schema's models into the -colliding package's ``__init__`` — ``generated_poc/brand/__init__.py`` WAS -``brand.json``, 140 classes including ``Brand`` — and 9.0 writes them to -``domains/_discovery.py`` instead, leaving ``domains//__init__.py`` -to the generated domain aggregator. So one deprecated name has two canonical -halves, and ``generated_poc.brand`` resolving to ``domains.brand`` alone raises -``ImportError: cannot import name 'Brand'`` — an error naming a module the -adopter never wrote. ``_SplitAliasModule`` below serves both halves under the -one deprecated name, discovery half first because that is the surface the -pre-9.0 module had. The ``_discovery`` suffix is the generator's own naming -(``ROOT_DISCOVERY_SCHEMAS``' value for ``brand.json`` is ``brand_discovery.py``) -rather than a second list here, and -``tests/test_generated_poc_alias.py::test_a_root_discovery_module_is_named_the_way_the_alias_derives_it`` -fails the build if the generator ever names one some other way. - -#1360 measured 110 such imports in ONE production seller, so 9.0 keeps the old -path working for the 9.x line and warns, instead of breaking every one of them -at once. **It is removed in v10** — and -``tests/test_generated_poc_alias.py::test_the_alias_does_not_outlive_its_removal_major`` -fails the build if this module is still here then. - -The one property that makes this an alias rather than a second copy of the -tree: ``sys.modules`` holds ONE module object under both names. - - >>> import adcp.types.generated_poc.core.format_id as old - >>> import adcp.types.domains.core.format_id as new - >>> old is new - True - >>> old.FormatReferenceStructuredObject is new.FormatReferenceStructuredObject - True - -A split module is the one place that cannot hold: two canonical modules cannot -be one object. The property that MATTERS is the second one, and the shim keeps -it — it binds no class of its own, it reads the two canonical modules. - - >>> from adcp.types.generated_poc.brand import Brand - >>> from adcp.types.domains.brand_discovery import Brand as Canonical - >>> Brand is Canonical - True - -Redirecting a package's ``__path__`` instead would make the import machinery -LOAD each module a second time under the old name, giving two module objects -and two class objects per class — and `isinstance` against the wrong one fails -for no visible reason. ``tests/test_export_surface_is_derived.py`` exists to -refuse exactly that, by asserting every generated class is DEFINED at the -address it is imported from. So the loader here never executes anything: it -imports the canonical module and hands that object back. A split name gets a -shim instead of one of its two halves, and the shim executes nothing either — -it binds no class, it reads the halves. - -The one write the import machinery makes to a module handed back from -``create_module`` is ``__spec__`` (``importlib._bootstrap._init_module_attrs`` -sets ``__name__``, ``__loader__``, ``__package__``, ``__path__`` and -``__file__`` only when they are absent, and they are not). ``exec_module`` -puts the canonical spec back, so the shared object carries no trace of having -been reached through the old name. -""" - -from __future__ import annotations - -import importlib -import importlib.util -import sys -import warnings -from collections.abc import Sequence -from importlib.machinery import ModuleSpec -from types import ModuleType -from typing import TYPE_CHECKING, cast - -if TYPE_CHECKING: - # For the one ``cast`` below, and nothing else. ``importlib.abc`` costs - # ~9ms to import because it pulls ``importlib.resources`` -> - # ``inspect``/``tempfile``/``typing``, and this module sits on - # ``adcp.types``' import path — the same path whose cost ``adcp.types``' - # PEP 562 ``__getattr__`` and ``adcp``'s deferred ``importlib.metadata`` - # exist to keep down. The import system reaches a loader and a finder - # structurally, so neither class needs the ABC at runtime. - from importlib.abc import Loader - -#: The pre-9.0 path, and the path it moved to. Every name under the first is -#: served by the identically-stemmed module under the second, plus — for a -#: root discovery schema — that module's ``_discovery`` sibling. -DEPRECATED_ROOT = "adcp.types.generated_poc" -CANONICAL_ROOT = "adcp.types.domains" - -#: The major version that removes this module. Stated here because a -#: deprecation with no expiry is a permanent second surface. -REMOVED_IN_MAJOR = 10 - -#: The suffix ``scripts/generate_types.py`` gives a root discovery schema's -#: module when its basename collides with a task-schema directory. Derived from -#: there rather than restated as a list of stems, so a schema added to -#: ``ROOT_DISCOVERY_SCHEMAS`` tomorrow is served without touching this file. -ROOT_DISCOVERY_SUFFIX = "_discovery" - - -def canonical_name(deprecated: str) -> str: - """``adcp.types.generated_poc.core.x`` -> ``adcp.types.domains.core.x``.""" - - return CANONICAL_ROOT + deprecated[len(DEPRECATED_ROOT) :] - - -def discovery_half(canonical: str) -> str | None: - """The second canonical module a split deprecated name also has to serve. - - ``adcp.types.domains.brand`` -> ``adcp.types.domains.brand_discovery``, and - ``None`` for every name that did not split. A root discovery schema sits at - the schema root, so only a DOMAIN-level package can be the colliding half; - anything deeper, and any domain with no ``_discovery`` sibling, is an - ordinary prefix rename. - """ - - stem = canonical[len(CANONICAL_ROOT) + 1 :] - if not stem or "." in stem: - return None - candidate = f"{canonical}{ROOT_DISCOVERY_SUFFIX}" - try: - if importlib.util.find_spec(candidate) is None: - return None - except (ImportError, AttributeError, ValueError): - return None - return candidate - - -class _SplitAliasModule(ModuleType): - """One deprecated name over the two canonical halves of a split module. - - Reads both; defines nothing. ``__getattr__`` fires only after normal - instance lookup misses, so the two halves live in the instance dict and the - attributes the import system writes (``__name__``, ``__spec__``, ``__path__`` - and friends) resolve without reaching it. - - ``__path__`` is the aggregator package's, which is what keeps - ``generated_poc.brand.acquire_rights_request`` resolving: ``_find_and_load`` - reads the parent's ``__path__`` before any meta-path finder is consulted, so - a shim that could not answer for it would raise - "'adcp.types.generated_poc.brand' is not a package" for all 20 of that - domain's leaf modules. Bound here rather than left to ``__getattr__``, which - would reach the same object by the same fallback: a package attribute the - import system reads on every submodule import is not a compatibility - lookup, and ``_init_module_attrs`` skips it only because it is already set. - - The discovery half is tried first because it is the surface the pre-9.0 - module HAD — ``generated_poc/brand/__init__.py`` was ``brand.json`` and - nothing else. The two halves share 12 names (``Logo``, ``Colors``, - ``Fonts``, ...), and for those the pre-9.0 import got the discovery class. - """ - - def __init__(self, name: str, discovery: ModuleType, aggregator: ModuleType) -> None: - super().__init__(name) - self.__path__ = aggregator.__path__ - # Set through ``__dict__`` so ``__getattr__`` can read it without the - # risk of recursing through a half-initialized instance. - self.__dict__["_halves"] = (discovery, aggregator) - - def __getattr__(self, attr: str) -> object: - for half in self.__dict__["_halves"]: - try: - return getattr(half, attr) - except AttributeError: - continue - halves = ", ".join(half.__name__ for half in self.__dict__["_halves"]) - raise AttributeError( - f"module {self.__name__!r} has no attribute {attr!r} (served from {halves})" - ) - - -class _AliasLoader: - """Hands back the canonical module object instead of loading anything.""" - - def __init__(self, canonical: str) -> None: - self._canonical = canonical - self._canonical_spec: ModuleSpec | None = None - - def create_module(self, spec: ModuleSpec) -> ModuleType: - module = importlib.import_module(self._canonical) - discovery = discovery_half(self._canonical) - if discovery is not None: - # A split name is the one case that gets its own module object: two - # canonical modules cannot be one. It binds no class, so every class - # reached through it is still the canonical class. - return _SplitAliasModule(spec.name, importlib.import_module(discovery), module) - # Captured before ``_init_module_attrs`` overwrites it with *spec*. - self._canonical_spec = module.__spec__ - return module - - def exec_module(self, module: ModuleType) -> None: - if isinstance(module, _SplitAliasModule): - # The shim IS its own module; it keeps the spec naming itself, so a - # reload re-runs this loader rather than one canonical half. - return - module.__spec__ = self._canonical_spec - - -class _AliasFinder: - """Serves every ``adcp.types.generated_poc`` name from its new address. - - Installed at the front of ``sys.meta_path`` so it is consulted before - ``PathFinder``. That ordering is the whole mechanism for the submodules: a - deprecated package resolves to the canonical package, whose ``__path__`` - points at the real directory, and ``PathFinder`` would happily load a - second copy of every module on it under the deprecated name. - - The warning fires once per deprecated MODULE, tracked here rather than - left to the import machinery's own caching: ``find_spec`` is called again - for a name already in ``sys.modules`` whenever something probes it ( - ``adcp.types.__getattr__`` does, for its submodule passthrough), and a - rename is one fact however many times it is looked up. - """ - - def __init__(self) -> None: - self._warned: set[str] = set() - - def find_spec( - self, - fullname: str, - path: Sequence[str] | None = None, - target: ModuleType | None = None, - ) -> ModuleSpec | None: - if fullname != DEPRECATED_ROOT and not fullname.startswith(DEPRECATED_ROOT + "."): - return None - canonical = canonical_name(fullname) - try: - if importlib.util.find_spec(canonical) is None: - return None - except (ImportError, AttributeError, ValueError): - # No such module under the new root either. Returning None lets the - # ModuleNotFoundError name what the caller actually asked for. - return None - if fullname not in self._warned: - self._warned.add(fullname) - # A split name's advice is not the prefix rename, because the - # classes this module used to hold are under the other half. Saying - # "import adcp.types.domains.brand instead" and nothing more sends - # the reader to a module that does not bind what they imported. - discovery = discovery_half(canonical) - if discovery is None: - advice = ( - f"import {canonical} instead. The module stems are " - f"unchanged, so this is a prefix rename, and " - f"`adcp migrate v3-to-v4 --apply` rewrites it." - ) - else: - advice = ( - f"import {discovery} for the classes this module declared " - f"and {canonical} for the ones its domain aggregates — this " - f"stem split in two, so it is the one case that is not a " - f"prefix rename; `adcp migrate v3-to-v4 --apply` resolves each " - f"imported name to its half. See docs/types-9-migration.md." - ) - warnings.warn( - f"{fullname} is deprecated since adcp 9.0 and is removed in " - f"v{REMOVED_IN_MAJOR}; {advice}", - DeprecationWarning, - stacklevel=2, - ) - # ``ModuleSpec`` declares its loader as ``importlib.abc.Loader``, which - # is a registration ABC: what the import system actually calls is - # ``create_module`` / ``exec_module``, and ``_AliasLoader`` defines - # both. typeshed's structural alternative, ``LoaderProtocol``, declares - # only ``load_module`` — removed from ``Loader`` in 3.12 — so - # implementing it would be the less honest of the two options. - return ModuleSpec(fullname, cast("Loader", _AliasLoader(canonical))) - - -def install() -> None: - """Put the finder at the front of ``sys.meta_path``, at most once.""" - - if not any(isinstance(finder, _AliasFinder) for finder in sys.meta_path): - sys.meta_path.insert(0, _AliasFinder()) diff --git a/tests/test_generated_poc_alias.py b/tests/test_generated_poc_alias.py deleted file mode 100644 index 6a18e7274..000000000 --- a/tests/test_generated_poc_alias.py +++ /dev/null @@ -1,654 +0,0 @@ -"""The pre-9.0 ``adcp.types.generated_poc`` path resolves, as the same objects. - -#1360 measured 110 deep ``generated_poc`` imports in ONE production seller, so -9.0 keeps the old path working and warns rather than breaking all of them at -once. The danger in doing that is the one the review named: redirecting a -package's ``__path__`` makes the import machinery LOAD each module a second -time under the old name, so there are two module objects and two class objects -per class, and an ``isinstance`` against the wrong one fails for no visible -reason. ``tests/test_export_surface_is_derived.py`` exists to refuse that, by -asserting every generated class is DEFINED at the address it is imported from. - -``adcp.types._generated_poc_alias`` is not that. Its loader executes nothing: -it imports the canonical module and hands that object back, so ``sys.modules`` -holds ONE object under both keys. This module grades that claim, and the four -others the alias has to keep: - -1. the leaf class is the SAME object, ``is`` not ``==``; -2. ``sys.modules`` holds one module object under both keys; -3. a ``DeprecationWarning`` naming the new path, once per module; -4. the shared object carries no trace of having been reached through the old - name — which is the property ``test_export_surface_is_derived`` rests on; -5. the alias does not outlive the major that removes it. - -The module list is derived — the root, every entry in -``adcp.types.domains.DOMAINS``, and the first few leaf modules each domain -package actually contains — rather than hand-listed, because a hand-listed -sample stops covering the tree the moment the tree changes. - -**A root discovery schema is the one name for which (2) cannot hold, and #1402 -is what happens when nothing says so.** ``generated_poc.brand`` resolved to -``domains.brand`` and ``from ... import Brand`` raised, because ``brand.json`` -now generates to ``domains/brand_discovery.py`` and ``domains/brand/__init__.py`` -is the domain aggregator. The suite above could not see it: it asserted over the -names the CANONICAL module binds, never over the names the PRE-9.0 module -exported. The split family below asserts the second direction, over every public -name of both halves, and takes its split set from the generator's own -``ROOT_DISCOVERY_SCHEMAS`` so the next split is graded without anyone extending -a list here. -""" - -from __future__ import annotations - -import importlib -import importlib.util -import pkgutil -import re -import subprocess -import sys -import warnings -from pathlib import Path -from types import ModuleType - -import pytest - -import adcp.types # noqa: F401 — importing it is what installs the finder -import adcp.types.domains as domains -from adcp.types._generated_poc_alias import ( - CANONICAL_ROOT, - DEPRECATED_ROOT, - REMOVED_IN_MAJOR, - ROOT_DISCOVERY_SUFFIX, - _AliasFinder, - _AliasLoader, - _SplitAliasModule, - canonical_name, - discovery_half, - install, -) -from scripts.generate_types import ROOT_DISCOVERY_SCHEMAS - -_REPO_ROOT = Path(__file__).resolve().parents[1] - -#: Leaf modules sampled per domain. Two is enough to grade the mechanism — -#: which is per-name, not per-module — without importing 1088 modules. -_LEAVES_PER_DOMAIN = 2 - - -def _canonical_module_names() -> list[str]: - """The canonical modules this suite grades, derived from the tree.""" - - names = [CANONICAL_ROOT] - for domain in domains.DOMAINS: - package_name = f"{CANONICAL_ROOT}.{domain}" - names.append(package_name) - package = importlib.import_module(package_name) - # A domain whose schemas are few enough generates a module, not a - # package, and has no submodules to sample. - search_path = getattr(package, "__path__", None) - if search_path is None: - continue - leaves = sorted(info.name for info in pkgutil.iter_modules(search_path)) - names.extend(f"{package_name}.{leaf}" for leaf in leaves[:_LEAVES_PER_DOMAIN]) - return names - - -CANONICAL_MODULES = _canonical_module_names() -DEPRECATED_MODULES = [DEPRECATED_ROOT + name[len(CANONICAL_ROOT) :] for name in CANONICAL_MODULES] -PAIRS = list(zip(DEPRECATED_MODULES, CANONICAL_MODULES, strict=True)) -PAIR_IDS = [deprecated[len(DEPRECATED_ROOT) + 1 :] or "" for deprecated, _ in PAIRS] - -#: The schemas whose pre-9.0 module and 9.0 module are not the same address, -#: read from the generator's own declaration rather than named here. Each entry -#: is ``(deprecated module, discovery half, aggregator half)``: ``brand.json`` -#: generated into ``generated_poc/brand/__init__.py`` and now generates into -#: ``domains/brand_discovery.py``, leaving ``domains/brand/__init__.py`` to the -#: domain aggregator, so one deprecated name has two canonical halves. -SPLITS = [ - ( - f"{DEPRECATED_ROOT}.{schema.stem}", - f"{CANONICAL_ROOT}.{output.stem}", - f"{CANONICAL_ROOT}.{schema.stem}", - ) - for schema, output in sorted(ROOT_DISCOVERY_SCHEMAS.items()) -] -SPLIT_IDS = [schema.stem for schema in sorted(ROOT_DISCOVERY_SCHEMAS)] -SPLIT_DEPRECATED = frozenset(deprecated for deprecated, _, _ in SPLITS) - -#: The pairs for which one deprecated name has exactly one canonical module. -UNSPLIT_PAIRS = [pair for pair in PAIRS if pair[0] not in SPLIT_DEPRECATED] -UNSPLIT_PAIR_IDS = [ - deprecated[len(DEPRECATED_ROOT) + 1 :] or "" for deprecated, _ in UNSPLIT_PAIRS -] - - -def _import_deprecated(name: str) -> ModuleType: - """Import a deprecated path with the warning suppressed. - - Suppressed because every import here is deliberate; the warning itself is - graded by ``test_the_warning_fires_once_per_module_and_names_the_new_path`` - and, on a real import, by the subprocess test below. - """ - with warnings.catch_warnings(): - warnings.simplefilter("ignore", DeprecationWarning) - return importlib.import_module(name) - - -def test_the_graded_module_list_is_not_empty() -> None: - """A tree reorganization must not empty this suite silently.""" - assert len(domains.DOMAINS) == 26, domains.DOMAINS - assert len(PAIRS) > 60, len(PAIRS) - assert CANONICAL_MODULES[0] == CANONICAL_ROOT - - -# --------------------------------------------------------------------------- -# Properties 1 and 2 — one module object, one class object -# --------------------------------------------------------------------------- - - -@pytest.mark.parametrize(("deprecated", "canonical"), UNSPLIT_PAIRS, ids=UNSPLIT_PAIR_IDS) -def test_sys_modules_holds_one_object_under_both_keys(deprecated: str, canonical: str) -> None: - """The property a ``__path__`` redirect cannot keep. - - Stated over every name that did not split. A split name has two canonical - halves and so cannot be one object with either; what it keeps instead — - the shim defines no class, so every class reached through it is still the - canonical class, which is the property this one exists to protect — is - graded by ``test_a_split_name_defines_nothing_and_serves_canonical_classes``. - """ - old = _import_deprecated(deprecated) - new = importlib.import_module(canonical) - - assert old is new - assert sys.modules[deprecated] is sys.modules[canonical] - assert len({id(sys.modules[deprecated]), id(sys.modules[canonical])}) == 1 - - -@pytest.mark.parametrize(("deprecated", "canonical"), UNSPLIT_PAIRS, ids=UNSPLIT_PAIR_IDS) -def test_every_class_reached_through_the_old_path_is_the_canonical_class( - deprecated: str, canonical: str -) -> None: - """Stated over every public class the module binds, not over a sample. - - ``is`` rather than ``==``: two pydantic models built from one schema compare - unequal as classes but would both satisfy a structural check, and it is - object identity that makes ``isinstance`` work. - - A split name is graded by the split tests instead. Not because the identity - is weaker there — it is not, the shim defines no class — but because the - canonical module this compares against is only HALF of what the pre-9.0 - module bound, and the two halves share 12 names (``Logo``, ``Colors``, - ``Fonts``, ...) that the pre-9.0 import resolved to the discovery class. - """ - old = _import_deprecated(deprecated) - new = importlib.import_module(canonical) - - public = [name for name in dir(new) if not name.startswith("_")] - assert public, f"{canonical} binds nothing public" - for name in public: - assert getattr(old, name) is getattr(new, name), f"{deprecated}.{name}" - - -def test_the_identity_sweep_grades_thousands_of_classes() -> None: - """The floor the per-module test cannot carry. - - A module that binds only aliases still passes the sweep above, and a tree - reorganization could leave every graded module in that state. This counts - the classes the sweep actually compared, so the suite cannot go quiet. - """ - modules = [_import_deprecated(name) for name in DEPRECATED_MODULES] - - classes = sum( - 1 - for module in modules - for name in dir(module) - if not name.startswith("_") and isinstance(getattr(module, name), type) - ) - assert classes > 1000, f"only {classes} classes graded — the sample collapsed" - - -def test_every_spelling_an_adopter_may_have_written_resolves() -> None: - """Each of these reaches the import system by a different route. - - A dotted ``from ... import `` resolves the leaf through - ``_find_and_load``; ``from import `` goes through - ``_handle_fromlist``; and reaching ``adcp.types.generated_poc`` as an - attribute goes through ``adcp.types.__getattr__``'s submodule passthrough, - which probes with ``find_spec`` before importing. The finder has to answer - for all three. - - ``importlib.reload`` of a deprecated name is graded in the subprocess test - below, not here: reloading a generated module rebuilds its classes, so the - domain root and ``_generated`` keep the pre-reload objects and - ``test_export_surface_is_derived`` fails for the rest of the session. It - caught exactly that when the assertion lived here. - """ - with warnings.catch_warnings(): - warnings.simplefilter("ignore", DeprecationWarning) - from adcp.types.generated_poc import core as old_core - from adcp.types.generated_poc.core import format_id as old_format_id - from adcp.types.generated_poc.media_buy.package_request import ( - PackageRequest as OldPackageRequest, - ) - - from adcp.types import generated_poc as old_root - - import adcp.types.domains as new_root - from adcp.types.domains import core as new_core - from adcp.types.domains.core import format_id as new_format_id - from adcp.types.domains.media_buy.package_request import PackageRequest - - assert old_root is new_root - assert old_core is new_core - assert old_format_id is new_format_id - assert OldPackageRequest is PackageRequest - assert adcp.types.generated_poc.core.format_id is new_format_id - - -# --------------------------------------------------------------------------- -# A root discovery schema — the one case a prefix rename gets wrong (#1402) -# --------------------------------------------------------------------------- - - -def test_the_split_set_is_read_from_the_generator_not_named_here() -> None: - """A split this suite does not know about is a hole it cannot see. - - ``ROOT_DISCOVERY_SCHEMAS`` is the generator's own declaration of which - schema's module is not at its own stem, so a schema added there is graded - by the tests below without anyone remembering to extend a list. This - asserts the derivation is wired and non-empty — a renamed or emptied - declaration would otherwise leave every test below vacuously green. - """ - assert SPLITS, "ROOT_DISCOVERY_SCHEMAS is empty — the split tests grade nothing" - assert ( - f"{DEPRECATED_ROOT}.brand", - f"{CANONICAL_ROOT}.brand_discovery", - f"{CANONICAL_ROOT}.brand", - ) in SPLITS - - -@pytest.mark.parametrize(("deprecated", "discovery", "aggregator"), SPLITS, ids=SPLIT_IDS) -def test_a_root_discovery_module_is_named_the_way_the_alias_derives_it( - deprecated: str, discovery: str, aggregator: str -) -> None: - """The alias derives the second half by suffix; the generator must spell it so. - - ``discovery_half`` appends ``ROOT_DISCOVERY_SUFFIX`` to the domain package's - name rather than carrying a second copy of ``ROOT_DISCOVERY_SCHEMAS``. That - is a derivation, not a guess, only while the generator keeps naming these - modules ``_discovery`` — so this is the assertion that a differently - named entry fails the build instead of silently reopening #1402. - """ - assert discovery == aggregator + ROOT_DISCOVERY_SUFFIX - assert discovery_half(aggregator) == discovery - # And the derivation fires for nothing else: a leaf module, and a domain - # with no discovery sibling, are ordinary prefix renames. - assert discovery_half(f"{CANONICAL_ROOT}.core.format_id") is None - assert discovery_half(f"{CANONICAL_ROOT}.media_buy") is None - - -@pytest.mark.parametrize(("deprecated", "discovery", "aggregator"), SPLITS, ids=SPLIT_IDS) -def test_every_name_a_split_pre_9_0_module_exported_resolves_through_the_alias( - deprecated: str, discovery: str, aggregator: str -) -> None: - """#1402: the module resolved and its contents did not. - - ``generated_poc/brand/__init__.py`` WAS ``brand.json`` — 140 classes, among - them ``Brand``, ``BrandDiscovery3`` and ``LocalizedName``. Those classes are - now in ``domains/brand_discovery.py``, and a prefix rename sent the - deprecated name to ``domains/brand``, where they are not. An adopter got - ``ImportError: cannot import name 'Brand' from 'adcp.types.domains.brand'`` - — an error naming a module they never wrote, which is worse than no alias - at all. - - Stated over every public name of the discovery half, not a sample, and over - the aggregator half too: a deprecated name has to serve everything both of - its canonical halves bind. - """ - old = _import_deprecated(deprecated) - discovery_module = importlib.import_module(discovery) - aggregator_module = importlib.import_module(aggregator) - - graded = 0 - for half in (discovery_module, aggregator_module): - public = [name for name in dir(half) if not name.startswith("_")] - assert public, f"{half.__name__} binds nothing public" - for name in public: - expected = getattr(half, name) - if half is aggregator_module and hasattr(discovery_module, name): - # A name both halves bind resolved to the discovery class - # pre-9.0, because the pre-9.0 module was the discovery schema - # and nothing else. That ordering is the fidelity claim. - expected = getattr(discovery_module, name) - assert getattr(old, name) is expected, f"{deprecated}.{name}" - graded += 1 - assert graded > 250, f"only {graded} names graded — the sweep collapsed" - - -@pytest.mark.parametrize(("deprecated", "discovery", "aggregator"), SPLITS, ids=SPLIT_IDS) -def test_a_split_name_defines_nothing_and_serves_canonical_classes( - deprecated: str, discovery: str, aggregator: str -) -> None: - """The property the one-module-object rule was protecting, kept without it. - - Two canonical modules cannot be one object, so a split name gets its own. - What must not follow is a second LOAD of either half: that is what makes two - class objects per class and an ``isinstance`` fail for no visible reason. - The shim executes nothing and binds no class — every class it hands back - reports a canonical ``__module__``. - """ - old = _import_deprecated(deprecated) - - assert isinstance(old, _SplitAliasModule) - assert sys.modules[deprecated] is old - assert old is not importlib.import_module(discovery) - assert old is not importlib.import_module(aggregator) - - discovery_module = importlib.import_module(discovery) - classes = [ - getattr(old, name) - for name in dir(discovery_module) - if not name.startswith("_") and isinstance(getattr(old, name), type) - ] - assert len(classes) > 150, f"only {len(classes)} classes reached — the sweep collapsed" - # Every class the discovery half DEFINES reports that half, never the - # deprecated name. The handful it merely imports (``AdCPBaseModel``, - # ``Annotated``, the pydantic bases) report where they are defined, which is - # the same answer: a second load under the deprecated name is what would - # show up here, and it would show up on all of them. - defined_here = [cls for cls in classes if cls.__module__ == discovery] - assert len(defined_here) > 150, f"only {len(defined_here)} classes defined in {discovery}" - for cls in classes: - assert DEPRECATED_ROOT not in cls.__module__, cls - - -@pytest.mark.parametrize(("deprecated", "discovery", "aggregator"), SPLITS, ids=SPLIT_IDS) -def test_a_split_name_warns_about_both_halves_not_the_prefix_rename( - deprecated: str, discovery: str, aggregator: str -) -> None: - """The advice an adopter reads has to be the advice that works for them. - - "import adcp.types.domains.brand instead" is the prefix rename, and for this - one name it sends the reader to a module that does not bind what they - imported — the same wrong answer #1402 was about, delivered as guidance - rather than as an ``ImportError``. Graded on a fresh finder, because the - installed one has already warned for this name. - """ - finder = _AliasFinder() - - with warnings.catch_warnings(record=True) as caught: - warnings.simplefilter("always") - assert finder.find_spec(deprecated) is not None - - assert len(caught) == 1, [str(entry.message) for entry in caught] - message = str(caught[0].message) - assert discovery in message, message - assert aggregator in message, message - assert "split in two" in message, message - assert "prefix rename" in message and "not a prefix rename" in message, message - assert f"v{REMOVED_IN_MAJOR}" in message, message - - -@pytest.mark.parametrize(("deprecated", "discovery", "aggregator"), SPLITS, ids=SPLIT_IDS) -def test_a_split_deprecated_package_still_resolves_its_leaf_modules( - deprecated: str, discovery: str, aggregator: str -) -> None: - """``_find_and_load`` reads the parent's ``__path__`` before any finder runs. - - So a shim without one answers "is not a package" for every leaf under it — - 20 modules in ``brand``'s case, broken to serve 193 names. It borrows the - aggregator package's own ``__path__``, and the finder ahead of - ``PathFinder`` still serves each leaf as the canonical module object. - """ - old = _import_deprecated(deprecated) - aggregator_module = importlib.import_module(aggregator) - - assert old.__path__ is aggregator_module.__path__ - - leaves = sorted(info.name for info in pkgutil.iter_modules(aggregator_module.__path__)) - assert len(leaves) > 5, leaves - for leaf in leaves: - through_alias = _import_deprecated(f"{deprecated}.{leaf}") - assert through_alias is importlib.import_module(f"{aggregator}.{leaf}") - - -# --------------------------------------------------------------------------- -# Property 4 — the shared object carries no trace of the old name -# --------------------------------------------------------------------------- - - -@pytest.mark.parametrize(("deprecated", "canonical"), UNSPLIT_PAIRS, ids=UNSPLIT_PAIR_IDS) -def test_the_shared_module_carries_no_trace_of_the_deprecated_name( - deprecated: str, canonical: str -) -> None: - """``test_export_surface_is_derived`` reads these, so the alias must not move them. - - The import machinery writes exactly one of them when a loader hands back an - existing module: ``_init_module_attrs`` always replaces ``__spec__``, and - sets the rest only when absent. ``exec_module`` puts the canonical spec - back; this is the assertion that says so. - - A split name is its own module and says so, which is the honest answer when - there are two canonical halves — what - ``test_export_surface_is_derived`` actually reads is each CLASS's - ``__module__``, and the shim creates no class to stamp. - """ - module = _import_deprecated(deprecated) - - assert module.__name__ == canonical - assert module.__spec__ is not None - assert module.__spec__.name == canonical - # ``__loader__`` is the real source loader, never the alias's. If - # ``_init_module_attrs`` ever started overriding it, a reload would re-run - # the alias instead of the file. - assert not isinstance(module.__loader__, _AliasLoader) - assert module.__loader__ is module.__spec__.loader - - traces = { - "__name__": module.__name__, - "__package__": module.__package__ or "", - "__file__": module.__file__ or "", - "__spec__.name": module.__spec__.name, - "__spec__.origin": module.__spec__.origin or "", - } - leaked = {key: value for key, value in traces.items() if DEPRECATED_ROOT in value} - assert leaked == {}, leaked - - -def test_a_class_reached_through_the_old_path_reports_the_canonical_module() -> None: - """``__module__`` is what grades #911, over 4000 classes. - - A second load under the deprecated name would stamp the deprecated path - onto every class it created, and - ``test_export_surface_is_derived::test_no_generated_type_is_reachable_under_zero_names`` - would fail. Here is the same invariant, asserted on a class reached the - deprecated way. - """ - old = _import_deprecated(f"{DEPRECATED_ROOT}.core.format_id") - - assert old.FormatReferenceStructuredObject.__module__ == f"{CANONICAL_ROOT}.core.format_id" - - -# --------------------------------------------------------------------------- -# Property 3 — a DeprecationWarning, once per module -# --------------------------------------------------------------------------- - - -def test_the_warning_fires_once_per_module_and_names_the_new_path() -> None: - """Graded on a fresh finder, because the installed one has already warned. - - ``find_spec`` is called again for a name already in ``sys.modules`` - whenever something probes it — ``adcp.types.__getattr__`` does, for its - submodule passthrough — so the once-per-module rule is the finder's own - bookkeeping rather than a side effect of ``sys.modules``. - """ - finder = _AliasFinder() - name = f"{DEPRECATED_ROOT}.core.format_id" - - with warnings.catch_warnings(record=True) as caught: - warnings.simplefilter("always") - assert finder.find_spec(name) is not None - assert finder.find_spec(name) is not None - assert finder.find_spec(f"{DEPRECATED_ROOT}.core.product") is not None - - messages = [str(entry.message) for entry in caught] - assert all(entry.category is DeprecationWarning for entry in caught) - assert len(messages) == 2, messages - assert sum(name in message for message in messages) == 1, messages - - first = messages[0] - assert canonical_name(name) in first - assert f"v{REMOVED_IN_MAJOR}" in first - - -def test_the_finder_declines_every_name_outside_the_deprecated_root() -> None: - """A finder at the front of ``sys.meta_path`` must not answer for anything else.""" - finder = _AliasFinder() - for name in ( - "adcp", - "adcp.types", - CANONICAL_ROOT, - f"{CANONICAL_ROOT}.core", - "adcp.types.generated_pocket", - "json", - ): - assert finder.find_spec(name) is None, name - - -def test_a_name_that_exists_under_neither_root_is_reported_as_itself() -> None: - """The error names what the caller asked for, not the rewritten target.""" - with pytest.raises(ModuleNotFoundError) as caught: - importlib.import_module(f"{DEPRECATED_ROOT}.core.not_a_schema") - assert f"{DEPRECATED_ROOT}.core.not_a_schema" in str(caught.value) - assert CANONICAL_ROOT not in str(caught.value) - - -def test_installing_twice_leaves_one_finder_ahead_of_the_path_finder() -> None: - """``adcp.types`` installs it; a second call must not stack a second copy.""" - install() - install() - ours = [index for index, f in enumerate(sys.meta_path) if isinstance(f, _AliasFinder)] - assert len(ours) == 1, sys.meta_path - - path_finder = [ - index for index, f in enumerate(sys.meta_path) if getattr(f, "__name__", "") == "PathFinder" - ] - assert path_finder, sys.meta_path - assert ours[0] < path_finder[0], ( - "PathFinder would find the real files on the canonical package's __path__ " - "and load a SECOND copy of every module under the deprecated name" - ) - - -def test_a_real_import_in_a_clean_process_warns_and_keeps_identity() -> None: - """The adopter-facing behaviour, measured where ``sys.modules`` is empty. - - In-process tests cannot show this: whichever test imports the deprecated - path first consumes the warning for the whole session. Two subprocesses — - one that turns the warning into an error, one that ignores it and checks - identity — pin both halves. - """ - deep = f"{DEPRECATED_ROOT}.core.format_id" - - refused = subprocess.run( - [sys.executable, "-W", "error::DeprecationWarning", "-c", f"import {deep}"], - cwd=_REPO_ROOT, - capture_output=True, - text=True, - timeout=180, - ) - assert refused.returncode != 0, refused.stdout - assert "DeprecationWarning" in refused.stderr - assert canonical_name(DEPRECATED_ROOT) in refused.stderr - - accepted = subprocess.run( - [ - sys.executable, - "-W", - "ignore::DeprecationWarning", - "-c", - f"import {deep} as old, {canonical_name(deep)} as new; " - "print(old is new, old.FormatReferenceStructuredObject " - "is new.FormatReferenceStructuredObject, old.__name__)", - ], - cwd=_REPO_ROOT, - capture_output=True, - text=True, - timeout=180, - ) - assert accepted.returncode == 0, accepted.stderr - assert accepted.stdout.strip() == f"True True {canonical_name(deep)}" - - -def test_reload_of_a_deprecated_name_re_runs_the_canonical_module() -> None: - """``reload`` must not re-enter the alias — it reads the restored ``__spec__``. - - In its own process, because reloading a generated module rebuilds its - classes and every other module in the tree keeps the old ones. - """ - deep = f"{DEPRECATED_ROOT}.core.format_id" - result = subprocess.run( - [ - sys.executable, - "-W", - "ignore::DeprecationWarning", - "-c", - f"import importlib, {deep} as m; " - "again = importlib.reload(m); " - "print(again is m, again.__name__, type(again.__loader__).__name__)", - ], - cwd=_REPO_ROOT, - capture_output=True, - text=True, - timeout=180, - ) - assert result.returncode == 0, result.stderr - # ``reload`` returns the same object, re-executed from the canonical file by - # the canonical loader. - assert result.stdout.strip() == f"True {canonical_name(deep)} SourceFileLoader" - - -# --------------------------------------------------------------------------- -# Property 5 — the alias does not outlive its removal major -# --------------------------------------------------------------------------- - - -def _declared_major() -> int: - """The major version ``pyproject.toml`` declares, which release-please bumps.""" - pyproject = (_REPO_ROOT / "pyproject.toml").read_text() - match = re.search(r'^version = "(\d+)\.', pyproject, re.MULTILINE) - assert match, "pyproject.toml declares no [project] version" - return int(match.group(1)) - - -def _installed_major() -> int: - """The major version of the built distribution, which may run ahead of the tree.""" - import adcp - - match = re.match(r"(\d+)\.", adcp.__version__) - assert match, adcp.__version__ - return int(match.group(1)) - - -def test_the_alias_does_not_outlive_its_removal_major() -> None: - """A deprecation with no expiry is a permanent second surface. - - Read from both places a major can appear, because they diverge: a dev tree - carries ``pyproject.toml``'s version while the venv's metadata still holds - whatever was last installed. Either reaching v10 means this module, its - ``install()`` call in ``adcp/types/__init__.py``, and this suite go. - """ - reached = max(_declared_major(), _installed_major()) - assert reached < REMOVED_IN_MAJOR, ( - f"adcp is at v{reached} and {DEPRECATED_ROOT} was to be removed in " - f"v{REMOVED_IN_MAJOR}. Delete src/adcp/types/_generated_poc_alias.py, its " - f"install() call in src/adcp/types/__init__.py, and this test module." - ) - - -def test_the_removal_major_is_stated_where_an_adopter_reads_it() -> None: - """The expiry is in the warning and in the migration guide, not only in code.""" - assert REMOVED_IN_MAJOR == 10 - guide = (_REPO_ROOT / "docs" / "types-9-migration.md").read_text() - assert f"v{REMOVED_IN_MAJOR}" in guide - assert DEPRECATED_ROOT in guide diff --git a/tests/test_migrate_v3_to_v4.py b/tests/test_migrate_v3_to_v4.py index 9ce9c5185..4ca4fc8f5 100644 --- a/tests/test_migrate_v3_to_v4.py +++ b/tests/test_migrate_v3_to_v4.py @@ -1224,23 +1224,19 @@ def test_import_path_rename_is_idempotent(tmp_path: Path) -> None: assert [f for f in report.applied if f.kind == "rename_import"] == [] -def test_renamed_deep_imports_resolve_to_the_same_objects(tmp_path: Path) -> None: - """The rewrite is a rename, not a re-binding: every name the migrated file - imports is the object the deprecated path served.""" - import warnings - - with warnings.catch_warnings(): - warnings.simplefilter("ignore", DeprecationWarning) - old_brand = importlib.import_module("adcp.types.generated_poc.brand") - old_context = importlib.import_module("adcp.types.generated_poc.core.context") +def test_renamed_deep_imports_resolve_and_the_old_path_is_gone(tmp_path: Path) -> None: + """v10 removed the 9.x runtime alias: the old path no longer imports, and + every name the codemod rewrites to is bound at its new address.""" + with pytest.raises(ModuleNotFoundError): + importlib.import_module("adcp.types.generated_poc.core.context") new_discovery = importlib.import_module("adcp.types.domains.brand_discovery") new_brand = importlib.import_module("adcp.types.domains.brand") new_context = importlib.import_module("adcp.types.domains.core.context") - assert old_brand.Brand is new_discovery.Brand - assert old_brand.Asset is new_discovery.Asset # a name both halves bind: discovery wins - assert old_brand.AcquireRightsRequest is new_brand.AcquireRightsRequest - assert old_context.ContextObject is new_context.ContextObject + assert new_discovery.Brand.__module__ == "adcp.types.domains.brand_discovery" + assert hasattr(new_discovery, "Asset") # a name both halves bind: discovery wins + assert hasattr(new_brand, "AcquireRightsRequest") + assert hasattr(new_context, "ContextObject") def test_dry_run_reports_import_path_renames_without_writing(tmp_path: Path) -> None: @@ -1339,22 +1335,10 @@ def test_cli_exits_zero_when_only_import_paths_need_renaming(tmp_path: Path) -> assert v3_to_v4.main([str(tmp_path)]) == 0 -def test_deprecation_warning_names_the_codemod_invocation_that_rewrites(tmp_path: Path) -> None: - """The warning, the docs and the codemod agree (#1417): the advice an - adopter reads names ``adcp migrate v3-to-v4 --apply``, and that invocation - performs the rewrite the advice promises.""" - import subprocess - import sys - - result = subprocess.run( - [sys.executable, "-W", "always", "-c", "import adcp.types.generated_poc.core.error"], - capture_output=True, - text=True, - timeout=300, - ) - assert result.returncode == 0, result.stderr - assert "`adcp migrate v3-to-v4 --apply` rewrites it" in result.stderr - +def test_docs_name_the_codemod_invocation_that_rewrites(tmp_path: Path) -> None: + """The docs and the codemod agree (#1417): the advice an adopter reads + names ``adcp migrate v3-to-v4 --apply``, and that invocation performs the + rewrite the advice promises.""" docs = Path(__file__).resolve().parents[1] / "docs" / "types-9-migration.md" assert "`adcp migrate v3-to-v4 --apply` rewrites those lines" in docs.read_text("utf-8") @@ -1363,15 +1347,12 @@ def test_deprecation_warning_names_the_codemod_invocation_that_rewrites(tmp_path assert (tmp_path / "code.py").read_text() == "from adcp.types.domains.core.error import Error\n" -def test_split_detection_uses_the_same_suffix_the_runtime_alias_serves() -> None: - """The codemod cannot import the private alias module (import layering), so - it restates the one suffix the generator gives a root discovery schema; this - keeps the two spellings from drifting apart.""" - from adcp.types import _generated_poc_alias - - assert v3_to_v4.ROOT_DISCOVERY_SUFFIX == _generated_poc_alias.ROOT_DISCOVERY_SUFFIX - assert v3_to_v4.CANONICAL_TYPES_ROOT == _generated_poc_alias.CANONICAL_ROOT - assert v3_to_v4.DEPRECATED_TYPES_ROOT == _generated_poc_alias.DEPRECATED_ROOT +def test_split_detection_uses_the_generated_discovery_suffix() -> None: + """The codemod restates the one suffix the generator gives a root discovery + schema; the split detection below proves it matches the shipped tree.""" + assert v3_to_v4.ROOT_DISCOVERY_SUFFIX == "_discovery" + assert v3_to_v4.CANONICAL_TYPES_ROOT == "adcp.types.domains" + assert v3_to_v4.DEPRECATED_TYPES_ROOT == "adcp.types.generated_poc" assert v3_to_v4._split_halves("brand") == ( "adcp.types.domains.brand_discovery", "adcp.types.domains.brand",