diff --git a/tests/helpers/import_state.py b/tests/helpers/import_state.py index c27ab7a46..0eea62d9d 100644 --- a/tests/helpers/import_state.py +++ b/tests/helpers/import_state.py @@ -12,6 +12,11 @@ Use ``clear_fake_database_modules`` to evict a *stubbed* ``core.database`` (and its companion ``src.database``) that another test left in import state, without touching a real ``core.database`` loaded from disk. +Use ``clear_fake_endpoint_resolver_modules`` to evict a *stubbed* +``src.endpoint_resolver`` (and the route modules that imported it) that another +test left in import state, without touching a real ``src.endpoint_resolver`` +loaded from disk. + Background: importing ``routes.session_routes`` also sets ``session_routes`` on the parent ``routes`` package object. A ``from routes import session_routes`` or ``import routes.session_routes as X`` statement resolves through that parent @@ -93,6 +98,50 @@ def clear_fake_database_modules(): delattr(parent, "database") +def clear_fake_endpoint_resolver_modules(*extra_modules): + """Evict a *stubbed* ``src.endpoint_resolver`` (and dependent route modules). + + Test-only. Several route tests need the *real* ``src.endpoint_resolver`` URL + helpers, but another test may have installed a fake — a stub module with no + on-disk ``__file__`` — into ``sys.modules`` and onto the ``src`` package + during collection. The route modules (``routes.model_routes`` and any extras + passed in, e.g. ``routes.chat_routes``) get cached against that fake on first + import, so they must be evicted too. + + Conservative, mirroring ``clear_fake_database_modules`` and the per-file + guards it replaces: + + * It acts only when ``src.endpoint_resolver`` is a fake/stub, detected by a + falsy ``__file__`` (missing, ``None``, or empty string) — exactly the + truthiness check the old inline guards used. A real resolver loaded from + disk carries a truthy ``__file__`` and is left untouched, as is the case + where nothing is cached. When the resolver is real, the dependent route + modules are left untouched too. + * When it does act, it drops ``routes.model_routes`` plus every name in + ``extra_modules``. + * It removes the ``src.endpoint_resolver`` parent-package attribute only when + that attribute is the same fake object being evicted. + + Behavior delta vs. the old bare ``sys.modules.pop(...)`` guards: dependent + modules are dropped via :func:`clear_module`, which also clears the parent + ``routes`` package attribute (e.g. ``routes.model_routes``), not just the + ``sys.modules`` entry. This prevents a stale parent attribute from shadowing + the fresh import — the same parent-attr handling the rest of this helper + family already applies. + """ + parent = sys.modules.get("src") + attr = getattr(parent, "endpoint_resolver", None) if parent is not None else None + mod = sys.modules.get("src.endpoint_resolver") or attr + if mod is None or getattr(mod, "__file__", None): + return + sys.modules.pop("src.endpoint_resolver", None) + if parent is not None and attr is mod: + delattr(parent, "endpoint_resolver") + clear_module("routes.model_routes") + for name in extra_modules: + clear_module(name) + + @contextmanager def preserve_import_state(*module_names): """Save and restore sys.modules entries and parent-package attributes. diff --git a/tests/test_chat_image_routing.py b/tests/test_chat_image_routing.py index 92b84769d..21e06f59c 100644 --- a/tests/test_chat_image_routing.py +++ b/tests/test_chat_image_routing.py @@ -1,12 +1,9 @@ import json -import sys from types import SimpleNamespace -_endpoint_resolver = sys.modules.get("src.endpoint_resolver") -if _endpoint_resolver is not None and not getattr(_endpoint_resolver, "__file__", None): - sys.modules.pop("src.endpoint_resolver", None) - sys.modules.pop("routes.model_routes", None) - sys.modules.pop("routes.chat_routes", None) +from tests.helpers.import_state import clear_fake_endpoint_resolver_modules + +clear_fake_endpoint_resolver_modules("routes.chat_routes") from routes import chat_routes diff --git a/tests/test_endpoint_probing.py b/tests/test_endpoint_probing.py index a9e7554fc..0206ebfb7 100644 --- a/tests/test_endpoint_probing.py +++ b/tests/test_endpoint_probing.py @@ -25,12 +25,11 @@ from unittest.mock import MagicMock import httpx import pytest +from tests.helpers.import_state import clear_fake_endpoint_resolver_modules + # Match test_model_routes.py: if another test stubbed src.endpoint_resolver # during collection, drop the stub so the real URL helpers load here. -_endpoint_resolver = sys.modules.get("src.endpoint_resolver") -if _endpoint_resolver is not None and not getattr(_endpoint_resolver, "__file__", None): - sys.modules.pop("src.endpoint_resolver", None) - sys.modules.pop("routes.model_routes", None) +clear_fake_endpoint_resolver_modules() if "core.database" not in sys.modules: _core_db = types.ModuleType("core.database") diff --git a/tests/test_helpers_import_state.py b/tests/test_helpers_import_state.py index 3e7d7a780..fdf406765 100644 --- a/tests/test_helpers_import_state.py +++ b/tests/test_helpers_import_state.py @@ -6,6 +6,7 @@ import pytest from tests.helpers.import_state import ( clear_fake_database_modules, + clear_fake_endpoint_resolver_modules, clear_module, preserve_import_state, ) @@ -16,6 +17,16 @@ _SENTINEL = "tests._import_state_test_sentinel" # tests never leak into the real core/src packages. _DB_NAMES = ("core", "core.database", "src", "src.database") +# Names touched by clear_fake_endpoint_resolver_modules — snapshot/restore these +# so the tests never leak into the real src/routes packages. +_RESOLVER_NAMES = ( + "src", + "src.endpoint_resolver", + "routes", + "routes.model_routes", + "routes.chat_routes", +) + def test_absent_module_is_removed_after_block(): assert _SENTINEL not in sys.modules @@ -250,3 +261,166 @@ def test_clear_fake_database_noop_when_nothing_cached(): clear_fake_database_modules() # must not raise assert "core.database" not in sys.modules + + +def test_clear_fake_resolver_removes_stub_endpoint_resolver(): + with preserve_import_state(*_RESOLVER_NAMES): + fake_src = types.ModuleType("src") + fake_resolver = types.ModuleType("src.endpoint_resolver") # no __file__ => stub + fake_src.endpoint_resolver = fake_resolver + sys.modules["src"] = fake_src + sys.modules["src.endpoint_resolver"] = fake_resolver + + clear_fake_endpoint_resolver_modules() + + assert "src.endpoint_resolver" not in sys.modules + assert not hasattr(fake_src, "endpoint_resolver") + + +def test_clear_fake_resolver_preserves_real_endpoint_resolver(): + with preserve_import_state(*_RESOLVER_NAMES): + fake_src = types.ModuleType("src") + real_resolver = types.ModuleType("src.endpoint_resolver") + real_resolver.__file__ = "/somewhere/src/endpoint_resolver.py" # looks on-disk + fake_src.endpoint_resolver = real_resolver + sys.modules["src"] = fake_src + sys.modules["src.endpoint_resolver"] = real_resolver + + clear_fake_endpoint_resolver_modules() + + assert sys.modules["src.endpoint_resolver"] is real_resolver + assert fake_src.endpoint_resolver is real_resolver + + +def test_clear_fake_resolver_evicts_empty_file_resolver(): + """A resolver with __file__ = "" is a stub under the old truthiness guard, so + it (and its dependents) must be evicted, not preserved.""" + with preserve_import_state(*_RESOLVER_NAMES): + fake_src = types.ModuleType("src") + empty_resolver = types.ModuleType("src.endpoint_resolver") + empty_resolver.__file__ = "" # falsy => stub + fake_src.endpoint_resolver = empty_resolver + sys.modules["src"] = fake_src + sys.modules["src.endpoint_resolver"] = empty_resolver + model_routes = types.ModuleType("routes.model_routes") + sys.modules["routes.model_routes"] = model_routes + + clear_fake_endpoint_resolver_modules() + + assert "src.endpoint_resolver" not in sys.modules + assert not hasattr(fake_src, "endpoint_resolver") + assert "routes.model_routes" not in sys.modules + + +def test_clear_fake_resolver_removes_model_routes_when_resolver_fake(): + """model_routes is dropped, and its parent `routes` attr is cleared too — + the behavior delta over the old bare sys.modules.pop() guards.""" + with preserve_import_state(*_RESOLVER_NAMES): + fake_src = types.ModuleType("src") + fake_resolver = types.ModuleType("src.endpoint_resolver") + fake_src.endpoint_resolver = fake_resolver + sys.modules["src"] = fake_src + sys.modules["src.endpoint_resolver"] = fake_resolver + + fake_routes = types.ModuleType("routes") + model_routes = types.ModuleType("routes.model_routes") + fake_routes.model_routes = model_routes + sys.modules["routes"] = fake_routes + sys.modules["routes.model_routes"] = model_routes + + clear_fake_endpoint_resolver_modules() + + assert "routes.model_routes" not in sys.modules + assert not hasattr(fake_routes, "model_routes") + + +def test_clear_fake_resolver_removes_extra_modules_when_resolver_fake(): + with preserve_import_state(*_RESOLVER_NAMES): + fake_src = types.ModuleType("src") + fake_resolver = types.ModuleType("src.endpoint_resolver") + fake_src.endpoint_resolver = fake_resolver + sys.modules["src"] = fake_src + sys.modules["src.endpoint_resolver"] = fake_resolver + + fake_routes = types.ModuleType("routes") + chat_routes = types.ModuleType("routes.chat_routes") + fake_routes.chat_routes = chat_routes + sys.modules["routes"] = fake_routes + sys.modules["routes.chat_routes"] = chat_routes + + clear_fake_endpoint_resolver_modules("routes.chat_routes") + + assert "routes.chat_routes" not in sys.modules + assert not hasattr(fake_routes, "chat_routes") + + +def test_clear_fake_resolver_keeps_dependents_when_resolver_real(): + with preserve_import_state(*_RESOLVER_NAMES): + fake_src = types.ModuleType("src") + real_resolver = types.ModuleType("src.endpoint_resolver") + real_resolver.__file__ = "/somewhere/src/endpoint_resolver.py" + fake_src.endpoint_resolver = real_resolver + sys.modules["src"] = fake_src + sys.modules["src.endpoint_resolver"] = real_resolver + + model_routes = types.ModuleType("routes.model_routes") + chat_routes = types.ModuleType("routes.chat_routes") + sys.modules["routes.model_routes"] = model_routes + sys.modules["routes.chat_routes"] = chat_routes + + clear_fake_endpoint_resolver_modules("routes.chat_routes") + + assert sys.modules["routes.model_routes"] is model_routes + assert sys.modules["routes.chat_routes"] is chat_routes + + +def test_clear_fake_resolver_noop_when_nothing_cached(): + with preserve_import_state(*_RESOLVER_NAMES): + sys.modules.pop("src.endpoint_resolver", None) + fake_src = types.ModuleType("src") # no endpoint_resolver attr + sys.modules["src"] = fake_src + model_routes = types.ModuleType("routes.model_routes") + sys.modules["routes.model_routes"] = model_routes + + clear_fake_endpoint_resolver_modules() # must not raise + + assert "src.endpoint_resolver" not in sys.modules + # dependents are left alone when the resolver was never cached + assert sys.modules["routes.model_routes"] is model_routes + + +def test_clear_fake_resolver_keeps_parent_attr_pointing_elsewhere(): + """When the cached src.endpoint_resolver is a stub but the `endpoint_resolver` + attr on the src package points at a *different* object, the attr is left + intact — only the same fake object is unlinked.""" + with preserve_import_state(*_RESOLVER_NAMES): + fake_src = types.ModuleType("src") + cached_fake = types.ModuleType("src.endpoint_resolver") # the stub in sys.modules + other = types.ModuleType("src.endpoint_resolver") # parent attr points here + fake_src.endpoint_resolver = other + sys.modules["src"] = fake_src + sys.modules["src.endpoint_resolver"] = cached_fake + + clear_fake_endpoint_resolver_modules() + + assert "src.endpoint_resolver" not in sys.modules + assert fake_src.endpoint_resolver is other + + +def test_clear_fake_resolver_uses_parent_attr_when_not_in_sys_modules(): + """A stub reachable only via the src package's `endpoint_resolver` attribute + (not in sys.modules) is still detected, unlinked, and triggers dependent + eviction.""" + with preserve_import_state(*_RESOLVER_NAMES): + sys.modules.pop("src.endpoint_resolver", None) + fake_src = types.ModuleType("src") + fake_resolver = types.ModuleType("src.endpoint_resolver") + fake_src.endpoint_resolver = fake_resolver + sys.modules["src"] = fake_src + model_routes = types.ModuleType("routes.model_routes") + sys.modules["routes.model_routes"] = model_routes + + clear_fake_endpoint_resolver_modules() + + assert not hasattr(fake_src, "endpoint_resolver") + assert "routes.model_routes" not in sys.modules diff --git a/tests/test_model_routes.py b/tests/test_model_routes.py index ec435ac15..4d68546c6 100644 --- a/tests/test_model_routes.py +++ b/tests/test_model_routes.py @@ -11,12 +11,11 @@ from types import SimpleNamespace import httpx import pytest -_endpoint_resolver = sys.modules.get("src.endpoint_resolver") -if _endpoint_resolver is not None and not getattr(_endpoint_resolver, "__file__", None): - # Other tests stub this module during collection. These helper tests need - # the real URL normalization helpers so Anthropic /v1 handling is covered. - sys.modules.pop("src.endpoint_resolver", None) - sys.modules.pop("routes.model_routes", None) +from tests.helpers.import_state import clear_fake_endpoint_resolver_modules + +# Other tests stub this module during collection. These helper tests need +# the real URL normalization helpers so Anthropic /v1 handling is covered. +clear_fake_endpoint_resolver_modules() if "core.database" not in sys.modules: _core_db = types.ModuleType("core.database")