mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-26 06:42:32 +00:00
The library query surface (songs/albums/artists/stats/genres/tuning-names/ practice-suggestions), the provider list/art/sync endpoints, and collection CRUD move to lib/routers/library.py. The registry itself — LibraryProviderRegistry, LocalLibraryProvider, SmartCollectionProvider, the collection-provider lifecycle (_sync/_unregister_collection_provider), and the shared query/collection helpers (_library_filter_args, _split_csv, _sanitize_collection_rules, _safe_art_redirect_url, the filter-key sets) — moves to lib/library_registry.py. The PLUGIN CONTRACT is untouched: server.py still constructs the singleton (LocalLibraryProvider needs meta_db), still exposes register_library_provider / unregister_library_provider to plugins via plugin_context (with the per-plugin ownership scoping in plugins/__init__.py), and injects the registry + local provider into appstate. The router reads appstate.library_providers / appstate.local_library_provider at call time; the provider classes are duck-typed so no plugin imports a base class. Acyclic: library_registry imports routers.art (for LocalLibraryProvider.get_art) + appstate, never server. server.py: 3,692 -> 2,925 (-767). Verified: pyflakes clean; ORDERED route table identical set (library block mounts at one site — all exact/specific-path, no catch-all, no shadowing); full pytest 2397 passed (incl the plugin register/unregister + collection-as-provider tests). eslint 0. Boot smoke: /api/library + providers list "local"; a created collection surfaces as a `collection:N` provider through the seam; collection CRUD clean. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
144 lines
6.9 KiB
Python
144 lines
6.9 KiB
Python
"""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
|
|
|
|
_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",
|
|
})
|
|
|
|
|
|
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)
|