From a7fb56c9fe402e31a7f7651255d435a4b229e0e0 Mon Sep 17 00:00:00 2001 From: Martin Bauer Date: Sat, 19 Sep 2026 21:20:59 +0200 Subject: [PATCH] Target the Python that Raspberry Pi OS ships Requiring 3.13 meant the device needed an interpreter the distribution does not have, which is what dragged uv in, and uv then had to be matched to the Pi's 32-bit userland by hand and to build Pillow from source because no armv7 wheel exists for a 3.13 ABI. Dropping to 3.11 removes all of that: apt provides the interpreter and piwheels has prebuilt armhf wheels for the native dependencies. The 3.13-only syntax was shallow - PEP 695 throughout, which converts back mechanically: type X = Y -> X: TypeAlias = Y type Handler[E: Event] -> E = TypeVar("E", bound=Event) plus a plain alias, which is generic anyway because it carries a TypeVar def f[T: Bound](...) -> a module-level TypeVar Also drop the one @override (3.12, and static-only), and stop the lirc test harness calling Server.close_clients(), which is 3.13: the scripted handler now releases its connection when asked, which is what that call was there to force. Verified on 3.11.14 - 485 passed, mypy strict clean - and still 496 passed on the 3.14 dev venv, which additionally has the analysis extra. Co-Authored-By: Claude Opus 5 --- python-backend/musicmouse/__main__.py | 7 +++++-- python-backend/musicmouse/bus.py | 12 ++++++++---- python-backend/musicmouse/config.py | 4 ++-- python-backend/musicmouse/devices/wire.py | 7 +++---- python-backend/musicmouse/events.py | 4 ++-- python-backend/musicmouse/library/models.py | 3 ++- python-backend/musicmouse/library/sections.py | 12 ++++++------ python-backend/musicmouse/reactions/registry.py | 10 ++++++---- python-backend/musicmouse/simulator/driver.py | 8 +++++--- python-backend/musicmouse/tippen/curriculum.py | 8 ++++---- python-backend/pyproject.toml | 10 +++++++--- python-backend/tests/test_lirc.py | 16 +++++++++++++--- python-backend/tests/test_mouse.py | 6 +++++- python-backend/tests/test_player.py | 6 +++++- python-backend/tests/test_reactions.py | 6 +++++- python-backend/tests/test_web.py | 3 ++- 16 files changed, 80 insertions(+), 42 deletions(-) diff --git a/python-backend/musicmouse/__main__.py b/python-backend/musicmouse/__main__.py index 3dc8b79..06f5b93 100644 --- a/python-backend/musicmouse/__main__.py +++ b/python-backend/musicmouse/__main__.py @@ -19,7 +19,7 @@ import logging import sys from collections.abc import Awaitable, Callable, Coroutine from pathlib import Path -from typing import Any +from typing import Any, TypeVar import httpx2 @@ -324,7 +324,10 @@ def _build_app( return app -def _service_of_type[T: Service](services: list[Service], kind: type[T]) -> T | None: +T = TypeVar("T", bound=Service) + + +def _service_of_type(services: list[Service], kind: type[T]) -> T | None: return next((s for s in services if isinstance(s, kind)), None) diff --git a/python-backend/musicmouse/bus.py b/python-backend/musicmouse/bus.py index 6d8b9d9..ddd691c 100644 --- a/python-backend/musicmouse/bus.py +++ b/python-backend/musicmouse/bus.py @@ -16,7 +16,7 @@ import contextlib import inspect import logging from collections.abc import Callable, Coroutine -from typing import Any +from typing import Any, TypeAlias, TypeVar from musicmouse.events import Event @@ -24,8 +24,12 @@ _log = logging.getLogger(__name__) __all__ = ["EventBus", "Handler", "Unsubscribe"] -type Handler[E: Event] = Callable[[E], Coroutine[Any, Any, None] | None] -type Unsubscribe = Callable[[], None] +#: An alias carrying a TypeVar is generic on its own, so ``Handler[SomeEvent]`` still +#: parameterises it the way the PEP 695 form did. +E = TypeVar("E", bound=Event) + +Handler: TypeAlias = Callable[[E], Coroutine[Any, Any, None] | None] +Unsubscribe: TypeAlias = Callable[[], None] class EventBus: @@ -63,7 +67,7 @@ class EventBus: # --------------------------------------------------------------- subscription - def subscribe[E: Event](self, event_type: type[E], handler: Handler[E]) -> Unsubscribe: + def subscribe(self, event_type: type[E], handler: Handler[E]) -> Unsubscribe: """Register ``handler`` for ``event_type`` and any subclass of it.""" self._handlers.setdefault(event_type, []).append(handler) self._resolved.clear() diff --git a/python-backend/musicmouse/config.py b/python-backend/musicmouse/config.py index 4812a50..c97c729 100644 --- a/python-backend/musicmouse/config.py +++ b/python-backend/musicmouse/config.py @@ -9,7 +9,7 @@ from __future__ import annotations import logging from pathlib import Path -from typing import Annotated, Any, Final, Literal, Self +from typing import Annotated, Any, Final, Literal, Self, TypeAlias from pydantic import ( BaseModel, @@ -51,7 +51,7 @@ __all__ = [ ] #: Number keys on the IR remote, as lircd's ``BTN_0``..``BTN_9`` map to them. -type Digit = Literal["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"] +Digit: TypeAlias = Literal["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"] DEFAULT_AUDIO_EXTENSIONS = (".mp3", ".ogg", ".oga", ".opus", ".flac", ".wav", ".m4a", ".aac") diff --git a/python-backend/musicmouse/devices/wire.py b/python-backend/musicmouse/devices/wire.py index 867b4c3..d0ebe30 100644 --- a/python-backend/musicmouse/devices/wire.py +++ b/python-backend/musicmouse/devices/wire.py @@ -15,7 +15,7 @@ from __future__ import annotations import struct from dataclasses import dataclass, replace from enum import IntEnum -from typing import override +from typing import TypeAlias from musicmouse.effects import ( EffectAlexaSwipeConfig, @@ -162,7 +162,7 @@ class FirmwareLog: text: str -type Decoded = InputEvent | FirmwareLog +Decoded: TypeAlias = InputEvent | FirmwareLog # ------------------------------------------------------------------------- encoding @@ -367,12 +367,11 @@ class SetButtonBrightness: button: Button brightness: float - @override def __repr__(self) -> str: return f"{self.button.slug} backlight <- {self.brightness:.2f}" -type HostCommand = SetEffect | SetButtonBrightness +HostCommand: TypeAlias = SetEffect | SetButtonBrightness _ID_TO_EFFECT: dict[int, tuple[LedZone, type[LedEffect]]] = { message: (zone, effect_cls) diff --git a/python-backend/musicmouse/events.py b/python-backend/musicmouse/events.py index 099670c..a09eb8d 100644 --- a/python-backend/musicmouse/events.py +++ b/python-backend/musicmouse/events.py @@ -15,7 +15,7 @@ from __future__ import annotations import time from dataclasses import dataclass, field -from typing import Literal +from typing import Literal, TypeAlias from musicmouse.effects import LedEffect from musicmouse.hardware import Button, ButtonAction, LedZone, RotaryDirection, TouchButton @@ -53,7 +53,7 @@ __all__ = [ "VolumeChanged", ] -type EventSource = Literal["device", "player", "mqtt", "web", "lirc", "simulator", "system"] +EventSource: TypeAlias = Literal["device", "player", "mqtt", "web", "lirc", "simulator", "system"] @dataclass(frozen=True, slots=True, kw_only=True) diff --git a/python-backend/musicmouse/library/models.py b/python-backend/musicmouse/library/models.py index 97e1c5c..70b9063 100644 --- a/python-backend/musicmouse/library/models.py +++ b/python-backend/musicmouse/library/models.py @@ -5,6 +5,7 @@ from __future__ import annotations import hashlib from dataclasses import dataclass from pathlib import Path +from typing import TypeAlias from musicmouse.library.analysis import TrackAnalysis from musicmouse.library.sections import AlbumKind @@ -15,7 +16,7 @@ __all__ = ["Album", "AlbumColors", "LibraryTrack", "album_id", "track_key"] #: Primary, secondary and accent as ``"#rrggbb"`` - the format #: :func:`musicmouse.color.parse_color` already accepts, so the LED side needs no new #: parsing and the frontend gets CSS colours for free. -type AlbumColors = tuple[str, str, str] +AlbumColors: TypeAlias = tuple[str, str, str] def album_id(root: Path, folder: Path) -> str: diff --git a/python-backend/musicmouse/library/sections.py b/python-backend/musicmouse/library/sections.py index c8fc41e..7990fe8 100644 --- a/python-backend/musicmouse/library/sections.py +++ b/python-backend/musicmouse/library/sections.py @@ -8,15 +8,15 @@ quirks are not understood yet. from __future__ import annotations from dataclasses import dataclass -from typing import Final, Literal +from typing import Final, Literal, TypeAlias __all__ = ["SECTIONS", "AlbumKind", "ArtistSource", "Section", "TitleSource", "TrackOrder"] -type AlbumKind = Literal["music", "book"] -type TrackOrder = Literal["filename", "newest_first"] -type TitleSource = Literal["tags", "folder"] -type ArtistSource = Literal["tags", "folder"] -type AlbumUnit = Literal["folder", "episode"] +AlbumKind: TypeAlias = Literal["music", "book"] +TrackOrder: TypeAlias = Literal["filename", "newest_first"] +TitleSource: TypeAlias = Literal["tags", "folder"] +ArtistSource: TypeAlias = Literal["tags", "folder"] +AlbumUnit: TypeAlias = Literal["folder", "episode"] @dataclass(frozen=True, slots=True) diff --git a/python-backend/musicmouse/reactions/registry.py b/python-backend/musicmouse/reactions/registry.py index b38d1e7..bd5f3f3 100644 --- a/python-backend/musicmouse/reactions/registry.py +++ b/python-backend/musicmouse/reactions/registry.py @@ -8,7 +8,7 @@ from __future__ import annotations import logging from collections.abc import Callable, Coroutine -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, TypeAlias, TypeVar from musicmouse.bus import EventBus from musicmouse.events import Event @@ -20,12 +20,14 @@ _log = logging.getLogger(__name__) __all__ = ["Reaction", "on", "register_all", "registered"] -type Reaction[E: Event] = Callable[[E, "App"], Coroutine[Any, Any, None] | None] +E = TypeVar("E", bound=Event) + +Reaction: TypeAlias = Callable[[E, "App"], Coroutine[Any, Any, None] | None] _REGISTRY: list[tuple[type[Event], Reaction[Any]]] = [] -def on[E: Event](event_type: type[E]) -> Callable[[Reaction[E]], Reaction[E]]: +def on(event_type: type[E]) -> Callable[[Reaction[E]], Reaction[E]]: """Register a reaction for ``event_type`` (and any subclass of it).""" def decorator(reaction: Reaction[E]) -> Reaction[E]: @@ -46,7 +48,7 @@ def register_all(bus: EventBus, app: App) -> None: _log.debug("Registered %d reactions", len(_REGISTRY)) -def _bind[E: Event](reaction: Reaction[E], app: App) -> Callable[[E], Any]: +def _bind(reaction: Reaction[E], app: App) -> Callable[[E], Any]: def handler(event: E) -> Any: return reaction(event, app) diff --git a/python-backend/musicmouse/simulator/driver.py b/python-backend/musicmouse/simulator/driver.py index 29e0dbe..dba3a3a 100644 --- a/python-backend/musicmouse/simulator/driver.py +++ b/python-backend/musicmouse/simulator/driver.py @@ -17,6 +17,7 @@ from __future__ import annotations import logging from dataclasses import dataclass +from typing import TypeVar from musicmouse.app import App from musicmouse.clock import Clock, FakeClock @@ -329,9 +330,10 @@ def _int(text: str) -> int: raise ScriptError(f"{text!r} is not a whole number") from None -def _enum_by_name[T: (Button, ButtonAction, TouchButton)]( - enum: type[T], name: str, what: str -) -> T: +_EnumT = TypeVar("_EnumT", Button, ButtonAction, TouchButton) + + +def _enum_by_name(enum: type[_EnumT], name: str, what: str) -> _EnumT: try: return enum[name.upper()] except KeyError: diff --git a/python-backend/musicmouse/tippen/curriculum.py b/python-backend/musicmouse/tippen/curriculum.py index 09bb0b1..5e5403c 100644 --- a/python-backend/musicmouse/tippen/curriculum.py +++ b/python-backend/musicmouse/tippen/curriculum.py @@ -15,7 +15,7 @@ from __future__ import annotations import logging from dataclasses import dataclass from pathlib import Path -from typing import Final, Literal +from typing import Final, Literal, TypeAlias from pydantic import BaseModel, ConfigDict, ValidationError, model_validator from ruamel.yaml import YAML @@ -37,9 +37,9 @@ __all__ = [ "world_reward", ] -type LessonKind = Literal["letters", "fragments", "words", "sentences"] -type ModeId = Literal["dive", "bubbles", "feed", "race"] -type CreatureId = Literal["clownfish", "octopus", "seahorse", "turtle", "pearlmussel"] +LessonKind: TypeAlias = Literal["letters", "fragments", "words", "sentences"] +ModeId: TypeAlias = Literal["dive", "bubbles", "feed", "race"] +CreatureId: TypeAlias = Literal["clownfish", "octopus", "seahorse", "turtle", "pearlmussel"] #: In world order - see ``tippen/src/lib/aquarium.ts``, the one place this list is #: allowed to grow, since each id names a drawing under ``public/aquarium/``. diff --git a/python-backend/pyproject.toml b/python-backend/pyproject.toml index e3e6495..30cc8fc 100644 --- a/python-backend/pyproject.toml +++ b/python-backend/pyproject.toml @@ -2,7 +2,11 @@ name = "musicmouse" version = "2.0.0" description = "Host backend for the MusicMouse RFID music player" -requires-python = ">=3.13" +# Raspberry Pi OS (Bookworm) ships Python 3.11, and its system interpreter is what the +# device runs. Staying on it means apt and piwheels supply prebuilt armhf wheels for +# the native dependencies (pydantic-core, Pillow), instead of needing a separate +# toolchain to fetch a newer interpreter and compile them on the Pi. +requires-python = ">=3.11" dependencies = [ "aiomqtt>=2.0", "fastapi>=0.115", @@ -50,7 +54,7 @@ filterwarnings = ["error"] [tool.ruff] line-length = 100 -target-version = "py313" +target-version = "py311" # Course material and scratch work, not part of the backend. See notebooks/README.md. extend-exclude = ["notebooks"] @@ -63,7 +67,7 @@ select = ["ARG", "B", "C4", "E", "F", "I", "N", "PTH", "RUF", "SIM", "UP", "W"] "musicmouse/services/mqtt/entity.py" = ["ARG002", "B027"] [tool.mypy] -python_version = "3.13" +python_version = "3.11" strict = true files = ["musicmouse"] warn_unreachable = true diff --git a/python-backend/tests/test_lirc.py b/python-backend/tests/test_lirc.py index 4349e36..1f9574f 100644 --- a/python-backend/tests/test_lirc.py +++ b/python-backend/tests/test_lirc.py @@ -70,6 +70,7 @@ class ScriptedLircd: def __init__(self) -> None: self._writer: asyncio.StreamWriter | None = None self._connected = asyncio.Event() + self._closing = asyncio.Event() self.connection_count = 0 async def _handle(self, _reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: @@ -77,7 +78,14 @@ class ScriptedLircd: self.connection_count += 1 self._connected.set() with contextlib.suppress(asyncio.CancelledError): - await asyncio.Event().wait() # held open until the test drops it + await self._closing.wait() # held open until the test drops it + writer.close() + with contextlib.suppress(OSError, asyncio.CancelledError): + await writer.wait_closed() + + def shutdown(self) -> None: + """Let go of every connection still being held open, so the server can close.""" + self._closing.set() async def wait_connected(self) -> None: await self._connected.wait() @@ -108,8 +116,10 @@ async def lircd() -> AsyncIterator[tuple[ScriptedLircd, asyncio.base_events.Serv finally: server.close() # The scripted connection handler holds its connection open forever (it does - # not know the test is done), so `wait_closed()` alone would hang here. - server.close_clients() + # not know the test is done), so `wait_closed()` alone would hang here. 3.13 + # has Server.close_clients() for exactly this; ask the handlers to let go + # instead, because the device runs the Pi's Python 3.11. + station.shutdown() await server.wait_closed() diff --git a/python-backend/tests/test_mouse.py b/python-backend/tests/test_mouse.py index 16a0ecf..37dbad7 100644 --- a/python-backend/tests/test_mouse.py +++ b/python-backend/tests/test_mouse.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging from collections.abc import AsyncIterator +from typing import TypeVar import pytest @@ -58,7 +59,10 @@ def seen(bus: EventBus) -> list[Event]: return events -def only[T: Event](events: list[Event], event_type: type[T]) -> list[T]: +T = TypeVar("T", bound=Event) + + +def only(events: list[Event], event_type: type[T]) -> list[T]: return [e for e in events if isinstance(e, event_type)] diff --git a/python-backend/tests/test_player.py b/python-backend/tests/test_player.py index 7987a30..f8a653f 100644 --- a/python-backend/tests/test_player.py +++ b/python-backend/tests/test_player.py @@ -10,6 +10,7 @@ from __future__ import annotations from collections.abc import AsyncIterator from pathlib import Path +from typing import TypeVar import pytest @@ -55,7 +56,10 @@ def seen(bus: EventBus) -> list[Event]: return events -def only[T: Event](events: list[Event], event_type: type[T]) -> list[T]: +T = TypeVar("T", bound=Event) + + +def only(events: list[Event], event_type: type[T]) -> list[T]: return [e for e in events if isinstance(e, event_type)] diff --git a/python-backend/tests/test_reactions.py b/python-backend/tests/test_reactions.py index 83e0bd5..51967a8 100644 --- a/python-backend/tests/test_reactions.py +++ b/python-backend/tests/test_reactions.py @@ -8,6 +8,7 @@ from __future__ import annotations from collections.abc import AsyncIterator from pathlib import Path +from typing import TypeVar import pytest @@ -49,7 +50,10 @@ def seen(sim: Simulation) -> list[Event]: return events -def only[T: Event](events: list[Event], event_type: type[T]) -> list[T]: +T = TypeVar("T", bound=Event) + + +def only(events: list[Event], event_type: type[T]) -> list[T]: return [e for e in events if isinstance(e, event_type)] diff --git a/python-backend/tests/test_web.py b/python-backend/tests/test_web.py index 28cee3e..90ad1fb 100644 --- a/python-backend/tests/test_web.py +++ b/python-backend/tests/test_web.py @@ -11,6 +11,7 @@ import contextlib import json from collections.abc import AsyncIterator from pathlib import Path +from typing import TypeAlias import httpx2 import pytest @@ -27,7 +28,7 @@ from tests.websocket_harness import WebSocketSession, websocket_connect TRACK_SECONDS = 10.0 #: Shorthand: every test takes the same client type. -type Client = httpx2.AsyncClient +Client: TypeAlias = httpx2.AsyncClient @pytest.fixture