diff --git a/docs/REPO_OVERVIEW.md b/docs/REPO_OVERVIEW.md index 517ac46..9ccf893 100644 --- a/docs/REPO_OVERVIEW.md +++ b/docs/REPO_OVERVIEW.md @@ -110,8 +110,10 @@ reported at once. ## Notes for agents -- The backend has `pytest`, `ruff` and `mypy --strict` configured in - `python-backend/pyproject.toml`, and no CI. Run all three before proposing changes. +- There is no CI. `scripts/check.sh` runs every check the repo has - backend + `pytest`, `ruff` and `mypy --strict`, web `vitest`, `tsc` and `eslint` - and is the + gate: run it before proposing changes. A checker that cannot run (mypy once aborted + on numpy's stubs for two weeks without anyone noticing) is a failure, not a skip. - Prefer adding a scenario in `python-backend/scenarios/` over a hand-rolled test when the behaviour is end-to-end — those files are executed by the test suite. - `python-backend/notebooks/` is university course material on chord recognition, not diff --git a/python-backend/README.md b/python-backend/README.md index 3642602..52158ae 100644 --- a/python-backend/README.md +++ b/python-backend/README.md @@ -106,6 +106,8 @@ ruff check . mypy # --strict, configured in pyproject.toml ``` +`../scripts/check.sh` runs these three plus the web front-end's checks in one go. + `tests/test_wire.py` parses `../esp-firmware/src/Messages.h` and fails if the Python message ids drift from the firmware's - the contract is hand-duplicated in two languages, and it had already drifted once (`BUTTON_EVENT` was missing on the Python diff --git a/python-backend/musicmouse/library/cache.py b/python-backend/musicmouse/library/cache.py index cfef2d0..44824d7 100644 --- a/python-backend/musicmouse/library/cache.py +++ b/python-backend/musicmouse/library/cache.py @@ -343,12 +343,12 @@ def shrink_cover( for a browser to decode. """ try: - with Image.open(io.BytesIO(data)) as image: - if max(image.size) <= max_px: + with Image.open(io.BytesIO(data)) as source: + if max(source.size) <= max_px: return data # JPEG can decode straight to 1/2, 1/4 or 1/8 size; a no-op for other formats. - image.draft("RGB", (max_px, max_px)) - image = image.convert("RGB") + source.draft("RGB", (max_px, max_px)) + image = source.convert("RGB") # `thumbnail` keeps the aspect ratio and never scales up. image.thumbnail((max_px, max_px), Image.Resampling.LANCZOS) buffer = io.BytesIO() diff --git a/python-backend/musicmouse/library/scanner.py b/python-backend/musicmouse/library/scanner.py index 9e4a885..a813949 100644 --- a/python-backend/musicmouse/library/scanner.py +++ b/python-backend/musicmouse/library/scanner.py @@ -135,9 +135,9 @@ def _cover_for( return cache.store_cover(identifier, art), art for path in paths[:3]: # Podcast feeds sometimes art only some episodes; a couple of tries is enough. - art = _embedded_art(path) - if art is not None: - return cache.store_cover(identifier, art), art + embedded = _embedded_art(path) + if embedded is not None: + return cache.store_cover(identifier, embedded), embedded return None, None @@ -155,9 +155,9 @@ def _cover_for_episode( if candidate.is_file(): art = candidate.read_bytes() return cache.store_cover(identifier, art), art - art = _embedded_art(path) - if art is not None: - return cache.store_cover(identifier, art), art + embedded = _embedded_art(path) + if embedded is not None: + return cache.store_cover(identifier, embedded), embedded for name in _COVER_NAMES: candidate = folder / name if candidate.is_file(): diff --git a/python-backend/pyproject.toml b/python-backend/pyproject.toml index 30cc8fc..49f284b 100644 --- a/python-backend/pyproject.toml +++ b/python-backend/pyproject.toml @@ -77,3 +77,12 @@ warn_unreachable = true # checkout must still type-check clean, so it needs the same treatment as librosa. module = ["vlc", "serial_asyncio", "ruamel.*", "librosa.*", "numpy", "feedparser"] ignore_missing_imports = true + +[[tool.mypy.overrides]] +# Recent numpy ships stubs written in 3.12+ syntax (`type X = ...`). Checking against +# the device's 3.11 makes mypy refuse to parse them and abort before checking anything +# at all - which is what happened in a dev venv that has the analysis extra. Treat the +# analysis stack as untyped instead: it is only touched by librosa_analyzer.py. +module = ["numpy", "numpy.*", "librosa", "librosa.*"] +follow_imports = "skip" +follow_imports_for_stubs = true diff --git a/scripts/check.sh b/scripts/check.sh new file mode 100755 index 0000000..4ac4c8a --- /dev/null +++ b/scripts/check.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# Every check this repo has, in one command. There is no CI, so this is the gate: +# run it before committing, and treat a checker that cannot run as a failure. +# +# scripts/check.sh backend and web +# scripts/check.sh backend just one half +# scripts/check.sh web +# +# PYTHON picks the interpreter (default: python-backend/.venv/bin/python, else python3). + +set -euo pipefail + +repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +what="${1:-all}" + +backend() { + cd "$repo/python-backend" + local python="${PYTHON:-}" + if [[ -z "$python" ]]; then + if [[ -x .venv/bin/python ]]; then python=.venv/bin/python; else python=python3; fi + fi + echo "== backend: pytest" + "$python" -m pytest -q + echo "== backend: ruff" + "$python" -m ruff check . + echo "== backend: mypy" + "$python" -m mypy +} + +web() { + cd "$repo/web" + echo "== web: vitest" + npm run --silent test + echo "== web: tsc + eslint" + npm run --silent check +} + +case "$what" in + all) backend; web ;; + backend) backend ;; + web) web ;; + *) echo "usage: $0 [all|backend|web]" >&2; exit 2 ;; +esac +echo "== all checks passed"