From 90b34670439cf7a2530b8e5897b97d12b96d34c4 Mon Sep 17 00:00:00 2001 From: bruno-f-cruz <7049351+bruno-f-cruz@users.noreply.github.com> Date: Mon, 10 Aug 2026 13:46:46 -0700 Subject: [PATCH 1/7] Add dependency injection pattern and typing tests --- src/packages/harp-device/README.md | 19 +++--- .../src/harp/device/client/_device.py | 63 ++++++++++++----- .../harp-serial/src/harp/serial/_serial.py | 67 ++++++++++++++++--- tests/conformance.py | 41 +++++++++++- 4 files changed, 151 insertions(+), 39 deletions(-) diff --git a/src/packages/harp-device/README.md b/src/packages/harp-device/README.md index b73ad68..70eb810 100644 --- a/src/packages/harp-device/README.md +++ b/src/packages/harp-device/README.md @@ -41,23 +41,20 @@ device address space while the module namespace is what the device adds to it. T common registers have a single definition, currently exported from `harp.device`, and are not a device, so the core register set carries no `WHO_AM_I`. -`Device` itself holds no register collection. `read`, `write` and `subscribe` take a -register class, so the module is the only place registers need to live: +Pass the module to `Device` (or `open_serial_device`) to validate identity on open and +pre-populate the register map for event parsing: ```python +from harp.device import Device from harp import behavior -# `device` is a Device opened over some transport (see harp-serial) -device.read(behavior.DigitalInputState) +with Device(transport, behavior) as device: + device.read(behavior.DigitalInputState) ``` -Identity is not yet read from the module. To validate `WhoAmI` on connect, subclass -`Device` with the same value, which is what `open` checks against today: - -```python -class MyDevice(Device): - __whoami__ = 1216 -``` +`WHO_AM_I` in the module drives the check; `0` skips it. Omitting the module skips +validation and starts with an empty register map — individual registers from +`harp.device` (e.g. `WhoAmI`, `OperationControl`) can still be used directly. A new transport is just an object implementing the `ITransport` protocol (`open`/`write`/`read`/`close`). diff --git a/src/packages/harp-device/src/harp/device/client/_device.py b/src/packages/harp-device/src/harp/device/client/_device.py index 0d0e556..de6270a 100644 --- a/src/packages/harp-device/src/harp/device/client/_device.py +++ b/src/packages/harp-device/src/harp/device/client/_device.py @@ -1,7 +1,7 @@ """Transport-agnostic Harp device base class.""" from collections.abc import Callable, Iterable -from typing import Any, ClassVar, Self, TypeVar +from typing import Any, ClassVar, Generic, Self, TypeVar, overload import logging import queue @@ -11,12 +11,14 @@ from harp.protocol._message import ParsedHarpMessage from harp.protocol._register import RegisterBase +from harp.device.schema import DeviceModuleLike from ._framer import HarpFramer from ._transport import ITransport, TransportError from harp.device.core import ( WhoAmI, ) +M = TypeVar("M", bound="DeviceModuleLike | None") P = TypeVar("P") _logger = logging.getLogger(__name__) @@ -67,30 +69,51 @@ def __exit__(self, *args: object) -> None: self.unsubscribe() -class Device: +class Device(Generic[M]): """Harp device protocol logic (framing, request/reply, register access) over an :class:`~harp.device.client.ITransport`. Must be opened before use, via ``with`` or :meth:`open`. :meth:`read`, - :meth:`write` and :meth:`subscribe` take a register class, so the device holds - no register collection of its own: a device's registers live in its module, - beside a ``REGISTER_MAP`` (see :func:`~harp.device.schema.create_device_module`, or the - ``harp-device`` README for the statically generated equivalent). + :meth:`write` and :meth:`subscribe` take a register class directly. - A subclass sets :attr:`__whoami__` to validate device identity on open - (``0x0`` skips the check):: + Pass a ``device_module`` (from :func:`~harp.device.schema.create_device_module` or a + statically generated device package) to validate device identity on open and + pre-populate the register map for event parsing:: - class Behavior(Device): - __whoami__ = 1216 + behavior = create_device_module(schema_text) + with Device(transport, behavior) as dev: + dev.read(behavior.OperationControl) + + Omitting ``device_module`` skips identity validation and starts with an empty + register map; individual registers can still be used via :meth:`read`, + :meth:`write`, and :meth:`subscribe`. """ REPLY_TIMEOUT: ClassVar[float] = 5.0 # seconds - #: Expected ``WhoAmI`` of the device this class models; ``0x0`` skips the check. - __whoami__: ClassVar[int] = 0x0 + @overload + def __init__( + self: "Device[M]", transport: ITransport, device_module: M, *, raise_on_error: bool = ... + ) -> None: ... + + @overload + def __init__( + self: "Device[None]", + transport: ITransport, + device_module: None = ..., + *, + raise_on_error: bool = ..., + ) -> None: ... - def __init__(self, transport: ITransport, *, raise_on_error: bool = True) -> None: + def __init__( + self, + transport: ITransport, + device_module: M | None = None, + *, + raise_on_error: bool = True, + ) -> None: self._transport = transport + self._device_module = device_module self.raise_on_error = raise_on_error self._framer = HarpFramer() self._pending: dict[int, queue.SimpleQueue] = {} @@ -106,6 +129,11 @@ def __init__(self, transport: ITransport, *, raise_on_error: bool = True) -> Non self._event_queue: queue.SimpleQueue[HarpMessage | None] = queue.SimpleQueue() self._event_thread: threading.Thread | None = None + @property + def module(self) -> M | None: + """The device module injected at construction, or ``None`` if not set.""" + return self._device_module + # ------------------------------------------------------------------ # Lifecycle # ------------------------------------------------------------------ @@ -130,15 +158,16 @@ def open(self) -> Self: return self def _validate_whoami(self) -> None: - """Check the device's ``WhoAmI`` against :attr:`__whoami__` (``0x0`` skips).""" - expected = self.__whoami__ + """Check the device's ``WhoAmI`` against the module (skipped if no module or ``WHO_AM_I == 0x0``).""" + if self._device_module is None: + return + expected = self._device_module.WHO_AM_I if expected == 0x0: return actual = int(self.read(WhoAmI).parsed) if actual != expected: raise RuntimeError( - f"WhoAmI mismatch: {type(self).__name__} expected 0x{expected:04x} " - f"but device reported 0x{actual:04x}." + f"WhoAmI mismatch: expected 0x{expected:04x} but device reported 0x{actual:04x}." ) def close(self) -> None: diff --git a/src/packages/harp-serial/src/harp/serial/_serial.py b/src/packages/harp-serial/src/harp/serial/_serial.py index f7d4e5b..e9b49ed 100644 --- a/src/packages/harp-serial/src/harp/serial/_serial.py +++ b/src/packages/harp-serial/src/harp/serial/_serial.py @@ -1,15 +1,17 @@ """Serial transport and factory for Harp devices.""" -from typing import TypeVar +from typing import TypeVar, overload import serial from harp.device.client import Device, TransportError - -D = TypeVar("D", bound=Device) +from harp.device.schema import DeviceModuleLike DEFAULT_BAUDRATE: int = 1_000_000 +D = TypeVar("D", bound=Device) +M = TypeVar("M", bound=DeviceModuleLike) + class SerialTransport: """A serial-port :class:`~harp.device.client.ITransport` (structural conformance).""" @@ -51,20 +53,65 @@ def close(self) -> None: self._serial.close() +@overload def open_serial_device( - device: type[D], + device_or_module: M, + *, + port: str, + baudrate: int = ..., + raise_on_error: bool = ..., +) -> Device[M]: ... + + +@overload +def open_serial_device( + device_or_module: None = ..., + *, + port: str, + baudrate: int = ..., + raise_on_error: bool = ..., +) -> Device[None]: ... + + +@overload +def open_serial_device( + device_or_module: type[D], + *, + port: str, + baudrate: int = ..., + raise_on_error: bool = ..., +) -> D: ... + + +def open_serial_device( + device_or_module: DeviceModuleLike | type[D] | None = None, *, port: str, baudrate: int = DEFAULT_BAUDRATE, raise_on_error: bool = True, -) -> D: - """Build ``device`` over a serial transport and open it. +) -> Device: + """Build a :class:`~harp.device.client.Device` over a serial transport and open it. - Like the builtin :func:`open`, the returned device is already connected; - use it directly or in a ``with`` block for guaranteed close:: + Accepts either a device module or a :class:`~harp.device.client.Device` subclass: - with open_serial_device(behavior.Device, port="COM3") as dev: + - **Module** (preferred): validates identity and pre-populates the register map:: + + import harp.device.behavior as behavior + + with open_serial_device(behavior, port="COM3") as dev: dev.read(behavior.WhoAmI) + + - **Device subclass**: instantiates the subclass directly, preserving its type:: + + with open_serial_device(MyBehavior, port="COM3") as dev: + dev.arm() # method defined on MyBehavior + + Omit the first argument for schema-free access (no identity check, empty register map). + + Like the builtin :func:`open`, the returned device is already connected; use it + directly or in a ``with`` block for guaranteed close. """ transport = SerialTransport(port, baudrate) - return device(transport, raise_on_error=raise_on_error).open() + if isinstance(device_or_module, type): + return device_or_module(transport, raise_on_error=raise_on_error).open() + return Device(transport, device_or_module, raise_on_error=raise_on_error).open() diff --git a/tests/conformance.py b/tests/conformance.py index 0e11319..f87cdd2 100644 --- a/tests/conformance.py +++ b/tests/conformance.py @@ -9,10 +9,11 @@ import numpy as np from harp.data import DatasetReader +from harp.device.client import Device, ITransport from harp.device.core import OperationControl, OperationControlPayload, WhoAmI -from harp.device.client import Device from harp.device.schema import DeviceModule, DeviceModuleLike, create_device_module from harp.protocol import ParsedHarpMessage, RegisterBase +from harp.serial import open_serial_device def schema_built_registers(yml: str) -> None: @@ -36,6 +37,44 @@ def register_writes(device: Device, payload: OperationControlPayload) -> None: assert_type(device.write(OperationControl, payload).parsed, OperationControlPayload) +def device_with_module(transport: ITransport, module: DeviceModule) -> None: + """Device constructed with a module is typed on that module.""" + device = Device(transport, module) + assert_type(device, Device[DeviceModule]) + assert_type(device.module, DeviceModule | None) + + +def device_without_module(transport: ITransport) -> None: + """Device constructed without a module is Device[None].""" + device = Device(transport) + assert_type(device, Device[None]) + assert_type(device.module, None) + + +def open_serial_device_with_module(module: DeviceModule) -> None: + """open_serial_device with a module returns Device[M].""" + device = open_serial_device(module, port="COM3") + assert_type(device, Device[DeviceModule]) + assert_type(device.module, DeviceModule | None) + + +def open_serial_device_without_module() -> None: + """open_serial_device without a module returns Device[None].""" + device = open_serial_device(port="COM3") + assert_type(device, Device[None]) + assert_type(device.module, None) + + +def open_serial_device_with_subclass() -> None: + """open_serial_device with a Device subclass preserves its type.""" + + class MyDevice(Device[DeviceModule]): + def arm(self) -> None: ... + + device = open_serial_device(MyDevice, port="COM3") + assert_type(device, MyDevice) + + def dataset_reader_accepts_either_module( schema_built: DeviceModule, generated: DeviceModuleLike ) -> None: From be27c9bb844367feeaf1360fbe9eff180322a067 Mon Sep 17 00:00:00 2001 From: bruno-f-cruz <7049351+bruno-f-cruz@users.noreply.github.com> Date: Mon, 10 Aug 2026 14:07:35 -0700 Subject: [PATCH 2/7] Fix edge case --- src/packages/harp-device/src/harp/device/client/_device.py | 4 ++-- tests/conformance.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/packages/harp-device/src/harp/device/client/_device.py b/src/packages/harp-device/src/harp/device/client/_device.py index de6270a..9713272 100644 --- a/src/packages/harp-device/src/harp/device/client/_device.py +++ b/src/packages/harp-device/src/harp/device/client/_device.py @@ -130,9 +130,9 @@ def __init__( self._event_thread: threading.Thread | None = None @property - def module(self) -> M | None: + def module(self) -> M: """The device module injected at construction, or ``None`` if not set.""" - return self._device_module + return self._device_module # type: ignore[return-value] # ------------------------------------------------------------------ # Lifecycle diff --git a/tests/conformance.py b/tests/conformance.py index f87cdd2..b379f6f 100644 --- a/tests/conformance.py +++ b/tests/conformance.py @@ -41,7 +41,7 @@ def device_with_module(transport: ITransport, module: DeviceModule) -> None: """Device constructed with a module is typed on that module.""" device = Device(transport, module) assert_type(device, Device[DeviceModule]) - assert_type(device.module, DeviceModule | None) + assert_type(device.module, DeviceModule) def device_without_module(transport: ITransport) -> None: @@ -55,7 +55,7 @@ def open_serial_device_with_module(module: DeviceModule) -> None: """open_serial_device with a module returns Device[M].""" device = open_serial_device(module, port="COM3") assert_type(device, Device[DeviceModule]) - assert_type(device.module, DeviceModule | None) + assert_type(device.module, DeviceModule) def open_serial_device_without_module() -> None: From 50c8a3b9600397a3e8552be2503c0b9433417d12 Mon Sep 17 00:00:00 2001 From: glopesdev Date: Wed, 12 Aug 2026 09:08:05 +0100 Subject: [PATCH 3/7] Bind the module type from the constructor argument The overload no longer annotates self with the class type variable, which is not allowed on __init__ and produced a pyright reportInvalidTypeVarUse warning. The module type is inferred from the device_module parameter instead, so Device(transport, behavior) still resolves to Device[BehaviorModule]. --- src/packages/harp-device/src/harp/device/client/_device.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/packages/harp-device/src/harp/device/client/_device.py b/src/packages/harp-device/src/harp/device/client/_device.py index 9713272..af5faab 100644 --- a/src/packages/harp-device/src/harp/device/client/_device.py +++ b/src/packages/harp-device/src/harp/device/client/_device.py @@ -93,7 +93,7 @@ class Device(Generic[M]): @overload def __init__( - self: "Device[M]", transport: ITransport, device_module: M, *, raise_on_error: bool = ... + self, transport: ITransport, device_module: M, *, raise_on_error: bool = ... ) -> None: ... @overload From e405d7b793e547ff90ef0166e8754a5561edf036 Mon Sep 17 00:00:00 2001 From: glopesdev Date: Wed, 12 Aug 2026 19:52:28 +0100 Subject: [PATCH 4/7] Update the device module documentation The module is consulted only for the identity check on open. It does not pre-populate the register map, so an event is parsed only for a register that has been subscribed. Examples import modules rather than names, so a register carries the portion it comes from at the point of use, core.WhoAmI beside behavior.AnalogData. --- .../create_device_module.py | 8 ++++---- src/packages/harp-device/README.md | 19 ++++++++++--------- .../src/harp/device/client/_device.py | 9 ++++----- .../harp-serial/src/harp/serial/_serial.py | 9 +++++---- 4 files changed, 23 insertions(+), 22 deletions(-) diff --git a/docs/examples/create_device_module/create_device_module.py b/docs/examples/create_device_module/create_device_module.py index 1d55e3e..9746aa2 100644 --- a/docs/examples/create_device_module/create_device_module.py +++ b/docs/examples/create_device_module/create_device_module.py @@ -29,11 +29,11 @@ df = parse_to_dataframe(AnalogData, "Behavior_44.bin") print(df.head()) -# To have the identity checked on connect, subclass `Device` with the schema's -# WhoAmI — the same one-liner a generated package ships: +# To have the identity checked on connect, pass the module itself. The check is +# driven by its `WHO_AM_I`, and `0` skips it: # -# class Behavior(Device): -# __whoami__ = behavior.WHO_AM_I +# with open_serial_device(behavior, port=SERIAL_PORT) as device: +# print("AnalogData:", device.read(AnalogData).parsed) # --- Custom interface types -------------------------------------------------- diff --git a/src/packages/harp-device/README.md b/src/packages/harp-device/README.md index 70eb810..4f16f99 100644 --- a/src/packages/harp-device/README.md +++ b/src/packages/harp-device/README.md @@ -38,23 +38,24 @@ or outside the official registry, and identity checks are skipped for it. A device module names only the registers its schema declares, so `REGISTER_MAP` is the device address space while the module namespace is what the device adds to it. The -common registers have a single definition, currently exported from `harp.device`, and +common registers have a single definition, in `harp.device.core`, and are not a device, so the core register set carries no `WHO_AM_I`. -Pass the module to `Device` (or `open_serial_device`) to validate identity on open and -pre-populate the register map for event parsing: +Pass the module to `Device` (or `open_serial_device`) to validate identity on open: ```python -from harp.device import Device -from harp import behavior +from harp.device import behavior, client, core -with Device(transport, behavior) as device: - device.read(behavior.DigitalInputState) +with client.Device(transport, behavior) as device: + device.read(core.WhoAmI) # a common register + device.read(behavior.DigitalInputState) # declared by the schema ``` `WHO_AM_I` in the module drives the check; `0` skips it. Omitting the module skips -validation and starts with an empty register map — individual registers from -`harp.device` (e.g. `WhoAmI`, `OperationControl`) can still be used directly. +validation. The module is not otherwise consulted: registers reach `read`, `write` and +`subscribe` as arguments either way, and only a subscribed register is parsed on +arrival. Common registers such as `WhoAmI` and `OperationControl` come from +`harp.device.core` and are read the same way. A new transport is just an object implementing the `ITransport` protocol (`open`/`write`/`read`/`close`). diff --git a/src/packages/harp-device/src/harp/device/client/_device.py b/src/packages/harp-device/src/harp/device/client/_device.py index af5faab..a6512f5 100644 --- a/src/packages/harp-device/src/harp/device/client/_device.py +++ b/src/packages/harp-device/src/harp/device/client/_device.py @@ -77,16 +77,15 @@ class Device(Generic[M]): :meth:`write` and :meth:`subscribe` take a register class directly. Pass a ``device_module`` (from :func:`~harp.device.schema.create_device_module` or a - statically generated device package) to validate device identity on open and - pre-populate the register map for event parsing:: + statically generated device package) to validate device identity on open:: behavior = create_device_module(schema_text) with Device(transport, behavior) as dev: dev.read(behavior.OperationControl) - Omitting ``device_module`` skips identity validation and starts with an empty - register map; individual registers can still be used via :meth:`read`, - :meth:`write`, and :meth:`subscribe`. + Omitting ``device_module`` skips that check. The module is not otherwise + consulted: registers reach :meth:`read`, :meth:`write` and :meth:`subscribe` + as arguments either way, and only a subscribed register is parsed on arrival. """ REPLY_TIMEOUT: ClassVar[float] = 5.0 # seconds diff --git a/src/packages/harp-serial/src/harp/serial/_serial.py b/src/packages/harp-serial/src/harp/serial/_serial.py index e9b49ed..b56802c 100644 --- a/src/packages/harp-serial/src/harp/serial/_serial.py +++ b/src/packages/harp-serial/src/harp/serial/_serial.py @@ -94,19 +94,20 @@ def open_serial_device( Accepts either a device module or a :class:`~harp.device.client.Device` subclass: - - **Module** (preferred): validates identity and pre-populates the register map:: + - **Module** (preferred): validates identity on open:: - import harp.device.behavior as behavior + from harp.device import behavior, core with open_serial_device(behavior, port="COM3") as dev: - dev.read(behavior.WhoAmI) + dev.read(core.WhoAmI) # a common register + dev.read(behavior.AnalogData) # declared by the schema - **Device subclass**: instantiates the subclass directly, preserving its type:: with open_serial_device(MyBehavior, port="COM3") as dev: dev.arm() # method defined on MyBehavior - Omit the first argument for schema-free access (no identity check, empty register map). + Omit the first argument for schema-free access, which skips the identity check. Like the builtin :func:`open`, the returned device is already connected; use it directly or in a ``with`` block for guaranteed close. From 950a424ba735f59a335f3d86a6c45ae4100e6feb Mon Sep 17 00:00:00 2001 From: glopesdev Date: Wed, 12 Aug 2026 22:08:50 +0100 Subject: [PATCH 5/7] Mark each device portion as typed The marker sat in harp/device/, which the split turned into a shared namespace directory, so a device package installing beside it would have collided on the same file. Each portion now carries its own. --- src/packages/harp-device/src/harp/device/{ => client}/py.typed | 0 src/packages/harp-device/src/harp/device/core/py.typed | 0 src/packages/harp-device/src/harp/device/schema/py.typed | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename src/packages/harp-device/src/harp/device/{ => client}/py.typed (100%) create mode 100644 src/packages/harp-device/src/harp/device/core/py.typed create mode 100644 src/packages/harp-device/src/harp/device/schema/py.typed diff --git a/src/packages/harp-device/src/harp/device/py.typed b/src/packages/harp-device/src/harp/device/client/py.typed similarity index 100% rename from src/packages/harp-device/src/harp/device/py.typed rename to src/packages/harp-device/src/harp/device/client/py.typed diff --git a/src/packages/harp-device/src/harp/device/core/py.typed b/src/packages/harp-device/src/harp/device/core/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/packages/harp-device/src/harp/device/schema/py.typed b/src/packages/harp-device/src/harp/device/schema/py.typed new file mode 100644 index 0000000..e69de29 From d722c6bb40427de423006dccda0f7289cf14d663 Mon Sep 17 00:00:00 2001 From: glopesdev Date: Wed, 12 Aug 2026 22:43:09 +0100 Subject: [PATCH 6/7] Name the module in the WhoAmI mismatch The error message says which module expected the identity, since now identity moved from a class attribute onto the injected module. Tests were added to cover the two paths: a WHO_AM_I of 0 skipping the read entirely, and the module property round-tripping. --- .../src/harp/device/client/_device.py | 8 +++-- tests/device/test_device.py | 34 +++++++++++++++++++ 2 files changed, 39 insertions(+), 3 deletions(-) create mode 100644 tests/device/test_device.py diff --git a/src/packages/harp-device/src/harp/device/client/_device.py b/src/packages/harp-device/src/harp/device/client/_device.py index a6512f5..3f778f9 100644 --- a/src/packages/harp-device/src/harp/device/client/_device.py +++ b/src/packages/harp-device/src/harp/device/client/_device.py @@ -158,15 +158,17 @@ def open(self) -> Self: def _validate_whoami(self) -> None: """Check the device's ``WhoAmI`` against the module (skipped if no module or ``WHO_AM_I == 0x0``).""" - if self._device_module is None: + module = self._device_module + if module is None: return - expected = self._device_module.WHO_AM_I + expected = module.WHO_AM_I if expected == 0x0: return actual = int(self.read(WhoAmI).parsed) if actual != expected: raise RuntimeError( - f"WhoAmI mismatch: expected 0x{expected:04x} but device reported 0x{actual:04x}." + f"WhoAmI mismatch: {module.__name__} expects 0x{expected:04x} " + f"but the device reported 0x{actual:04x}." ) def close(self) -> None: diff --git a/tests/device/test_device.py b/tests/device/test_device.py new file mode 100644 index 0000000..570be9c --- /dev/null +++ b/tests/device/test_device.py @@ -0,0 +1,34 @@ +import types + +from harp.device.client import Device + + +class _NullTransport: + def open(self) -> None: ... + + def close(self) -> None: ... + + def write(self, data: bytes) -> None: ... + + def read(self) -> bytes: + return b"" + + +def _module(name: str, **attrs: object) -> types.ModuleType: + mod = types.ModuleType(name) + for key, value in attrs.items(): + setattr(mod, key, value) + return mod + + +def test_whoami_of_zero_skips_the_check(): + # 0 marks an unregistered device, so opening must not read WhoAmI at all. + device = Device(_NullTransport(), _module("Unregistered", WHO_AM_I=0, REGISTER_MAP={})) + with device: + assert device.module.WHO_AM_I == 0 + + +def test_module_is_returned_by_the_property(): + module = _module("Behavior", WHO_AM_I=0, REGISTER_MAP={}) + assert Device(_NullTransport(), module).module is module + assert Device(_NullTransport()).module is None From dac23fe7f307a81d3030496a84a81cc0d07184cf Mon Sep 17 00:00:00 2001 From: glopesdev Date: Wed, 12 Aug 2026 22:44:40 +0100 Subject: [PATCH 7/7] Match a device subclass before a device module open_serial_device tries the type[D] overload first. A class carrying REGISTER_MAP and WHO_AM_I satisfies the module protocol structurally, so the module overload would otherwise win and return Device[type[Subclass]] rather than the subclass itself. --- .../harp-serial/src/harp/serial/_serial.py | 12 ++++++------ tests/conformance.py | 18 +++++++++++++++++- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/src/packages/harp-serial/src/harp/serial/_serial.py b/src/packages/harp-serial/src/harp/serial/_serial.py index b56802c..deb9ab6 100644 --- a/src/packages/harp-serial/src/harp/serial/_serial.py +++ b/src/packages/harp-serial/src/harp/serial/_serial.py @@ -55,32 +55,32 @@ def close(self) -> None: @overload def open_serial_device( - device_or_module: M, + device_or_module: type[D], *, port: str, baudrate: int = ..., raise_on_error: bool = ..., -) -> Device[M]: ... +) -> D: ... @overload def open_serial_device( - device_or_module: None = ..., + device_or_module: M, *, port: str, baudrate: int = ..., raise_on_error: bool = ..., -) -> Device[None]: ... +) -> Device[M]: ... @overload def open_serial_device( - device_or_module: type[D], + device_or_module: None = ..., *, port: str, baudrate: int = ..., raise_on_error: bool = ..., -) -> D: ... +) -> Device[None]: ... def open_serial_device( diff --git a/tests/conformance.py b/tests/conformance.py index b379f6f..c2df806 100644 --- a/tests/conformance.py +++ b/tests/conformance.py @@ -5,7 +5,7 @@ type fails the build rather than being noticed downstream. """ -from typing import Any, assert_type +from typing import Any, ClassVar, assert_type import numpy as np from harp.data import DatasetReader @@ -83,3 +83,19 @@ def dataset_reader_accepts_either_module( reader = DatasetReader(generated, "session.harp") reader.read(WhoAmI) reader.read(44) + + +def open_serial_device_prefers_the_subclass_overload() -> None: + """A Device subclass is matched as a subclass even when it looks like a module. + + type[D] is narrower than the structural module overload, so it has to come first: + a class carrying REGISTER_MAP and WHO_AM_I satisfies DeviceModuleLike too, and the + module overload would otherwise win and return Device[type[Hybrid]]. + """ + + class Hybrid(Device[None]): + REGISTER_MAP: ClassVar[dict[int, type[RegisterBase[Any]]]] = {} + WHO_AM_I: ClassVar[int] = 1216 + + device = open_serial_device(Hybrid, port="COM3") + assert_type(device, Hybrid)