diff --git a/tests/helpers/import_state.py b/tests/helpers/import_state.py index 35059bfe8..c27ab7a46 100644 --- a/tests/helpers/import_state.py +++ b/tests/helpers/import_state.py @@ -8,6 +8,10 @@ had before the block — present, absent, or carrying a parent-package attribute Use ``clear_module`` to drop a single module from both ``sys.modules`` and its parent-package attribute (e.g. before forcing a fresh import inside the block). +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. + 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 @@ -60,6 +64,35 @@ def clear_module(dotted_name): _restore_one(dotted_name, _ABSENT, _ABSENT) +def clear_fake_database_modules(): + """Evict a *stubbed* ``core.database`` (and ``src.database``) from import state. + + Test-only. Some tests install a fake ``core.database`` — a stub module with + no on-disk ``__file__`` — into ``sys.modules`` and onto the ``core`` package. + A later test that needs the real database module must evict that stub first, + or its ``import core.database`` resolves to the fake. + + This is deliberately conservative and mirrors the per-file helpers it + replaces: + + * It acts only when ``core.database`` is a fake/stub, detected by a missing + string ``__file__``. A real ``core.database`` loaded from disk is left + untouched, as is the case where nothing is cached. + * When it does act, it also drops the cached ``src.database`` entry. + * It removes the ``core.database`` parent-package attribute only when that + attribute is the same fake object being evicted. + """ + parent = sys.modules.get("core") + attr = getattr(parent, "database", None) if parent is not None else None + mod = sys.modules.get("core.database") or attr + if mod is None or isinstance(getattr(mod, "__file__", None), str): + return + sys.modules.pop("core.database", None) + sys.modules.pop("src.database", None) + if parent is not None and attr is mod: + delattr(parent, "database") + + @contextmanager def preserve_import_state(*module_names): """Save and restore sys.modules entries and parent-package attributes. diff --git a/tests/test_calendar_rrule.py b/tests/test_calendar_rrule.py index c49f14215..18d6eaadd 100644 --- a/tests/test_calendar_rrule.py +++ b/tests/test_calendar_rrule.py @@ -15,20 +15,9 @@ from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from sqlalchemy.pool import NullPool +from tests.helpers.import_state import clear_fake_database_modules -def _drop_fake_core_database(): - parent = sys.modules.get("core") - attr = getattr(parent, "database", None) if parent is not None else None - mod = sys.modules.get("core.database") or attr - if mod is None or isinstance(getattr(mod, "__file__", None), str): - return - sys.modules.pop("core.database", None) - sys.modules.pop("src.database", None) - if parent is not None and attr is mod: - delattr(parent, "database") - - -_drop_fake_core_database() +clear_fake_database_modules() import core.database as cdb from core.database import CalendarEvent diff --git a/tests/test_document_close_clears_active_route.py b/tests/test_document_close_clears_active_route.py index 5428d4f2c..dbd84e589 100644 --- a/tests/test_document_close_clears_active_route.py +++ b/tests/test_document_close_clears_active_route.py @@ -13,7 +13,6 @@ while completing reliably everywhere. """ import tempfile -import sys import uuid from types import SimpleNamespace @@ -22,20 +21,9 @@ from sqlalchemy.orm import sessionmaker from sqlalchemy.pool import NullPool from unittest.mock import MagicMock +from tests.helpers.import_state import clear_fake_database_modules -def _drop_fake_core_database(): - parent = sys.modules.get("core") - attr = getattr(parent, "database", None) if parent is not None else None - mod = sys.modules.get("core.database") or attr - if mod is None or isinstance(getattr(mod, "__file__", None), str): - return - sys.modules.pop("core.database", None) - sys.modules.pop("src.database", None) - if parent is not None and attr is mod: - delattr(parent, "database") - - -_drop_fake_core_database() +clear_fake_database_modules() import core.database as cdb import routes.document_routes as droutes diff --git a/tests/test_helpers_import_state.py b/tests/test_helpers_import_state.py index d9f92548d..3e7d7a780 100644 --- a/tests/test_helpers_import_state.py +++ b/tests/test_helpers_import_state.py @@ -4,10 +4,18 @@ import types import pytest -from tests.helpers.import_state import clear_module, preserve_import_state +from tests.helpers.import_state import ( + clear_fake_database_modules, + clear_module, + preserve_import_state, +) _SENTINEL = "tests._import_state_test_sentinel" +# Names touched by clear_fake_database_modules — snapshot/restore these so the +# tests never leak into the real core/src packages. +_DB_NAMES = ("core", "core.database", "src", "src.database") + def test_absent_module_is_removed_after_block(): assert _SENTINEL not in sys.modules @@ -139,3 +147,106 @@ def test_parent_attr_restored_correctly_when_parent_also_preserved(): finally: sys.modules.pop("_fake_istate_parent", None) sys.modules.pop("_fake_istate_parent.child", None) + + +def test_clear_fake_database_removes_stub_core_database(): + with preserve_import_state(*_DB_NAMES): + fake_core = types.ModuleType("core") + fake_db = types.ModuleType("core.database") # no __file__ => a stub + fake_core.database = fake_db + sys.modules["core"] = fake_core + sys.modules["core.database"] = fake_db + + clear_fake_database_modules() + + assert "core.database" not in sys.modules + assert not hasattr(fake_core, "database") + + +def test_clear_fake_database_preserves_real_core_database(): + with preserve_import_state(*_DB_NAMES): + fake_core = types.ModuleType("core") + real_db = types.ModuleType("core.database") + real_db.__file__ = "/somewhere/core/database.py" # looks on-disk + fake_core.database = real_db + sys.modules["core"] = fake_core + sys.modules["core.database"] = real_db + + clear_fake_database_modules() + + assert sys.modules["core.database"] is real_db + assert fake_core.database is real_db + + +def test_clear_fake_database_drops_src_database_when_core_is_fake(): + with preserve_import_state(*_DB_NAMES): + fake_core = types.ModuleType("core") + fake_db = types.ModuleType("core.database") + fake_core.database = fake_db + sys.modules["core"] = fake_core + sys.modules["core.database"] = fake_db + sys.modules["src.database"] = types.ModuleType("src.database") + + clear_fake_database_modules() + + assert "src.database" not in sys.modules + + +def test_clear_fake_database_leaves_src_database_when_core_is_real(): + with preserve_import_state(*_DB_NAMES): + fake_core = types.ModuleType("core") + real_db = types.ModuleType("core.database") + real_db.__file__ = "/somewhere/core/database.py" + fake_core.database = real_db + sys.modules["core"] = fake_core + sys.modules["core.database"] = real_db + src_db = types.ModuleType("src.database") + sys.modules["src.database"] = src_db + + clear_fake_database_modules() + + assert sys.modules["src.database"] is src_db + + +def test_clear_fake_database_keeps_parent_attr_pointing_elsewhere(): + """When the cached core.database is a stub but the `database` attr on the + core package points at a *different* object, the attr is left intact — + only the same fake object is unlinked.""" + with preserve_import_state(*_DB_NAMES): + fake_core = types.ModuleType("core") + cached_fake = types.ModuleType("core.database") # the stub in sys.modules + other = types.ModuleType("core.database") # parent attr points here + fake_core.database = other + sys.modules["core"] = fake_core + sys.modules["core.database"] = cached_fake + + clear_fake_database_modules() + + assert "core.database" not in sys.modules + assert fake_core.database is other + + +def test_clear_fake_database_uses_parent_attr_when_not_in_sys_modules(): + """A stub reachable only via the core package's `database` attribute (not in + sys.modules) is still detected and unlinked from the parent.""" + with preserve_import_state(*_DB_NAMES): + sys.modules.pop("core.database", None) + fake_core = types.ModuleType("core") + fake_db = types.ModuleType("core.database") + fake_core.database = fake_db + sys.modules["core"] = fake_core + + clear_fake_database_modules() + + assert not hasattr(fake_core, "database") + + +def test_clear_fake_database_noop_when_nothing_cached(): + with preserve_import_state(*_DB_NAMES): + sys.modules.pop("core.database", None) + fake_core = types.ModuleType("core") # no `database` attr + sys.modules["core"] = fake_core + + clear_fake_database_modules() # must not raise + + assert "core.database" not in sys.modules diff --git a/tests/test_sqlite_foreign_keys.py b/tests/test_sqlite_foreign_keys.py index dcf564268..0983009b3 100644 --- a/tests/test_sqlite_foreign_keys.py +++ b/tests/test_sqlite_foreign_keys.py @@ -1,22 +1,10 @@ import pytest -import sys from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker +from tests.helpers.import_state import clear_fake_database_modules -def _drop_fake_core_database(): - parent = sys.modules.get("core") - attr = getattr(parent, "database", None) if parent is not None else None - mod = sys.modules.get("core.database") or attr - if mod is None or isinstance(getattr(mod, "__file__", None), str): - return - sys.modules.pop("core.database", None) - sys.modules.pop("src.database", None) - if parent is not None and attr is mod: - delattr(parent, "database") - - -_drop_fake_core_database() +clear_fake_database_modules() from core.database import Base, Session, ChatMessage from datetime import datetime diff --git a/tests/test_task_scheduler_session_delivery.py b/tests/test_task_scheduler_session_delivery.py index 4f35cb31f..a08f6704a 100644 --- a/tests/test_task_scheduler_session_delivery.py +++ b/tests/test_task_scheduler_session_delivery.py @@ -12,20 +12,9 @@ if not isinstance(sqlalchemy, _types.ModuleType): from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker +from tests.helpers.import_state import clear_fake_database_modules -def _drop_fake_core_database(): - parent = sys.modules.get("core") - attr = getattr(parent, "database", None) if parent is not None else None - mod = sys.modules.get("core.database") or attr - if mod is None or isinstance(getattr(mod, "__file__", None), str): - return - sys.modules.pop("core.database", None) - sys.modules.pop("src.database", None) - if parent is not None and attr is mod: - delattr(parent, "database") - - -_drop_fake_core_database() +clear_fake_database_modules() import core.database as cdb from core.database import Base, Session as DbSession diff --git a/tests/test_topic_analyzer.py b/tests/test_topic_analyzer.py index c47d14e1f..f9cca19ea 100644 --- a/tests/test_topic_analyzer.py +++ b/tests/test_topic_analyzer.py @@ -1,24 +1,12 @@ """Tests for topic keyword matching (src/topic_analyzer.py).""" -import sys from types import SimpleNamespace import pytest from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker +from tests.helpers.import_state import clear_fake_database_modules -def _drop_fake_core_database(): - parent = sys.modules.get("core") - attr = getattr(parent, "database", None) if parent is not None else None - mod = sys.modules.get("core.database") or attr - if mod is None or isinstance(getattr(mod, "__file__", None), str): - return - sys.modules.pop("core.database", None) - sys.modules.pop("src.database", None) - if parent is not None and attr is mod: - delattr(parent, "database") - - -_drop_fake_core_database() +clear_fake_database_modules() from core.database import Base, Session as DbSession, ChatMessage as DbChatMessage from core.session_manager import SessionManager