diff --git a/docs/requirements.txt b/docs/requirements.txt index a9cbea0..ce4ed70 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,6 +1,6 @@ furo==2024.8.6 -sphinx==8.0.2 -sphinx_autodoc_typehints==2.4.4 +sphinx==8.1.2 +sphinx_autodoc_typehints==2.5.0 sphinx_copybutton==0.5.2 sphinx_inline_tabs==2023.4.21 sphinxext_opengraph==0.9.1 diff --git a/tests/conftest.py b/tests/conftest.py index 951a523..e153254 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,18 +1,15 @@ from __future__ import annotations -import asyncio import os -from asyncio import create_task, start_unix_server from tempfile import TemporaryDirectory from typing import TYPE_CHECKING import pytest from anyio.from_thread import start_blocking_portal -from .emulator import EmulatedDevice +from .emulator import EmulatorServer if TYPE_CHECKING: - from asyncio import StreamReader, StreamWriter from collections.abc import Iterator @@ -21,55 +18,17 @@ def anyio_backend() -> str: return 'asyncio' -class FakeServer: - """Fake server used to spawn fake LD2410 devices.""" - - def __init__(self, socket_path: str) -> None: - """Create a fake unix socket server used to emulate a real device.""" - self.shutdown = asyncio.Event() - self.socket_path = socket_path - self.started = asyncio.Event() - self.stopped = asyncio.Event() - self._tasks = [] - - async def handle_connection(self, reader: StreamReader, writer: StreamWriter) -> None: - """Handle a new connection, which means a brand new device.""" - self._tasks.append(asyncio.current_task()) - async with EmulatedDevice(reader, writer) as device: - await device.wait_for_closing() - - async def serve(self) -> None: - """Serve requests until we are told to exit.""" - server = await start_unix_server(self.handle_connection, self.socket_path) - try: - async with server: - self.started.set() - task_wait = create_task(self.shutdown.wait()) - task_serve = create_task(server.serve_forever()) - done, pending = await asyncio.wait( - (task_wait, task_serve), - return_when=asyncio.FIRST_COMPLETED, - ) - for task in pending: - task.cancel() - for task in self._tasks: - task.cancel() - finally: - self.stopped.set() - - -@pytest.fixture(scope='class') -def fake_device_socket() -> Iterator[str]: - """Run a real server in a separate thread.""" +@pytest.fixture(scope='session') +def emulation_server() -> Iterator[str]: + """Run an emulation server in a separate thread.""" with TemporaryDirectory() as tmp: tmp_socket = os.path.join(tmp, 'server.sock') + with start_blocking_portal(backend='asyncio') as portal: - server = FakeServer(tmp_socket) - portal.start_task_soon(server.serve) + server = EmulatorServer(tmp_socket) + future, _ = portal.start_task(server.run) try: - portal.call(server.started.wait) yield server.socket_path finally: - portal.call(server.shutdown.set) - portal.call(server.stopped.wait) + future.cancel() diff --git a/tests/emulator/__init__.py b/tests/emulator/__init__.py index c57eeb7..4b4aee0 100644 --- a/tests/emulator/__init__.py +++ b/tests/emulator/__init__.py @@ -2,9 +2,11 @@ from .device import EmulatedDevice from .models import EmulatorCode, EmulatorCommand +from .server import EmulatorServer __all__ = [ 'EmulatedDevice', 'EmulatorCode', 'EmulatorCommand', + 'EmulatorServer', ] diff --git a/tests/emulator/device.py b/tests/emulator/device.py index b99afe0..2356041 100644 --- a/tests/emulator/device.py +++ b/tests/emulator/device.py @@ -3,7 +3,7 @@ import asyncio import json import logging -from asyncio import Event +from asyncio import Event, Lock from contextlib import AsyncExitStack, suppress from dataclasses import asdict, is_dataclass from enum import IntEnum @@ -62,7 +62,7 @@ async def _check_configuration_mode(self, command: Container[Any]) -> bytes: class EmulatedDevice: - """Emulate a fake device for test purpose.""" + """Emulate a fake LD2410 device for test purpose.""" def __init__(self, reader: StreamReader, writer: StreamWriter) -> None: """Create a new emulated LD2410 device from a generic reader/writer.""" @@ -103,7 +103,7 @@ def __init__(self, reader: StreamReader, writer: StreamWriter) -> None: self._status = DeviceStatus() self._reader = reader self._writer = writer - self._write_lock = asyncio.Lock() + self._write_lock = Lock() def _build_reply( self, @@ -358,7 +358,7 @@ async def _command_task(self) -> None: self._closing.set() async def _report_task(self) -> None: - """Report tasks regularly.""" + """Generate sensor reports regularly.""" while True: await asyncio.sleep(0.1) try: diff --git a/tests/emulator/models.py b/tests/emulator/models.py index 76afa2c..c37560e 100644 --- a/tests/emulator/models.py +++ b/tests/emulator/models.py @@ -44,27 +44,54 @@ class EmulatorCode(IntEnum): - """All kind of emulator-specific commands.""" + """ + Emulator-specific commands codes. + These are used to control the behavior of the emulator through + a REPORT frame sent from the client to the fake device. + + """ + + #: Tell the emulator to disconnect immediately. DISCONNECT_NOW = auto() + + #: Tell the emulator to disconnect after the next command is received. DISCONNECT_AFTER_COMMAND = auto() + + #: Generate and push a corrupted frame immediately. GENERATE_CORRUPTED_FRAME = auto() + + #: Generate and push a corrupted reply frame. GENERATE_CORRUPTED_COMMAND = auto() + + #: Generate and push an unsolicited reply frame immediately. GENERATE_SPURIOUS_REPLY = auto() + + #: The next DISTANCE_RESOLUTION_GET command will receive an invalid resolution index. RETURN_INVALID_RESOLUTION = auto() @dataclass class EmulatorCommand: - """A command sent to the emulator.""" + """ + Emulator-specific commands. + + These are used to control the behavior of the emulator through a REPORT + frame sent from the client to the fake device. ``data`` is currently + unused because no emulator require additional data. + """ + + #: Command core sent to the emulator. code: EmulatorCode + + #: Additional parameters for the command code (unused for now). data: Mapping[str, Any] | None = None @dataclass class DeviceStatus: - """Contains the internal state of a device.""" + """Internal state of the emulated device.""" baud_rate = BaudRateIndex.RATE_256000 configuring: bool = False diff --git a/tests/emulator/server.py b/tests/emulator/server.py new file mode 100644 index 0000000..9c885eb --- /dev/null +++ b/tests/emulator/server.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from asyncio import start_unix_server +from typing import TYPE_CHECKING + +from anyio import TASK_STATUS_IGNORED + +from .device import EmulatedDevice + +if TYPE_CHECKING: + from asyncio import StreamReader, StreamWriter + + from anyio.abc import TaskStatus + + +class EmulatorServer: + """Server used to emulate LD2410 devices on top of an unix socket.""" + + def __init__(self, socket_path: str) -> None: + """ + Create an emulation server for LD2410 devices on top of an unix socket. + + Args: + socket_path: path to the unix socket + + """ + self._socket_path = socket_path + + @property + def socket_path(self) -> str: + """Get the server's unix socket path.""" + return self._socket_path + + async def _handle_connection(self, reader: StreamReader, writer: StreamWriter) -> None: + """ + Handle a new connection for a new emulated device. + + This coroutine runs in a new :class:`asyncio.Task` as stated in the documentation + from :meth:`start_unix_server`, which means that we don't have to handle anything + else than the emulated device here. + + Args: + reader: the read part of the connection stream + writer: the write part of the connection stream + + """ + async with EmulatedDevice(reader, writer) as device: + await device.wait_for_closing() + + async def run(self, *, task_status: TaskStatus[None] = TASK_STATUS_IGNORED) -> None: + """ + Serve incoming connection requests forever. + + Connection server is closed when the underlying task is cancelled. + + Keyword Args: + task_status: anyio specific used to tell when we are ready to serve + + """ + server = await start_unix_server(self._handle_connection, self._socket_path) + async with server: + task_status.started() + await server.serve_forever() diff --git a/tests/requirements-testing.txt b/tests/requirements-testing.txt index 748c515..1f0d696 100644 --- a/tests/requirements-testing.txt +++ b/tests/requirements-testing.txt @@ -1,5 +1,5 @@ anyio==4.6.0 -coverage==7.6.1 +coverage==7.6.2 pytest-cov==5.0.0 pytest-timeout==2.3.1 pytest==8.3.3 diff --git a/tests/test_ld2410.py b/tests/test_ld2410.py index 1d79f8c..efa1a99 100644 --- a/tests/test_ld2410.py +++ b/tests/test_ld2410.py @@ -25,7 +25,7 @@ from .emulator import EmulatorCode, EmulatorCommand -# All test coroutines will be treated as marked. +# All test coroutines will be treated as marked for anyio. pytestmark = pytest.mark.anyio @@ -47,9 +47,9 @@ async def send_emulator_command(self, command: EmulatorCommand) -> None: @pytest.fixture -def raw_device(fake_device_socket): +def raw_device(emulation_server): """A raw non-entered device.""" - return FakeLD2410(fake_device_socket) + return FakeLD2410(emulation_server) @pytest.fixture