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