From d7c079c9c9715967f8154992b4c046e189ce4d85 Mon Sep 17 00:00:00 2001 From: Lev Date: Mon, 20 Mar 2023 22:35:12 +0300 Subject: [PATCH] Refactor backend (#161) --- .gitignore | 1 + .pre-commit-config.yaml | 9 +- backend/pyproject.toml | 4 +- backend/src/playbacker/app/app.py | 7 +- backend/src/playbacker/app/dependencies.py | 40 +++++ backend/src/playbacker/app/routes.py | 185 ++++++++++++--------- backend/tests/audiofile_test.py | 9 +- backend/tests/settings_test.py | 4 +- frontend/src/api.ts | 24 +-- frontend/src/store.ts | 14 +- 10 files changed, 182 insertions(+), 115 deletions(-) create mode 100644 backend/src/playbacker/app/dependencies.py diff --git a/.gitignore b/.gitignore index 04c34a3..cff2a34 100644 --- a/.gitignore +++ b/.gitignore @@ -3,5 +3,6 @@ dist __pycache__ .venv .pytest_cache +.ruff_cache .coverage node_modules diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8bfbf3e..7d5865d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -17,12 +17,13 @@ repos: hooks: - id: black - - repo: https://github.com/pycqa/isort - rev: 5.12.0 + - repo: https://github.com/charliermarsh/ruff-pre-commit + rev: v0.0.257 hooks: - - id: isort + - id: ruff + args: [--fix, --exit-non-zero-on-fix] - repo: https://github.com/pre-commit/mirrors-prettier - rev: "v3.0.0-alpha.6" + rev: v3.0.0-alpha.6 hooks: - id: prettier diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 913f010..261e97c 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -46,8 +46,8 @@ format = "v{base}" [tool.black] target-version = ["py310"] -[tool.isort] -profile = "black" +[tool.ruff] +ignore = ["E501"] [tool.coverage.run] source = ["src/playbacker"] diff --git a/backend/src/playbacker/app/app.py b/backend/src/playbacker/app/app.py index 5c3ae0c..aceeebb 100644 --- a/backend/src/playbacker/app/app.py +++ b/backend/src/playbacker/app/app.py @@ -5,8 +5,8 @@ from fastapi.middleware import Middleware from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles - -from playbacker.app.routes import get_router +from playbacker.app.dependencies import set_dependencies +from playbacker.app.routes import router from playbacker.config import Config, get_config_file_path from playbacker.core.playback import Playback from playbacker.core.player import Player @@ -30,7 +30,8 @@ def get_app(config: Config): ], on_shutdown=[lambda: player.stop()], ) - app.include_router(get_router(config, player)) + set_dependencies(app=app, player=player, config=config) + app.include_router(router) frontend = Path(__file__).parent.parent / "dist" if frontend.exists(): diff --git a/backend/src/playbacker/app/dependencies.py b/backend/src/playbacker/app/dependencies.py new file mode 100644 index 0000000..44a5ceb --- /dev/null +++ b/backend/src/playbacker/app/dependencies.py @@ -0,0 +1,40 @@ +from pathlib import Path +from typing import Annotated + +from fastapi import Depends, FastAPI +from playbacker.config import Config, get_setlists_dir_path, get_songs_file_path +from playbacker.core.player import Player + + +def get_player() -> Player: + ... + + +CurrentPlayer = Annotated[Player, Depends(get_player)] + + +def get_config() -> Config: + ... + + +CurrentConfig = Annotated[Config, Depends(get_config)] + + +def get_setlist_dir_path_dep(config: CurrentConfig): + return get_setlists_dir_path(config.config_dir_path) + + +CurrentSetlistDir = Annotated[Path, Depends(get_setlist_dir_path_dep)] + + +def get_songs_file_path_dep(config: CurrentConfig): + return get_songs_file_path(config.config_dir_path) + + +CurrentSongsFile = Annotated[Path, Depends(get_songs_file_path_dep)] + + +def set_dependencies(app: FastAPI, player: Player, config: Config): + app.dependency_overrides[get_player] = lambda: player + app.dependency_overrides[get_config] = lambda: config + app.dependency_overrides[get_config] = lambda: config diff --git a/backend/src/playbacker/app/routes.py b/backend/src/playbacker/app/routes.py index 12e17cc..c9c8ab3 100644 --- a/backend/src/playbacker/app/routes.py +++ b/backend/src/playbacker/app/routes.py @@ -1,12 +1,15 @@ from pathlib import Path +from typing import Any, TypeVar +from collections.abc import Callable import watchfiles import yaml from fastapi import APIRouter, HTTPException -from pydantic import BaseModel -from sse_starlette import EventSourceResponse - -from playbacker.config import Config, get_setlists_dir_path, get_songs_file_path +from playbacker.app.dependencies import ( + CurrentPlayer, + CurrentSetlistDir, + CurrentSongsFile, +) from playbacker.core.player import Player from playbacker.core.setlist import ( NoSongInStorageError, @@ -16,13 +19,20 @@ ) from playbacker.core.song import load_songs from playbacker.core.tempo import Tempo +from pydantic import BaseModel +from sse_starlette import EventSourceResponse +router = APIRouter() -def get_setlist_path_from_pretty_name(name: str, setlists_dir_path: Path) -> Path: - map = {prettify_setlist_stem(f.stem): f for f in setlists_dir_path.glob("*.yaml")} - if path := map.get(name): - return path - raise HTTPException(404, "no setlist with this name") +_RouteFunc = TypeVar("_RouteFunc", bound=Callable[..., Any]) + + +def post(func: _RouteFunc) -> _RouteFunc: + return router.post(f"/{func.__name__}")(func) + + +def get(func: _RouteFunc) -> _RouteFunc: + return router.get(f"/{func.__name__}")(func) class PlayerState(BaseModel): @@ -34,77 +44,86 @@ def make(cls, player: Player): return cls(playing=player.playing, guide_enabled=player.guide_enabled) -def get_router(config: Config, player: Player): - router = APIRouter() - setlists_dir_path = get_setlists_dir_path(config.config_dir_path) - songs_file_path = get_songs_file_path(config.config_dir_path) - - @router.post("/getSetlists") - def _(): - stems = [ - prettify_setlist_stem(f.stem) for f in setlists_dir_path.glob("*.yaml") - ] - stems.sort(reverse=True) - return stems - - @router.post("/getSetlist") - def _(name: str) -> Setlist: - path = get_setlist_path_from_pretty_name(name, setlists_dir_path) - - with songs_file_path.open() as f: - songs = load_songs(content=yaml.safe_load(f)) - - with path.open() as f: - content = yaml.safe_load(f) - - try: - return load_setlist(name=name, content=content, songs=songs) - except NoSongInStorageError as err: - raise HTTPException(404, err.message) - - @router.post("/togglePlaying") - def _(tempo: Tempo): - if player.playing: - player.pause() - else: - player.play(tempo) - return PlayerState.make(player) - - @router.post("/toggleGuideEnabled") - def _(): - player.guide_enabled = not player.guide_enabled - return PlayerState.make(player) - - @router.post("/prepareForSwitch") - def _(): - player.prepare_for_switch() - return PlayerState.make(player) - - @router.post("/reset") - def _(): - player.reset() - return PlayerState.make(player) - - @router.get("/watchSetlists") - def _(): - async def watch(): - async for _ in watchfiles.awatch( # pyright: ignore[reportUnknownMemberType] - setlists_dir_path - ): - yield True - - return EventSourceResponse(watch()) - - @router.get("/watchSetlist") - def _(name: str) -> EventSourceResponse: - path = get_setlist_path_from_pretty_name(name, setlists_dir_path) - - async def watch(): - async for _ in watchfiles.awatch( # pyright: ignore[reportUnknownMemberType] - path, songs_file_path - ): - yield True - - return EventSourceResponse(watch()) - - return router +@post +def get_setlists(setlists_dir: CurrentSetlistDir): + stems = [prettify_setlist_stem(f.stem) for f in setlists_dir.glob("*.yaml")] + stems.sort(reverse=True) + return stems + + +def get_setlist_path_from_pretty_name(name: str, setlists_dir_path: Path) -> Path: + map = {prettify_setlist_stem(f.stem): f for f in setlists_dir_path.glob("*.yaml")} + if path := map.get(name): + return path + raise HTTPException(404, "no setlist with this name") + + +@post +def get_setlist( + name: str, setlists_dir: CurrentSetlistDir, songs_file: CurrentSongsFile +) -> Setlist: + path = get_setlist_path_from_pretty_name(name, setlists_dir) + + with songs_file.open() as f: + songs = load_songs(content=yaml.safe_load(f)) + + with path.open() as f: + content = yaml.safe_load(f) + + try: + return load_setlist(name=name, content=content, songs=songs) + except NoSongInStorageError as err: + raise HTTPException(404, err.message) + + +@post +def toggle_playing(tempo: Tempo, player: CurrentPlayer): + if player.playing: + player.pause() + else: + player.play(tempo) + return PlayerState.make(player) + + +@post +def toggle_guide_enabled(player: CurrentPlayer): + player.guide_enabled = not player.guide_enabled + return PlayerState.make(player) + + +@post +def prepare_for_switch(player: CurrentPlayer): + player.prepare_for_switch() + return PlayerState.make(player) + + +@post +def reset(player: CurrentPlayer): + player.reset() + return PlayerState.make(player) + + +@get +def watch_setlists(setlist_dir: CurrentSetlistDir): + async def watch(): + async for _ in watchfiles.awatch( # pyright: ignore[reportUnknownMemberType] + setlist_dir + ): + yield True + + return EventSourceResponse(watch()) + + +@get +def watch_setlist( + name: str, setlist_dir: CurrentSetlistDir, songs_file: CurrentSongsFile +) -> EventSourceResponse: + path = get_setlist_path_from_pretty_name(name, setlist_dir) + + async def watch(): + async for _ in watchfiles.awatch( # pyright: ignore[reportUnknownMemberType] + path, songs_file + ): + yield True + + return EventSourceResponse(watch()) diff --git a/backend/tests/audiofile_test.py b/backend/tests/audiofile_test.py index 2d015aa..fa7a820 100644 --- a/backend/tests/audiofile_test.py +++ b/backend/tests/audiofile_test.py @@ -1,4 +1,3 @@ -from collections.abc import Callable from pathlib import Path from typing import Any from unittest.mock import Mock @@ -18,13 +17,17 @@ def audiofile(): def test_audiofile_data_no_resample( audiofile: AudioFile, monkeypatch: pytest.MonkeyPatch ): - func: Callable[..., Any] = lambda _: ("mydata", 44100) + def func(_: Any): + return ("mydata", 44100) + monkeypatch.setattr(soundfile, "read", func) assert audiofile.data == "mydata" def test_audiofile_data_resample(audiofile: AudioFile, monkeypatch: pytest.MonkeyPatch): - func: Callable[..., Any] = lambda _: ("mydata", 48000) + def func(_: Any): + return "mydata", 48000 + monkeypatch.setattr(soundfile, "read", func) mock = Mock() diff --git a/backend/tests/settings_test.py b/backend/tests/settings_test.py index fd2e26a..bb50069 100644 --- a/backend/tests/settings_test.py +++ b/backend/tests/settings_test.py @@ -54,7 +54,9 @@ def query_devices(device: str | None, kind: str): @pytest.fixture def devices(): - m = lambda: _ChannelMap(metronome=[1], guide=[2], multitrack=[3, 4]) + def m(): + return _ChannelMap(metronome=[1], guide=[2], multitrack=[3, 4]) + return [ _Device( name="dev1", pretty_name="Device 1", sample_rate=48000, channel_map=m() diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 8e4eb19..5a59689 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -21,11 +21,11 @@ export interface PlayerState { } export interface Player { - getSetlists(): Promise; - getSetlist(name: string): Promise; - togglePlaying(tempo: Tempo): Promise; - toggleGuideEnabled(): Promise; - prepareForSwitch(): Promise; + get_setlists(): Promise; + get_setlist(name: string): Promise; + toggle_playing(tempo: Tempo): Promise; + toggle_guide_enabled(): Promise; + prepare_for_switch(): Promise; reset(): Promise; } @@ -36,16 +36,16 @@ export function apiPlayer(): Player { (await fetch(makeUrl(path), { method: "POST", ...init })).json(); return { - getSetlists: () => e("/getSetlists"), - getSetlist: (name: string) => - e(`/getSetlist?${new URLSearchParams({ name })}`), - togglePlaying: (tempo: Tempo) => - e("/togglePlaying", { + get_setlists: () => e("/get_setlists"), + get_setlist: (name: string) => + e(`/get_setlist?${new URLSearchParams({ name })}`), + toggle_playing: (tempo: Tempo) => + e("/toggle_playing", { headers: { "Content-Type": "application/json" }, body: JSON.stringify(tempo), }), - toggleGuideEnabled: () => e("/toggleGuideEnabled"), - prepareForSwitch: () => e("/prepareForSwitch"), + toggle_guide_enabled: () => e("/toggle_guide_enabled"), + prepare_for_switch: () => e("/prepare_for_switch"), reset: () => e("/reset"), }; } diff --git a/frontend/src/store.ts b/frontend/src/store.ts index 870082d..bd73217 100644 --- a/frontend/src/store.ts +++ b/frontend/src/store.ts @@ -25,7 +25,7 @@ function getNextSong(songs: Song[], current: Song | null): Song { export function getStore(player: Player) { const [setlists, { refetch: refetchSetlists }] = createResource( - player.getSetlists, + player.get_setlists, ); function allowSetlistChange(prev: string | null, next: string | null) { @@ -41,7 +41,7 @@ export function getStore(player: Player) { const [setlist, { refetch: refetchSetlist }] = createResource( setlistName, - player.getSetlist, + player.get_setlist, ); createEffect( on(setlist, (setlist) => { @@ -72,7 +72,7 @@ export function getStore(player: Player) { }, }); createEffect( - on(song, async () => updateState(await player.prepareForSwitch()), { + on(song, async () => updateState(await player.prepare_for_switch()), { defer: false, }), ); @@ -88,15 +88,15 @@ export function getStore(player: Player) { async function togglePlaying() { const song_ = song(); if (!song_) return; - updateState(await player.togglePlaying(song_.tempo)); + updateState(await player.toggle_playing(song_.tempo)); } const [guideEnabled, _setGuideEnabled] = createSignal(true); const toggleGuide = async () => - updateState(await player.toggleGuideEnabled()); + updateState(await player.toggle_guide_enabled()); const resetPlayback = async () => updateState(await player.reset()); - const setlistsSource = new EventSource(makeUrl("/watchSetlists")); + const setlistsSource = new EventSource(makeUrl("/watch_setlists")); setlistsSource.addEventListener("message", () => refetchSetlists(), false); let setlistSource: EventSource | undefined; @@ -104,7 +104,7 @@ export function getStore(player: Player) { const setlistName_ = setlistName(); if (setlistSource) setlistSource.close(); setlistSource = new EventSource( - makeUrl(`/watchSetlist?name=${setlistName_}`), + makeUrl(`/watch_setlist?name=${setlistName_}`), ); setlistSource.addEventListener("message", () => refetchSetlist(), false); });