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:
@@ -19,7 +19,7 @@ import logging
|
|||||||
import sys
|
import sys
|
||||||
from collections.abc import Awaitable, Callable, Coroutine
|
from collections.abc import Awaitable, Callable, Coroutine
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any, TypeVar
|
||||||
|
|
||||||
import httpx2
|
import httpx2
|
||||||
|
|
||||||
@@ -324,7 +324,10 @@ def _build_app(
|
|||||||
return 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)
|
return next((s for s in services if isinstance(s, kind)), None)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import contextlib
|
|||||||
import inspect
|
import inspect
|
||||||
import logging
|
import logging
|
||||||
from collections.abc import Callable, Coroutine
|
from collections.abc import Callable, Coroutine
|
||||||
from typing import Any
|
from typing import Any, TypeAlias, TypeVar
|
||||||
|
|
||||||
from musicmouse.events import Event
|
from musicmouse.events import Event
|
||||||
|
|
||||||
@@ -24,8 +24,12 @@ _log = logging.getLogger(__name__)
|
|||||||
|
|
||||||
__all__ = ["EventBus", "Handler", "Unsubscribe"]
|
__all__ = ["EventBus", "Handler", "Unsubscribe"]
|
||||||
|
|
||||||
type Handler[E: Event] = Callable[[E], Coroutine[Any, Any, None] | None]
|
#: An alias carrying a TypeVar is generic on its own, so ``Handler[SomeEvent]`` still
|
||||||
type Unsubscribe = Callable[[], None]
|
#: 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:
|
class EventBus:
|
||||||
@@ -63,7 +67,7 @@ class EventBus:
|
|||||||
|
|
||||||
# --------------------------------------------------------------- subscription
|
# --------------------------------------------------------------- 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."""
|
"""Register ``handler`` for ``event_type`` and any subclass of it."""
|
||||||
self._handlers.setdefault(event_type, []).append(handler)
|
self._handlers.setdefault(event_type, []).append(handler)
|
||||||
self._resolved.clear()
|
self._resolved.clear()
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Annotated, Any, Final, Literal, Self
|
from typing import Annotated, Any, Final, Literal, Self, TypeAlias
|
||||||
|
|
||||||
from pydantic import (
|
from pydantic import (
|
||||||
BaseModel,
|
BaseModel,
|
||||||
@@ -51,7 +51,7 @@ __all__ = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
#: Number keys on the IR remote, as lircd's ``BTN_0``..``BTN_9`` map to them.
|
#: 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")
|
DEFAULT_AUDIO_EXTENSIONS = (".mp3", ".ogg", ".oga", ".opus", ".flac", ".wav", ".m4a", ".aac")
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from __future__ import annotations
|
|||||||
import struct
|
import struct
|
||||||
from dataclasses import dataclass, replace
|
from dataclasses import dataclass, replace
|
||||||
from enum import IntEnum
|
from enum import IntEnum
|
||||||
from typing import override
|
from typing import TypeAlias
|
||||||
|
|
||||||
from musicmouse.effects import (
|
from musicmouse.effects import (
|
||||||
EffectAlexaSwipeConfig,
|
EffectAlexaSwipeConfig,
|
||||||
@@ -162,7 +162,7 @@ class FirmwareLog:
|
|||||||
text: str
|
text: str
|
||||||
|
|
||||||
|
|
||||||
type Decoded = InputEvent | FirmwareLog
|
Decoded: TypeAlias = InputEvent | FirmwareLog
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------- encoding
|
# ------------------------------------------------------------------------- encoding
|
||||||
@@ -367,12 +367,11 @@ class SetButtonBrightness:
|
|||||||
button: Button
|
button: Button
|
||||||
brightness: float
|
brightness: float
|
||||||
|
|
||||||
@override
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return f"{self.button.slug} backlight <- {self.brightness:.2f}"
|
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]]] = {
|
_ID_TO_EFFECT: dict[int, tuple[LedZone, type[LedEffect]]] = {
|
||||||
message: (zone, effect_cls)
|
message: (zone, effect_cls)
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Literal
|
from typing import Literal, TypeAlias
|
||||||
|
|
||||||
from musicmouse.effects import LedEffect
|
from musicmouse.effects import LedEffect
|
||||||
from musicmouse.hardware import Button, ButtonAction, LedZone, RotaryDirection, TouchButton
|
from musicmouse.hardware import Button, ButtonAction, LedZone, RotaryDirection, TouchButton
|
||||||
@@ -53,7 +53,7 @@ __all__ = [
|
|||||||
"VolumeChanged",
|
"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)
|
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|||||||
import hashlib
|
import hashlib
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import TypeAlias
|
||||||
|
|
||||||
from musicmouse.library.analysis import TrackAnalysis
|
from musicmouse.library.analysis import TrackAnalysis
|
||||||
from musicmouse.library.sections import AlbumKind
|
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
|
#: Primary, secondary and accent as ``"#rrggbb"`` - the format
|
||||||
#: :func:`musicmouse.color.parse_color` already accepts, so the LED side needs no new
|
#: :func:`musicmouse.color.parse_color` already accepts, so the LED side needs no new
|
||||||
#: parsing and the frontend gets CSS colours for free.
|
#: 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:
|
def album_id(root: Path, folder: Path) -> str:
|
||||||
|
|||||||
@@ -8,15 +8,15 @@ quirks are not understood yet.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Final, Literal
|
from typing import Final, Literal, TypeAlias
|
||||||
|
|
||||||
__all__ = ["SECTIONS", "AlbumKind", "ArtistSource", "Section", "TitleSource", "TrackOrder"]
|
__all__ = ["SECTIONS", "AlbumKind", "ArtistSource", "Section", "TitleSource", "TrackOrder"]
|
||||||
|
|
||||||
type AlbumKind = Literal["music", "book"]
|
AlbumKind: TypeAlias = Literal["music", "book"]
|
||||||
type TrackOrder = Literal["filename", "newest_first"]
|
TrackOrder: TypeAlias = Literal["filename", "newest_first"]
|
||||||
type TitleSource = Literal["tags", "folder"]
|
TitleSource: TypeAlias = Literal["tags", "folder"]
|
||||||
type ArtistSource = Literal["tags", "folder"]
|
ArtistSource: TypeAlias = Literal["tags", "folder"]
|
||||||
type AlbumUnit = Literal["folder", "episode"]
|
AlbumUnit: TypeAlias = Literal["folder", "episode"]
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
from collections.abc import Callable, Coroutine
|
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.bus import EventBus
|
||||||
from musicmouse.events import Event
|
from musicmouse.events import Event
|
||||||
@@ -20,12 +20,14 @@ _log = logging.getLogger(__name__)
|
|||||||
|
|
||||||
__all__ = ["Reaction", "on", "register_all", "registered"]
|
__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]]] = []
|
_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)."""
|
"""Register a reaction for ``event_type`` (and any subclass of it)."""
|
||||||
|
|
||||||
def decorator(reaction: Reaction[E]) -> Reaction[E]:
|
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))
|
_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:
|
def handler(event: E) -> Any:
|
||||||
return reaction(event, app)
|
return reaction(event, app)
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from typing import TypeVar
|
||||||
|
|
||||||
from musicmouse.app import App
|
from musicmouse.app import App
|
||||||
from musicmouse.clock import Clock, FakeClock
|
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
|
raise ScriptError(f"{text!r} is not a whole number") from None
|
||||||
|
|
||||||
|
|
||||||
def _enum_by_name[T: (Button, ButtonAction, TouchButton)](
|
_EnumT = TypeVar("_EnumT", Button, ButtonAction, TouchButton)
|
||||||
enum: type[T], name: str, what: str
|
|
||||||
) -> T:
|
|
||||||
|
def _enum_by_name(enum: type[_EnumT], name: str, what: str) -> _EnumT:
|
||||||
try:
|
try:
|
||||||
return enum[name.upper()]
|
return enum[name.upper()]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Final, Literal
|
from typing import Final, Literal, TypeAlias
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, ValidationError, model_validator
|
from pydantic import BaseModel, ConfigDict, ValidationError, model_validator
|
||||||
from ruamel.yaml import YAML
|
from ruamel.yaml import YAML
|
||||||
@@ -37,9 +37,9 @@ __all__ = [
|
|||||||
"world_reward",
|
"world_reward",
|
||||||
]
|
]
|
||||||
|
|
||||||
type LessonKind = Literal["letters", "fragments", "words", "sentences"]
|
LessonKind: TypeAlias = Literal["letters", "fragments", "words", "sentences"]
|
||||||
type ModeId = Literal["dive", "bubbles", "feed", "race"]
|
ModeId: TypeAlias = Literal["dive", "bubbles", "feed", "race"]
|
||||||
type CreatureId = Literal["clownfish", "octopus", "seahorse", "turtle", "pearlmussel"]
|
CreatureId: TypeAlias = Literal["clownfish", "octopus", "seahorse", "turtle", "pearlmussel"]
|
||||||
|
|
||||||
#: In world order - see ``tippen/src/lib/aquarium.ts``, the one place this list is
|
#: 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/``.
|
#: allowed to grow, since each id names a drawing under ``public/aquarium/``.
|
||||||
|
|||||||
@@ -2,7 +2,11 @@
|
|||||||
name = "musicmouse"
|
name = "musicmouse"
|
||||||
version = "2.0.0"
|
version = "2.0.0"
|
||||||
description = "Host backend for the MusicMouse RFID music player"
|
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 = [
|
dependencies = [
|
||||||
"aiomqtt>=2.0",
|
"aiomqtt>=2.0",
|
||||||
"fastapi>=0.115",
|
"fastapi>=0.115",
|
||||||
@@ -50,7 +54,7 @@ filterwarnings = ["error"]
|
|||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
line-length = 100
|
line-length = 100
|
||||||
target-version = "py313"
|
target-version = "py311"
|
||||||
# Course material and scratch work, not part of the backend. See notebooks/README.md.
|
# Course material and scratch work, not part of the backend. See notebooks/README.md.
|
||||||
extend-exclude = ["notebooks"]
|
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"]
|
"musicmouse/services/mqtt/entity.py" = ["ARG002", "B027"]
|
||||||
|
|
||||||
[tool.mypy]
|
[tool.mypy]
|
||||||
python_version = "3.13"
|
python_version = "3.11"
|
||||||
strict = true
|
strict = true
|
||||||
files = ["musicmouse"]
|
files = ["musicmouse"]
|
||||||
warn_unreachable = true
|
warn_unreachable = true
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ class ScriptedLircd:
|
|||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._writer: asyncio.StreamWriter | None = None
|
self._writer: asyncio.StreamWriter | None = None
|
||||||
self._connected = asyncio.Event()
|
self._connected = asyncio.Event()
|
||||||
|
self._closing = asyncio.Event()
|
||||||
self.connection_count = 0
|
self.connection_count = 0
|
||||||
|
|
||||||
async def _handle(self, _reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
async def _handle(self, _reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
||||||
@@ -77,7 +78,14 @@ class ScriptedLircd:
|
|||||||
self.connection_count += 1
|
self.connection_count += 1
|
||||||
self._connected.set()
|
self._connected.set()
|
||||||
with contextlib.suppress(asyncio.CancelledError):
|
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:
|
async def wait_connected(self) -> None:
|
||||||
await self._connected.wait()
|
await self._connected.wait()
|
||||||
@@ -108,8 +116,10 @@ async def lircd() -> AsyncIterator[tuple[ScriptedLircd, asyncio.base_events.Serv
|
|||||||
finally:
|
finally:
|
||||||
server.close()
|
server.close()
|
||||||
# The scripted connection handler holds its connection open forever (it does
|
# The scripted connection handler holds its connection open forever (it does
|
||||||
# not know the test is done), so `wait_closed()` alone would hang here.
|
# not know the test is done), so `wait_closed()` alone would hang here. 3.13
|
||||||
server.close_clients()
|
# 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()
|
await server.wait_closed()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
|
from typing import TypeVar
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -58,7 +59,10 @@ def seen(bus: EventBus) -> list[Event]:
|
|||||||
return events
|
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)]
|
return [e for e in events if isinstance(e, event_type)]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import TypeVar
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -55,7 +56,10 @@ def seen(bus: EventBus) -> list[Event]:
|
|||||||
return events
|
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)]
|
return [e for e in events if isinstance(e, event_type)]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import TypeVar
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -49,7 +50,10 @@ def seen(sim: Simulation) -> list[Event]:
|
|||||||
return events
|
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)]
|
return [e for e in events if isinstance(e, event_type)]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import contextlib
|
|||||||
import json
|
import json
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import TypeAlias
|
||||||
|
|
||||||
import httpx2
|
import httpx2
|
||||||
import pytest
|
import pytest
|
||||||
@@ -27,7 +28,7 @@ from tests.websocket_harness import WebSocketSession, websocket_connect
|
|||||||
TRACK_SECONDS = 10.0
|
TRACK_SECONDS = 10.0
|
||||||
|
|
||||||
#: Shorthand: every test takes the same client type.
|
#: Shorthand: every test takes the same client type.
|
||||||
type Client = httpx2.AsyncClient
|
Client: TypeAlias = httpx2.AsyncClient
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|||||||
Reference in New Issue
Block a user