feedBack/appstate.py
byrongamatos 73a654f293 feat(server): add appstate.py, the router seam (R3)
Routes moving out of server.py need `meta_db` and friends, but must not
`import server` -- that goes circular the moment server imports them back.
server.py keeps CONSTRUCTING its singletons and now injects them once via
`appstate.configure(...)`; routers read them back as module attributes at call
time (`import appstate; appstate.meta_db`). The Python analogue of the frontend
refactor's `configureX({...})` seams and of the plugin `setup(app, context)`
contract: dependencies flow one way, server -> routers -> appstate.

Two properties are load-bearing, both pinned by tests/test_appstate.py:

1. `import appstate` constructs nothing and touches no disk. This is why the
   ~49 test fixtures that `sys.modules.pop("server")` + re-import (to rebuild
   meta_db under a patched CONFIG_DIR) keep working UNTOUCHED. A singleton
   owned by appstate would survive that pop and go stale -- verified.
2. Reads must be late-bound. `from appstate import meta_db` freezes the binding
   and defeats both a later configure() and monkeypatch.setattr -- the same
   read-only-binding trap as ES imports.

configure() raises on an unknown slot instead of silently creating a global
nothing reads, and the suite asserts server ACTUALLY calls it. Negative-checked:
dropping the configure() call fails exactly the two wiring tests while the other
five stay green -- those five are the false-green a seam test must not be.

The new suite imports server through an `isolated_server` fixture that patches
CONFIG_DIR to tmp_path and closes both DB connections on teardown. An unguarded
`import server` constructs MetadataDB + AudioEffectsMappingDB under the real
`~/.local/share/feedback` (reproduced: running the file alone created
web_library.db + audio_effects.db there). The full suite now leaves the real
config dir untouched.

Packaging: `COPY appstate.py /app/` plus a .dockerignore allowlist entry. That
file opens with a blanket `*` exclusion, so root-level Python must be re-allowed
explicitly -- without it the image build fails on the COPY. Verified against the
real docker daemon (build context reaches /app/appstate.py). docker-compose.yml
gains the dev bind-mount; docker-compose.nas.yml runs the baked image, so the
COPY covers it. `routers/` will need the same two entries when it lands.

Verified: pyflakes clean; pytest 2348 passed (2341 + 7 new); eslint 0 errors;
boot smoke serves /api/version, /api/library, /api/audio-effects/mappings, and
all three migrated plugins' src/ graphs, with `appstate.meta_db is server.meta_db`
asserted against the live import.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 14:49:54 +02:00

73 lines
3.1 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``.
"""
# The singletons routers may read. Every name here must also be a `_SLOTS` key.
meta_db = None
audio_effect_mappings = None
# Declared up front so `configure()` can reject a typo'd or stale keyword
# instead of silently creating a new global that nothing ever reads. A seam
# whose wiring can no-op undetected is worse than no seam.
_SLOTS = frozenset({"meta_db", "audio_effect_mappings"})
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)