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 <noreply@anthropic.com>
This commit is contained in:
2026-09-19 21:20:59 +02:00
parent fa3d92189c
commit a7fb56c9fe
16 changed files with 80 additions and 42 deletions

View File

@@ -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)

View File

@@ -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()

View File

@@ -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")

View File

@@ -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)

View File

@@ -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)

View File

@@ -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:

View File

@@ -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)

View File

@@ -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)

View File

@@ -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:

View File

@@ -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/``.

View File

@@ -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

View File

@@ -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()

View File

@@ -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)]

View File

@@ -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)]

View File

@@ -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)]

View File

@@ -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