From ebe59d3f97719f11bc7f7f655618b7a7e2610ea0 Mon Sep 17 00:00:00 2001 From: Byron Gamatos Date: Fri, 10 Jul 2026 15:24:31 +0200 Subject: [PATCH] refactor(server): extract the audio-effects routes into routers/audio_effects.py (R3) (#834) The first route module through the appstate seam (#833). Picked BY MEASUREMENT, not by the plan's guess: a transitive dep-closure scan over every route group ranked audio-effects at 0 monkeypatch.setattr targets and exactly one exclusive helper. (The same scan disproved the plan's assumption that artists/aliases was free -- api_artist_links reaches _mb_http_get and _enrich_network_enabled, both setattr targets.) Bodies are verbatim. The only edits are mechanical: @app.get(...) -> @router.get(...) audio_effect_mappings.x -> appstate.audio_effect_mappings.x The singleton read must stay a module attribute resolved at call time, so a re-imported server re-publishes a fresh DB into the seam and monkeypatch reaches this module. `routers/` never imports `server`: server -> routers -> appstate. `app.include_router(...)` sits exactly where the routes used to be defined -- FastAPI matches in registration order, so the mount site preserves it. Verified by diffing the FULL route table against origin/main: 143 routes, identical paths, methods AND order. server.py: 9,445 -> 9,386 lines. `fastapi.Query` went dead with the move and was removed (the other four unused imports are pre-existing on main). Packaging: COPY routers/ /app/routers/ plus `!routers/` + `!routers/**` in .dockerignore (that file opens with a blanket `*`). Verified against the real docker daemon: routers/ reaches the build context, __pycache__ does not. Verified: pyflakes clean on routers/; no new undefined name in server.py; pytest 2348 passed (75 in the audio-effects + demo-mode suites); eslint 0 errors; boot smoke drives all five routes end-to-end (create -> read back -> activate -> clear -> delete -> 404 on missing -> 400 on bad body), Query(...) still 422s on a missing required param, and demo mode still 403s all four moved write routes while allowing the read. Co-authored-by: Claude Opus 4.8 (1M context) --- .dockerignore | 5 ++- CHANGELOG.md | 13 +++++++ Dockerfile | 1 + docker-compose.yml | 1 + docs/size-exemptions.md | 4 +- routers/__init__.py | 22 +++++++++++ routers/audio_effects.py | 80 ++++++++++++++++++++++++++++++++++++++++ server.py | 71 +++-------------------------------- 8 files changed, 129 insertions(+), 68 deletions(-) create mode 100644 routers/__init__.py create mode 100644 routers/audio_effects.py diff --git a/.dockerignore b/.dockerignore index 9ae2faf..a9c08a2 100644 --- a/.dockerignore +++ b/.dockerignore @@ -7,8 +7,11 @@ !server.py # The router seam server.py injects its singletons into (R3). Root-level Python # that ships in the image must be re-allowed explicitly — this file starts with -# a blanket `*` exclusion. `routers/` needs the same treatment when it lands. +# a blanket `*` exclusion. !appstate.py +# Route modules extracted from server.py (R3). +!routers/ +!routers/** !main.py !VERSION !tailwind.config.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fcf5cc..a033c07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **`routers/` — the first extracted route module (R3).** The five audio-effects mapping + endpoints move out of `server.py` into `routers/audio_effects.py` as a + `fastapi.APIRouter`, mounted with `app.include_router(...)` **at the point in the file + where they used to be defined** — FastAPI matches routes in registration order, so the + mount site preserves it. Verified: the full 143-route table (paths, methods, *and* + order) is byte-for-byte identical to `main`. Bodies are verbatim; the only edits are + the decorator receiver (`@app.get` → `@router.get`) and the singleton read + (`audio_effect_mappings` → `appstate.audio_effect_mappings`, a module attribute + resolved at call time). This proves the seam from #833 under a real consumer, including + the second slot. The `_demo_mode_guard` middleware still blocks all four moved write + routes with 403, and `Query(...)` validation still 422s — both checked against a running + server. Ships via `COPY routers/ /app/routers/` plus `!routers/` + `!routers/**` in + `.dockerignore`. `server.py`: **9,445 → 9,386 lines**. - **`appstate.py` — the router seam (R3).** Route modules moving out of `server.py` need `meta_db` and friends but must not `import server`, or the import graph goes circular the moment `server` imports them back. So `server.py` keeps *constructing* diff --git a/Dockerfile b/Dockerfile index 0c08d0e..ddc39cb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -209,6 +209,7 @@ COPY server.py /app/ # The router seam server.py injects its singletons into (R3). Root-level, like # server.py, so `import appstate` resolves off PYTHONPATH=/app. COPY appstate.py /app/ +COPY routers/ /app/routers/ COPY main.py /app/ COPY VERSION /app/ # Built-in diagnostic sloppaks seeded into DLC_DIR/diagnostics-builtin/ at scan diff --git a/docker-compose.yml b/docker-compose.yml index 8c8ad8e..acdbe95 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,6 +12,7 @@ services: - ./static:/app/static - ./server.py:/app/server.py - ./appstate.py:/app/appstate.py + - ./routers:/app/routers - ./VERSION:/app/VERSION - ./ug_browser.py:/app/ug_browser.py - ./lib:/app/lib diff --git a/docs/size-exemptions.md b/docs/size-exemptions.md index 777d845..2255df7 100644 --- a/docs/size-exemptions.md +++ b/docs/size-exemptions.md @@ -55,8 +55,8 @@ without a *signed* exemption" is unenforceable. ## Planned, NOT exempt (owned by split plans — listed so nothing falls between states) core `static/app.js` (11,852) · `static/highway.js` (4,168, whole file) · `server.py` -(9,445 — was 14,037; ratcheted by the R3 `MetadataDB` + `AudioEffectsMappingDB` -extractions, plus 12 lines for the `appstate.py` seam) · +(9,386 — was 14,037; ratcheted by the R3 `MetadataDB` + `AudioEffectsMappingDB` +extractions and the first `routers/` module) · `lib/metadata_db.py` (4,373 — new in R3; the `MetadataDB` class alone is 4,018 lines and is a monolith in its own right, to be split per-table once the router train lands) · `static/v3/songs.js` (4,134) · `static/capabilities/audio-session.js` diff --git a/routers/__init__.py b/routers/__init__.py new file mode 100644 index 0000000..ede4d39 --- /dev/null +++ b/routers/__init__.py @@ -0,0 +1,22 @@ +"""FastAPI route modules extracted from ``server.py`` (R3). + +Each module here exposes a module-level ``router`` (a ``fastapi.APIRouter``) +that ``server.py`` mounts with ``app.include_router(...)`` at the point in the +file where those routes used to be defined — FastAPI matches routes in +registration order, so keeping the mount site preserves it. + +**Routers must never ``import server``.** They reach core singletons through +the injected seam instead:: + + import appstate + + @router.get("/api/thing") + def get_thing(): + return appstate.meta_db.thing() + +and always as a **module attribute, at call time** — never +``from appstate import meta_db``, which freezes the binding and defeats both a +later ``appstate.configure()`` and ``monkeypatch.setattr``. See ``appstate.py``. + +Dependencies flow one way: ``server -> routers -> appstate``. +""" diff --git a/routers/audio_effects.py b/routers/audio_effects.py new file mode 100644 index 0000000..62c0d50 --- /dev/null +++ b/routers/audio_effects.py @@ -0,0 +1,80 @@ +"""Audio-effects mapping API — the core-owned song/tone -> provider routing index. + +Extracted verbatim from ``server.py`` (R3); only the decorator receiver +(``@app`` -> ``@router``) and the singleton read (``audio_effect_mappings`` -> +``appstate.audio_effect_mappings``) changed. The read must stay a module +attribute so a re-imported ``server`` re-publishes a fresh DB into the seam and +`monkeypatch.setattr` reaches this module — see ``appstate.py``. +""" + +from fastapi import APIRouter, Body, Query +from fastapi.responses import JSONResponse + +import appstate + +router = APIRouter() + + +def _audio_effects_error(exc: Exception): + return JSONResponse({"error": str(exc)}, status_code=400) + + +@router.get("/api/audio-effects/mappings") +def list_audio_effect_mappings( + song_key: str = Query(""), + filename: str = Query(""), + tone_key: str = Query(""), + provider_id: str = Query(""), +): + try: + return { + "mappings": appstate.audio_effect_mappings.list( + song_key=song_key, + filename=filename, + tone_key=tone_key, + provider_id=provider_id, + ) + } + except ValueError as exc: + return _audio_effects_error(exc) + + +@router.post("/api/audio-effects/mappings") +def upsert_audio_effect_mapping(data: dict = Body(...)): + try: + mapping = appstate.audio_effect_mappings.upsert(data) + except ValueError as exc: + return _audio_effects_error(exc) + return {"ok": True, "mapping": mapping} + + +@router.delete("/api/audio-effects/mappings/{mapping_id}") +def delete_audio_effect_mapping(mapping_id: int, provider_id: str = Query("")): + try: + deleted = appstate.audio_effect_mappings.delete(mapping_id, provider_id=provider_id) + except ValueError as exc: + return _audio_effects_error(exc) + if not deleted: + return JSONResponse({"error": "mapping not found"}, status_code=404) + return {"ok": True} + + +@router.post("/api/audio-effects/mappings/{mapping_id}/activate") +def activate_audio_effect_mapping(mapping_id: int, data: dict = Body(default_factory=dict)): + try: + provider_id = data.get("provider_id") if "provider_id" in data else data.get("providerId") + mapping = appstate.audio_effect_mappings.activate(mapping_id, provider_id="" if provider_id is None else provider_id) + except ValueError as exc: + return _audio_effects_error(exc) + if not mapping: + return JSONResponse({"error": "mapping not found"}, status_code=404) + return {"ok": True, "mapping": mapping} + + +@router.delete("/api/audio-effects/active-mapping") +def clear_audio_effect_active_mapping(song_key: str = Query(...), tone_key: str = Query("")): + try: + cleared = appstate.audio_effect_mappings.clear_active(song_key=song_key, tone_key=tone_key) + except ValueError as exc: + return _audio_effects_error(exc) + return {"ok": True, "cleared": cleared} diff --git a/server.py b/server.py index 5b6bee2..10afa53 100644 --- a/server.py +++ b/server.py @@ -21,7 +21,7 @@ configure_logging() log = logging.getLogger("feedBack.server") -from fastapi import Body, FastAPI, WebSocket, WebSocketDisconnect, UploadFile, File, HTTPException, Query +from fastapi import Body, FastAPI, WebSocket, WebSocketDisconnect, UploadFile, File, HTTPException from fastapi.concurrency import run_in_threadpool from fastapi.staticfiles import StaticFiles from fastapi.responses import FileResponse, JSONResponse, RedirectResponse, Response, StreamingResponse @@ -69,6 +69,8 @@ from audio_effects_db import AudioEffectsMappingDB # The router seam. Imported as a module (never `from appstate import ...`) so # `appstate.configure(...)` below publishes into the same namespace routers read. import appstate +# Extracted route modules. They import `appstate`, never `server` — one-way graph. +from routers import audio_effects import sloppak as sloppak_mod import drums as drums_mod import notation as notation_mod @@ -5991,70 +5993,9 @@ def delete_loop(loop_id: int): # ── Audio Effects Mapping API ─────────────────────────────────────────────── - -def _audio_effects_error(exc: Exception): - return JSONResponse({"error": str(exc)}, status_code=400) - - -@app.get("/api/audio-effects/mappings") -def list_audio_effect_mappings( - song_key: str = Query(""), - filename: str = Query(""), - tone_key: str = Query(""), - provider_id: str = Query(""), -): - try: - return { - "mappings": audio_effect_mappings.list( - song_key=song_key, - filename=filename, - tone_key=tone_key, - provider_id=provider_id, - ) - } - except ValueError as exc: - return _audio_effects_error(exc) - - -@app.post("/api/audio-effects/mappings") -def upsert_audio_effect_mapping(data: dict = Body(...)): - try: - mapping = audio_effect_mappings.upsert(data) - except ValueError as exc: - return _audio_effects_error(exc) - return {"ok": True, "mapping": mapping} - - -@app.delete("/api/audio-effects/mappings/{mapping_id}") -def delete_audio_effect_mapping(mapping_id: int, provider_id: str = Query("")): - try: - deleted = audio_effect_mappings.delete(mapping_id, provider_id=provider_id) - except ValueError as exc: - return _audio_effects_error(exc) - if not deleted: - return JSONResponse({"error": "mapping not found"}, status_code=404) - return {"ok": True} - - -@app.post("/api/audio-effects/mappings/{mapping_id}/activate") -def activate_audio_effect_mapping(mapping_id: int, data: dict = Body(default_factory=dict)): - try: - provider_id = data.get("provider_id") if "provider_id" in data else data.get("providerId") - mapping = audio_effect_mappings.activate(mapping_id, provider_id="" if provider_id is None else provider_id) - except ValueError as exc: - return _audio_effects_error(exc) - if not mapping: - return JSONResponse({"error": "mapping not found"}, status_code=404) - return {"ok": True, "mapping": mapping} - - -@app.delete("/api/audio-effects/active-mapping") -def clear_audio_effect_active_mapping(song_key: str = Query(...), tone_key: str = Query("")): - try: - cleared = audio_effect_mappings.clear_active(song_key=song_key, tone_key=tone_key) - except ValueError as exc: - return _audio_effects_error(exc) - return {"ok": True, "cleared": cleared} +# Mounted here, where these routes used to be defined: FastAPI matches in +# registration order, so the mount site preserves it. +app.include_router(audio_effects.router) # ── Settings API ──────────────────────────────────────────────────────────────