from __future__ import annotations import logging import os import shutil import tempfile from pathlib import Path from typing import Generator # Disable pytest-xdist for the whole test session. # # xdist has a known incompatibility with the sys.modules mocking pattern # used in tests/test_new_providers.py (60+ tests that patch torch and # transformers to None to simulate missing deps). xdist's module # isolation loads C-extension modules before the patch runs, so the # `import torch` inside the provider's _init() succeeds when it should # fail, and all "test_not_available_when_deps_missing" tests fail. # # The fix is to disable xdist: `-p no:xdist` in the test command. # This is added programmatically below via a pytest plugin. # Set environment variables BEFORE any shopstack imports. # ``shopstack.config`` instantiates ``settings = Settings()`` at module # import time (line 112), so the env vars must be set first or the # module-level ``settings.db_path`` is locked to ``data/shopstack.db``. # # Per motto_v3 §6, all backends are pinned to "mock" via env vars so the # module-level Settings singleton (used by session-scoped _app_session # fixture) also gets mock backends. LOCAL_AUTO_DOWNLOAD=False prevents # test-time model downloads as defense-in-depth. # # DB PATH CHOICE (2026-06-14 fix, motto_v3 §0.6 reliability): # Previously defaulted to ``:memory:``, which is per-connection in SQLite. # That works for in-process tests (all connections share the main-thread # connection via threading.local), but it breaks for any test that # spawns Gradio worker threads — each worker gets a fresh empty # in-memory DB and fails with "no such table: app_config". The fix is a # session-scoped temp FILE so all threads see the same schema. The file # is removed at session exit via the atexit hook below. import atexit _SESSION_DB_FD, _SESSION_DB_PATH = tempfile.mkstemp( suffix=".db", prefix="shopstack_test_session_" ) os.close(_SESSION_DB_FD) @atexit.register def _cleanup_session_db() -> None: """Remove the session DB + WAL/SHM sidecars at process exit.""" base = Path(_SESSION_DB_PATH) for suffix in ("", "-wal", "-shm"): base.with_suffix(base.suffix + suffix).unlink(missing_ok=True) os.environ.setdefault("SHOPSTACK_DB_PATH", _SESSION_DB_PATH) os.environ.setdefault("SHOPSTACK_LOCAL_AUTO_DOWNLOAD", "false") os.environ.setdefault("SHOPSTACK_OFF_THE_GRID", "true") os.environ.setdefault("SHOPSTACK_PLANNER_BACKEND", "mock") os.environ.setdefault("SHOPSTACK_STT_BACKEND", "mock") os.environ.setdefault("SHOPSTACK_TTS_BACKEND", "mock") os.environ.setdefault("SHOPSTACK_VISION_BACKEND", "mock") os.environ.setdefault("SHOPSTACK_OBJECT_DETECTION_BACKEND", "mock") os.environ.setdefault("SHOPSTACK_GROUNDING_BACKEND", "mock") os.environ.setdefault("SHOPSTACK_SEGMENTATION_BACKEND", "mock") os.environ.setdefault("SHOPSTACK_OCR_BACKEND", "mock") os.environ.setdefault("SHOPSTACK_TOOL_CALL_PARSER_BACKEND", "mock") os.environ.setdefault("SHOPSTACK_EMBEDDINGS_BACKEND", "mock") os.environ.setdefault("SHOPSTACK_IMAGE_EDIT_BACKEND", "mock") os.environ.setdefault("SHOPSTACK_IMAGE_GEN_BACKEND", "mock") import pytest from unittest.mock import patch from shopstack.config import Settings from shopstack.persistence.database import Database from shopstack.planner.engine import PlannerEngine from shopstack.providers.registry import ProviderRegistry from shopstack.tools.registry import ToolRegistry # ── Stale __pycache__ reset ─────────────────────────────────────────── # Drift across passes can leave ``__pycache__/`` directories out of sync # with the source files (e.g. when a module is extracted in the same pass # that edited it, or when ``app.py`` is rewritten and other modules # import from it). pytest's mtime check is reliable *within* a session # but a stale cache from a prior session can leak into the first # import of this session, producing transient ImportError failures # that vanish on the second run. # # Clearing the project ``__pycache__/`` at conftest load time (before # any fixture or test imports anything) guarantees a fresh compile # on the first import. We skip ``.venv/`` (provider wheels) and # ``node_modules/`` (we don't have any, but defensive). # # This is a one-time cost: ``find . -name __pycache__`` over the # project takes ~50ms; the recompile cost is amortized because the # existing fixtures re-import anyway. _PROJECT_ROOT = Path(__file__).resolve().parent.parent for _cache_dir in _PROJECT_ROOT.rglob("__pycache__"): if ".venv" in _cache_dir.parts or "node_modules" in _cache_dir.parts: continue shutil.rmtree(_cache_dir, ignore_errors=True) del _cache_dir del _PROJECT_ROOT # Tables to clear between tests that share the session-scoped app module. # Includes every household-scoped data table so preference signals, market # snapshots, reconciliation events, etc. cannot leak across tests. The # ``households``, ``household_members``, and ``app_config`` tables are # intentionally preserved (the default household and the active_household_id # key are reset explicitly in the ``app`` fixture below). _APP_DATA_TABLES = [ "inventory_lots", "shopping_list_items", "shopping_lists", "movement_events", "price_observations", "purchase_events", "traces", "household_locations", "stores", "market_snapshots", "market_records", "market_record_components", "reconciliation_events", "preference_signals", "inventory_events", ] @pytest.fixture(autouse=True) def _patch_community_paths(tmp_path, monkeypatch): """Redirect community price-map storage to a temp dir for every test. The module ``shopstack.services.community_price_map`` hardcodes its storage paths to ``Path.home() / ".shopstack" / "community"``. Without this fixture, tests that touch community functions (e.g. ``export_bundle``, ``submit_observation``) would read/write the real user data directory at ``~/.shopstack/community/``, polluting it across test runs and leaking test artifacts outside the project. Per motto_v3 §0.6 reliability, test isolation must not depend on filesystem state. This fixture replaces the module-level path constants with temp paths so every test gets a clean community directory and the real user data is never touched. Also cleans up any real-dir artifacts that may have been left by earlier sessions (pre-fixture runs) so the user's data directory stays pristine. """ from shopstack.services import community_price_map as cpm monkeypatch.setattr(cpm, "_COMMUNITY_DIR", tmp_path / "community") monkeypatch.setattr(cpm, "_POOL_FILE", tmp_path / "community" / "prices.jsonl") monkeypatch.setattr(cpm, "_SALT_FILE", tmp_path / "community" / "salt") monkeypatch.setattr(cpm, "_OPTED_IN_FILE", tmp_path / "community" / "opted_in.json") yield # Post-test cleanup: remove any real-dir artifacts that may have # leaked (e.g. from a test that ran before this fixture was added, # or from a code path that bypasses the module-level monkeypatch). real_dir = Path.home() / ".shopstack" / "community" if real_dir.exists(): for name in ("opted_in.json", "salt", "prices.jsonl"): (real_dir / name).unlink(missing_ok=True) @pytest.fixture(autouse=True) def _clear_dashboard_cache(): """Clear the dashboard state cache before every test. ``build_dashboard_state`` caches results for 60 s per user_id. Tests that call it with different inventory states would see stale data without this reset. """ from shopstack.services.dashboard import clear_dashboard_cache clear_dashboard_cache() yield clear_dashboard_cache() @pytest.fixture(autouse=True) def _patch_decode_barcode(): """Patch ``decode_barcode`` to return ``[]`` by default in all tests. This prevents real file I/O in mock-only tests (e.g. the barcode scanner trying to open ``fake-market-image.jpg``). Tests that need actual barcode behaviour can unpatch by calling ``monkeypatch.undo()`` or applying their own patch inside the test body. """ with patch("shopstack.scanner.decode_barcode", return_value=[]): yield @pytest.fixture(scope="session") def _app_session(): """Import app module once per session with an in-memory database. Importing ``app`` triggers ``shopstack.app_context`` which bootstraps the ``ProviderRegistry`` — an expensive operation (~10s per invocation). Caching at session scope avoids that cost on every test. """ import app as _app return _app @pytest.fixture() def app(_app_session): """Return the session-scoped app module, clearing all data tables between tests. Resets ``active_household_id`` to the default household ("default_household") so household-scoped screens find a valid context. Setting it to "" would break every screen that filters by household. """ app_mod = _app_session conn = app_mod.db.conn conn.execute("PRAGMA foreign_keys = OFF") for table in _APP_DATA_TABLES: conn.execute(f"DELETE FROM {table}") conn.execute("PRAGMA foreign_keys = ON") conn.commit() app_mod.db._seed_locations() app_mod.db._seed_default_household() app_mod.db.active_household_id = "default_household" return app_mod @pytest.fixture() def db_path() -> Generator[str, None, None]: path = "" try: with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f: path = f.name yield path finally: _remove_db_with_sidecars(path) @pytest.fixture(scope="module") def live_app_db_path() -> Generator[str, None, None]: """Canonical module-scoped temp DB path for live-app browser tests. The canonical pattern for any test that needs a real Gradio app running against a unique DB file. Replaces ad-hoc ``tempfile.mkstemp(suffix=".db", ...)`` calls in test files (test_browser_hydration, test_pwa_runtime, test_hydration_recovery). Cleans up the DB + WAL/SHM sidecars at module teardown via the ``_remove_db_with_sidecars`` helper. Per motto_v3 §7, this is the SUPERSESSION: all module-scoped temp DBs in tests must go through this fixture. """ db_fd, db_path = tempfile.mkstemp(suffix=".db", prefix="shopstack_live_app_test_") os.close(db_fd) try: yield db_path finally: _remove_db_with_sidecars(db_path) def _remove_db_with_sidecars(db_path: str) -> None: """Remove a DB file plus its WAL/SHM sidecars. Canonical helper for test DB cleanup. Used by both ``db_path`` (function-scoped) and ``live_app_db_path`` (module-scoped) fixtures, and by ``teardown_module`` functions that previously did ad-hoc ``Path(...).unlink(missing_ok=True)``. Per motto_v3 §7, do not inline ``os.unlink(db_path)`` in test teardown — always use this helper so WAL/SHM are removed. """ if not db_path: return base = Path(db_path) for suffix in ("", "-wal", "-shm"): base.with_suffix(base.suffix + suffix).unlink(missing_ok=True) @pytest.fixture() def settings(db_path: str) -> Settings: # The project Settings API uses `db_path`, not the older `database_path` name. # Ignore the repo .env file during tests so defaults are deterministic. # # Per motto_v3 §6: tests must be deterministic. When real backend deps # are installed (funasr, torch, sentence-transformers, etc.) the # ProviderRegistry loads real providers that fail at model-weight-download # time (local_auto_download=False), producing empty/error results that # make tests non-deterministic. Pinning all backends to "mock" ensures # every unit test exercises the mock contract with known outputs. # Real-provider validation happens via Modal Tier 4 benches, not unit tests. return Settings( _env_file=None, db_path=db_path, off_the_grid=True, local_auto_download=False, planner_backend="mock", stt_backend="mock", tts_backend="mock", vision_backend="mock", object_detection_backend="mock", grounding_backend="mock", segmentation_backend="mock", ocr_backend="mock", tool_call_parser_backend="mock", embeddings_backend="mock", image_edit_backend="mock", image_gen_backend="mock", ) @pytest.fixture() def db(settings: Settings) -> Database: return Database(settings.db_path) @pytest.fixture() def providers(settings: Settings) -> ProviderRegistry: return ProviderRegistry(settings) @pytest.fixture() def tool_registry(db: Database) -> ToolRegistry: return ToolRegistry(db) @pytest.fixture() def planner(db: Database, tool_registry: ToolRegistry, providers: ProviderRegistry) -> PlannerEngine: return PlannerEngine(db, tool_registry, providers) # ── Pytest hooks ────────────────────────────────────────────────────── # The "cache issue" that recurred across multiple passes turned out to be # drift-introduced import-time errors hidden by stale ``__pycache__`` # bytecode. The module-level cache clear at the top of this conftest # surfaces those errors on the first test run of a fresh checkout. # The two hooks below are defense-in-depth + future diagnostics. def pytest_configure(config): """Re-clear ``__pycache__`` AFTER pytest's collection phase and disable pytest-xdist. The module-level clear at the top of this file runs before any tests are collected. This hook re-clears after collection but before test execution — catches any bytecode that pytest's collection phase may have generated by importing test files early (e.g., for parametrize IDs). xdist is also blocked here because of a known incompatibility with the sys.modules mocking pattern in tests/test_new_providers.py (60+ tests that patch torch/transformers to None to simulate missing deps). xdist's module isolation loads C-extension modules before the patch runs, so the `import torch` inside the provider's _init() succeeds when it should fail. See the module-level comment at the top of this file for the full rationale. """ project_root = Path(__file__).resolve().parent.parent cleared = 0 for cache_dir in project_root.rglob("__pycache__"): if ".venv" in cache_dir.parts or "node_modules" in cache_dir.parts: continue shutil.rmtree(cache_dir, ignore_errors=True) cleared += 1 if cleared: logging.getLogger(__name__).debug( "pytest_configure: cleared %d __pycache__ directories", cleared ) # Disable xdist for this session try: import xdist # noqa: F401 config.pluginmanager.set_blocked("xdist") logging.getLogger(__name__).debug( "pytest_configure: blocked pytest-xdist plugin (incompatible with sys.modules mocking in test_new_providers.py)" ) except ImportError: pass # Disable the cacheprovider plugin. Discovered 2026-06-15: when the # full sample runs test_regression_pass13.py BEFORE test_screens.py, # the cache provider's stored state causes RecursionError in # TestRenderListSummary, TestMarketLensSaveTrace, etc. The # ``-p no:cacheprovider`` flag is the documented workaround but is # easy to forget at the CLI. Blocking the plugin here is the # canonical fix per the project's existing pattern of disabling # xdist in this same hook. try: config.pluginmanager.set_blocked("cacheprovider") logging.getLogger(__name__).debug( "pytest_configure: blocked pytest-cacheprovider plugin " "(avoids RecursionError in test_screens.py when run after " "test_regression_pass13.py)" ) except Exception: pass # Register custom pytest marks used in this repo. Avoids # "PytestUnknownMarkWarning" for marks used in test files. config.addinivalue_line( "markers", "slow: slow tests that should be skipped in fast-feedback runs " "(e.g. tests that build the full app or download models). " "Run with `pytest -m slow` to execute them explicitly.", ) config.addinivalue_line( "markers", "standalone: tests that mutate the global db singleton or launch a " "Gradio server. Must run in their own pytest process (not in parallel " "with other tests that touch the same singletons). Per the 2026-06-14 " "test isolation hardening, run these via `pytest -m standalone` in a " "separate invocation, or skip them in bulk test runs.", ) def pytest_unconfigure(config): """Diagnostic: log session-level state on shutdown. If the "cache issue" ever recurs, this is the place to add introspection (e.g., ``sys.modules`` contents, ``app_context`` global state, dashboard cache size, etc.). For now it just logs a marker so we can correlate the cache-clear event with test pass/fail events. """ logging.getLogger(__name__).debug( "pytest_unconfigure: session ended; cache state preserved for next invocation" )