"""Shared application state — the seam that lets route modules reach core singletons without importing ``server``. ``server.py`` is the host: it owns the FastAPI ``app``, constructs the DB singletons, and runs the lifecycle. As routes move out into ``routers/`` (R3), those modules need ``meta_db`` and friends — but they must not ``import server``, or the import graph goes circular the moment ``server`` imports them back. So ``server`` **injects** its singletons here once, at the point it builds them:: # server.py meta_db = MetadataDB(CONFIG_DIR) appstate.configure(meta_db=meta_db, ...) and a router reads them back as **module attributes, at call time**:: # routers/artists.py import appstate @router.get("/api/artist/{name}/page") def artist_page(name): return appstate.meta_db.artist_page(name) This is the Python analogue of the injected `configureX({...})` seams the frontend refactor uses (stems' ``configureStreaming``, studio's ``configureAudioGraph``, the editor's ``src/host.js``), and of the plugin ``setup(app, context)`` contract in Principle III: dependencies flow one way, ``server -> routers -> appstate``, and nothing imports back up. Two properties this shape buys, both load-bearing: * **``import appstate`` performs no IO and constructs nothing.** ``server`` still owns construction, so the ~49 test fixtures that do ``sys.modules.pop("server")`` + re-import (to rebuild ``meta_db`` under a patched ``CONFIG_DIR``) keep working untouched — a singleton *owned* here would survive that pop and go stale. * **Reads are late-bound.** Routers must use ``appstate.meta_db``, never ``from appstate import meta_db`` — a ``from`` import freezes the binding at its current value, so a later ``configure()`` (or a ``monkeypatch.setattr(appstate, "meta_db", fake)``) would not reach the router. This is the same read-only-binding trap as ES ``import``. Defaults are ``None`` on purpose: they are inert but *type-honest*, so a router that runs before ``configure()`` fails loudly on ``NoneType`` instead of quietly operating on a stand-in. Slots are added here only when a router actually needs one — this is a seam, not a grab-bag for everything in ``server.py``. **Why this lives in ``lib/`` and not the repo root.** Because it constructs nothing and does no import-time IO, it satisfies Principle V's rule for ``lib/`` modules — and ``lib/`` is the only core directory every packaging path already copies: the Dockerfile (``COPY lib/``), ``docker-compose.yml``, and feedback-desktop's ``bundle-slopsmith.sh`` (``cp -r lib``). All three also put both the bundle root and ``lib/`` on ``sys.path``. A root-level module ships in Docker but is silently dropped from the packaged desktop app, whose bundler copies a hardcoded file list — that regression is what moved this file here. """ # The singletons routers may read. Every name here must also be a `_SLOTS` key. meta_db = None audio_effect_mappings = None # The tuning-provider registry instance (built-ins + plugin-contributed). A # stable object mutated in place via register()/unregister() — injected here by # reference so routers read the same registry plugins populate. tuning_providers = None # The library-provider registry instance + the local provider, constructed in # server.py (LocalLibraryProvider needs meta_db) and injected by reference. The # classes live in lib/library_registry.py; plugins register their own providers # through the registry via plugin_context. library_providers = None local_library_provider = None # Config paths. server.py derives these from the environment (fresh on every # import, so the ~49 pop-and-reimport fixtures keep working) and injects them # here. Routers read them as `appstate.config_dir` etc. — a module attribute at # call time. NOTE: config_dir/dlc_dir are env-derived, so a `setenv`+reimport # test reconfigures them for free; STATIC_DIR/SLOPPAK_CACHE_DIR are patched via # `setattr(server, …)` in a few tests, so those slots (when added) need their # tests retargeted to appstate in the same PR. config_dir = None dlc_dir = None # the DLC_DIR env value as a Path (Path("") if unset) dlc_dir_env = None # the raw DLC_DIR env string, "" if unset — distinguishes # "unset" from Path("")→"." (see dlc_paths._get_dlc_dir) # Cache/asset dirs. static_dir + sloppak_cache_dir are patched via # `setattr(server, …)` in a few tests, so a router reading them here needs those # setattr sites retargeted to `setattr(appstate, …)` in the same PR (ws_highway # retargets the 3 test_highway_ws_* SLOPPAK sites). config_dir-derived dirs are # reconfigured for free on a setenv+reimport. static_dir = None sloppak_cache_dir = None audio_cache_dir = None # Injected callables (not values): server owns the impl + its state, routers call # through the seam. get_progression_content wraps a lazy content cache that stays # in server.py (its `setattr(server, "_progression_content")` test is untouched). get_progression_content = None builtin_diagnostic_filename = None running_version = None # Art helpers that stay in server.py (shared with the art/delete routes) but are # also called by the enrichment worker in lib/enrichment.py — injected as # callables to keep enrichment acyclic. art_cache_dir is server's ART_CACHE_DIR. art_cache_dir = None song_pack_art_exists = None art_override_paths = None art_safe_name = None # The canonical settings-defaults builder — stays in server.py (shared with the # scan/artist-links code) but the settings router calls it through the seam. default_settings = None # Scan/ingest seam for the song routes (routers/song.py). kick_scan/ # invalidate_song_caches/stat_for_cache stay in server.py (scan lifecycle owns # them); scan_status is a GETTER (the underlying dict is reassigned, so a value # would go stale) — call appstate.scan_status() to read the live status. kick_scan = None invalidate_song_caches = None stat_for_cache = None scan_status = None # The directory containing server.py: the repo root in dev, resources/feedBack when # bundled — the tree that actually holds docs/ and data/. # # It is published HERE, by server.py, precisely so no module under lib/ ever computes it. # `Path(__file__).resolve().parent` is correct in server.py and silently WRONG anywhere in # lib/ (it yields lib/, which has no docs/ or data/), and it fails by finding nothing # rather than by raising — the builtin-content seeds would just quietly never run. See # lib/builtin_content.py's header. Read it; never re-derive it. server_root = None _SLOTS = frozenset({ "meta_db", "audio_effect_mappings", "tuning_providers", "library_providers", "local_library_provider", "config_dir", "dlc_dir", "dlc_dir_env", "static_dir", "sloppak_cache_dir", "audio_cache_dir", "get_progression_content", "builtin_diagnostic_filename", "running_version", "art_cache_dir", "song_pack_art_exists", "art_override_paths", "art_safe_name", "default_settings", "kick_scan", "invalidate_song_caches", "stat_for_cache", "scan_status", "server_root", }) def configure(**kwargs) -> None: """Publish `server`'s singletons into this module. Called once per `server` import (and again on re-import), so it must be idempotent.""" unknown = set(kwargs) - _SLOTS if unknown: raise TypeError( f"appstate.configure() got unknown slot(s): {sorted(unknown)}. " f"Known slots: {sorted(_SLOTS)}. Add the name to _SLOTS if a router " f"genuinely needs it." ) globals().update(kwargs)