From ea2cae561906f728cd51be002856824f0d369658 Mon Sep 17 00:00:00 2001 From: glopesdev Date: Fri, 7 Aug 2026 16:33:31 +0100 Subject: [PATCH 1/7] Emit runtime device interface as a module create_module replaces create_device and returns a module instead of a Device subclass. The schema registers, merged with the common Harp ones, sit at module level keyed by name, beside a REGISTER_MAP keyed by address and the schema identity as WHO_AM_I, so a schema-built device is reached the same way a generated package is, with behavior.AnalogData resolving the same class as behavior.REGISTER_MAP[44]. The module is not registered in sys.modules and has to be bound by the caller. A device register now displaces the common one it collides with from both views at once, by address or by name, so a name and the address map can no longer disagree about what sits at an address. Device carries no REGISTER_MAP of its own, and DatasetReader takes a module in place of a device class. --- README.md | 13 +- docs/api/device.md | 2 +- .../create_module.md} | 36 ++--- .../create_module.py} | 35 +++-- docs/examples/index.md | 2 +- docs/examples/read_dataset/read_dataset.py | 16 +-- mkdocs.yml | 2 +- src/packages/harp-data/README.md | 10 +- .../harp-data/src/harp/data/_dataset.py | 52 +++---- src/packages/harp-device/README.md | 32 +++-- .../harp-device/src/harp/device/__init__.py | 4 +- .../harp-device/src/harp/device/_device.py | 17 ++- .../src/harp/device/_emit_device.py | 79 ++++++++--- tests/data/test_dataset.py | 128 ++++++++++-------- tests/device/test_create_module.py | 127 +++++++++++++++++ tests/device/test_device_emit.py | 66 --------- 16 files changed, 384 insertions(+), 237 deletions(-) rename docs/examples/{create_device/create_device.md => create_module/create_module.md} (55%) rename docs/examples/{create_device/create_device.py => create_module/create_module.py} (51%) create mode 100644 tests/device/test_create_module.py delete mode 100644 tests/device/test_device_emit.py diff --git a/README.md b/README.md index c7b3645..8d4d11b 100644 --- a/README.md +++ b/README.md @@ -80,16 +80,17 @@ everything = reader.read_all() # {register_name: DataFrame} ``` Both paths are driven by a device schema. If you have only a `device.yml` and no -pre-generated package, `create_device` compiles it into a typed `Device` at runtime — -no code-generation step — which is exactly what `create_dataset_reader` does under -the hood: +pre-generated package, `create_module` compiles it into a module of register classes +at runtime — no code-generation step — which is exactly what `create_dataset_reader` +does under the hood: ```python from pathlib import Path -from harp.device import create_device +from harp.device import create_module -Behavior = create_device(Path("device.yml").read_text()) -AnalogData = Behavior.REGISTER_MAP[44] # registers are reached by address +behavior = create_module(Path("device.yml").read_text()) +AnalogData = behavior.AnalogData # registers are reached by name... +assert behavior.REGISTER_MAP[44] is AnalogData # ...or by address ``` See the [Examples](https://harp-tech.org/pyharp/examples/) for the full walkthroughs, diff --git a/docs/api/device.md b/docs/api/device.md index 652e4c7..98eabe5 100644 --- a/docs/api/device.md +++ b/docs/api/device.md @@ -3,7 +3,7 @@ --- ::: harp.device.Device -::: harp.device.create_device +::: harp.device.create_module ::: harp.device.parse_device_schema ::: harp.device.ConverterContext ::: harp.device.HarpFramer diff --git a/docs/examples/create_device/create_device.md b/docs/examples/create_module/create_module.md similarity index 55% rename from docs/examples/create_device/create_device.md rename to docs/examples/create_module/create_module.md index 15907d9..41a37c1 100644 --- a/docs/examples/create_device/create_device.md +++ b/docs/examples/create_module/create_module.md @@ -1,24 +1,27 @@ -# Generating a Device from a Schema +# Generating Registers from a Schema -This example demonstrates how to turn a Harp `device.yml` into a typed -[`Device`](../../api/device.md) at runtime with `create_device`, without a -code-generation step. This is the quickest way to get started when you have only a -device's schema and no pre-generated package for it. +This example demonstrates how to turn a Harp `device.yml` into a module of register +classes at runtime with `create_module`, without a code-generation step. This is the +quickest way to get started when you have only a device's schema and no +pre-generated package for it. -The compiled device exposes its registers through `REGISTER_MAP` (keyed by address) -and carries the device's `__whoami__` identity. From there it works exactly like a -pre-generated device class — drive it over a transport to talk to hardware, or use -its register classes to decode recorded data. +A generated device package is a module: register classes at module level, with a +`REGISTER_MAP` beside them keyed by address. `create_module` builds that same shape +from a schema, so registers are reached the same way either way — by name +(`behavior.AnalogData`) or by address (`behavior.REGISTER_MAP[44]`). From there they +work exactly like a pre-generated package's: drive them over a transport with +[`Device`](../../api/device.md) to talk to hardware, or use them to decode recorded +data. ## When to use runtime generation -`create_device` trades statically generated device packages for schema-driven +`create_module` trades statically generated device packages for schema-driven convenience. It's worth understanding what that buys you and what it costs. **You gain:** - **No build step.** A `device.yml` — even one you just pulled off a device — - becomes a working device in a single call. There's nothing to generate, install, + becomes a working module in a single call. There's nothing to generate, install, or keep in sync with the schema. - **Coverage for any device.** You don't need a published package for the device; unreleased, custom, or one-off schemas work immediately. @@ -27,9 +30,10 @@ convenience. It's worth understanding what that buys you and what it costs. **You give up:** -- **Named, typed access.** Registers are reached by address (`REGISTER_MAP[44]`), - not as importable, autocompleting classes (`from harp_behavior import AnalogData`). - You lose editor discovery and static type checking of register names. +- **Static typing and autocomplete.** The names exist only once the module is built, + so an editor can't offer them and a type checker can't verify them. A generated + package is a real module on disk, so both work. The module also isn't in + `sys.modules`, so you bind it yourself rather than `import`-ing it. - **Generator naming conventions.** Identifiers are kept verbatim from the yml (`AnalogInput0`, `DIO0`) rather than the C# generator's snake_case fields and `UPPER_SNAKE` enum members, so code written against a generated package won't line @@ -40,7 +44,7 @@ convenience. It's worth understanding what that buys you and what it costs. For shipped, widely-used devices a pre-generated package from the [Harp C# generator](https://github.com/harp-tech/generators) remains the authoritative choice — better editor support and static typing. Reach for -`create_device` when you want to go from a schema to working code with no +`create_module` when you want to go from a schema to working code with no generation step. !!! warning @@ -48,6 +52,6 @@ generation step. ```python -[](./create_device.py) +[](./create_module.py) ``` diff --git a/docs/examples/create_device/create_device.py b/docs/examples/create_module/create_module.py similarity index 51% rename from docs/examples/create_device/create_device.py rename to docs/examples/create_module/create_module.py index 11ec1b8..56a25d6 100644 --- a/docs/examples/create_device/create_device.py +++ b/docs/examples/create_module/create_module.py @@ -1,23 +1,26 @@ from pathlib import Path from harp.data import parse_to_dataframe -from harp.device import create_device +from harp.device import Device, create_module from harp.serial import open_serial_device SERIAL_PORT = "/dev/ttyUSB0" # or "COMx" in Windows ("x" is the number of the serial port) -# `create_device` compiles a Harp `device.yml` into a typed `Device` subclass at +# `create_module` compiles a Harp `device.yml` into a module of register classes at # runtime — no code-generation step. This is the quickest way to work with a device # when you don't have a pre-generated package for it: point it at the schema and you -# get the device's registers (keyed by address) plus its identity. -Behavior = create_device(Path("device.yml").read_text()) - -print("WhoAmI:", Behavior.__whoami__) # device identity, taken from the schema -AnalogData = Behavior.REGISTER_MAP[44] # registers are reached by address - -# The generated device behaves like any other `Device` class. Talk to hardware over -# a transport — `read`/`write` take a register class: -with open_serial_device(Behavior, port=SERIAL_PORT) as device: +# get the same shape a generated package has, registers at module level beside a +# `REGISTER_MAP`. +behavior = create_module(Path("device.yml").read_text()) + +print("WhoAmI:", behavior.WHO_AM_I) # device identity, taken from the schema +AnalogData = behavior.AnalogData # registers are reached by name... +assert behavior.REGISTER_MAP[44] is AnalogData # ...or by address + +# Registers are ordinary register classes, so they drive `read`/`write` on any +# `Device` over a transport. The schema carries no Python code, so there is no +# generated device class here: use `Device` itself. +with open_serial_device(Device, port=SERIAL_PORT) as device: print("AnalogData:", device.read(AnalogData).parsed) # ...or use the same register classes to decode a recorded binary dump into a @@ -25,14 +28,20 @@ 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: +# +# class Behavior(Device): +# __whoami__ = behavior.WHO_AM_I + # --- Custom interface types -------------------------------------------------- # A register with a custom `interfaceType` needs a converter so its field decodes # to the right Python type. Pass it via `converters=`, keyed by "Converter": # -# Behavior = create_device(yml_text, converters={"DataConverter": DataConverter()}) +# behavior = create_module(yml_text, converters={"DataConverter": DataConverter()}) # # An unresolved custom type raises `UnknownConverterError`; pass `strict=False` to # decode it natively instead. `exclude_private=True` (the default) drops registers # marked `private` in the schema. If you only want the parsed schema model rather -# than a device, `parse_device_schema(yml_text)` returns that directly. +# than a module, `parse_device_schema(yml_text)` returns that directly. diff --git a/docs/examples/index.md b/docs/examples/index.md index ff83850..f2e4c86 100644 --- a/docs/examples/index.md +++ b/docs/examples/index.md @@ -4,7 +4,7 @@ This section contains some examples to help you get started with `harp`. Working from a device schema: -- [Generating a Device from a Schema](./create_device/create_device.md) - compile a `device.yml` into a typed device at runtime with `create_device`. +- [Generating Registers from a Schema](./create_module/create_module.md) - compile a `device.yml` into a module of register classes at runtime with `create_module`. Talking to a device: diff --git a/docs/examples/read_dataset/read_dataset.py b/docs/examples/read_dataset/read_dataset.py index 1e9b834..390e14f 100644 --- a/docs/examples/read_dataset/read_dataset.py +++ b/docs/examples/read_dataset/read_dataset.py @@ -12,8 +12,8 @@ # ┗ 📜 device.yml # # `create_dataset_reader` does the right thing: it finds `device.yml` inside the -# folder, builds the device that knows how to decode each register, and hands back -# a reader ready to go. +# folder, builds the module of register classes that knows how to decode each +# register, and hands back a reader ready to go. reader = create_dataset_reader("session.harp") # Read one register into a DataFrame — by register class (any register in the @@ -34,13 +34,13 @@ absolute = reader.read(44, epoch=REFERENCE_EPOCH) print(absolute.index[:3]) -# --- Already have a device class? ------------------------------------------- -# A pre-generated device package, or one you built yourself with `create_device`, -# can drive the reader directly — construct `DatasetReader(Device, folder)`: +# --- Already have a device module? ------------------------------------------- +# A pre-generated device package, or one you built yourself with `create_module`, +# can drive the reader directly — construct `DatasetReader(module, folder)`: # # from harp.data import DatasetReader -# from harp.device import create_device +# from harp.device import create_module # from pathlib import Path # -# Behavior = create_device((Path("session.harp") / "device.yml").read_text()) -# reader = DatasetReader(Behavior, "session.harp") +# behavior = create_module((Path("session.harp") / "device.yml").read_text()) +# reader = DatasetReader(behavior, "session.harp") diff --git a/mkdocs.yml b/mkdocs.yml index c4a63b4..87b7511 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -73,7 +73,7 @@ nav: - Home: index.md - Examples: - examples/index.md - - Generating a Device from a Schema: examples/create_device/create_device.md + - Generating Registers from a Schema: examples/create_module/create_module.md - Getting Device Info: examples/get_info/get_info.md - Read and Write from Registers: examples/read_and_write_from_registers/read_and_write_from_registers.md - Subscribing to Events: examples/subscribing_to_events/subscribing_to_events.md diff --git a/src/packages/harp-data/README.md b/src/packages/harp-data/README.md index 80045b8..ca30744 100644 --- a/src/packages/harp-data/README.md +++ b/src/packages/harp-data/README.md @@ -37,16 +37,16 @@ df = reader.read(44) # by address everything = reader.read_all() # {register_name: DataFrame} ``` -Already have a device class (e.g. a pre-generated package, or one built with -`create_device`)? Drive `DatasetReader` with it directly: +Already have a device module (e.g. a pre-generated package, or one built with +`create_module`)? Drive `DatasetReader` with it directly: ```python from pathlib import Path from harp.data import DatasetReader -from harp.device import create_device +from harp.device import create_module -Behavior = create_device((Path("session.harp") / "device.yml").read_text()) -reader = DatasetReader(Behavior, "session.harp") +behavior = create_module((Path("session.harp") / "device.yml").read_text()) +reader = DatasetReader(behavior, "session.harp") ``` Timestamps are auto-detected per register and placed on the DataFrame index diff --git a/src/packages/harp-data/src/harp/data/_dataset.py b/src/packages/harp-data/src/harp/data/_dataset.py index bad29a6..3173ebd 100644 --- a/src/packages/harp-data/src/harp/data/_dataset.py +++ b/src/packages/harp-data/src/harp/data/_dataset.py @@ -3,10 +3,11 @@ from datetime import datetime from os import PathLike from pathlib import Path +from types import ModuleType from typing import Any import pandas as pd -from harp.device import Device, create_device +from harp.device import create_module from harp.protocol import RegisterBase from harp.protocol._constants import _TIMESTAMP_FLAG @@ -34,17 +35,18 @@ def default_file_resolver(root: Path, name: str) -> dict[int, list[Path]]: class DatasetReader: """Reader over a de-multiplexed Harp dataset folder. - Construct from a generated device and a dataset folder, then read a register's + Construct from a device module and a dataset folder, then read a register's frames into a DataFrame by register class or by address:: - reader = DatasetReader(Behavior, "session.harp") - df = reader.read(AnalogData) # by register class - df = reader.read(44) # by address - everything = reader.read_all() # {register_name: DataFrame} + reader = DatasetReader(behavior, "session.harp") + df = reader.read(behavior.AnalogData) # by register class + df = reader.read(44) # by address + everything = reader.read_all() # {register_name: DataFrame} - ``device`` is a generated :class:`~harp.device.Device` subclass; its - ``REGISTER_MAP`` and class name are read on demand. ``name`` overrides the - ```` file prefix, which defaults to the device class name. + ``module`` is a device module -- a generated device package, or one built from a + schema with :func:`~harp.device.create_module`. Its ``REGISTER_MAP`` and + ``__name__`` are read on demand. ``name`` overrides the ```` file + prefix, which defaults to the module name. File resolution defaults to the Harp file format: ``_
.bin`` and, when a register was logged as several ``_
_.bin`` chunks, @@ -54,13 +56,13 @@ class DatasetReader: def __init__( self, - device: type[Device], + module: ModuleType, root: str | PathLike[str], *, name: str | None = None, resolver: FileNameResolver = default_file_resolver, ) -> None: - self._device = device + self._module = module self._root = Path(root) self._name_override = name self._resolver = resolver @@ -72,19 +74,19 @@ def root(self) -> Path: return self._root @property - def device(self) -> type[Device]: - """The generated device this reader parses against.""" - return self._device + def module(self) -> ModuleType: + """The device module this reader parses against.""" + return self._module @property def name(self) -> str: """The ```` prefix used to match binary files.""" - return self._name_override or self._device.__name__ + return self._name_override or self._module.__name__ @property def registers(self) -> Mapping[int, type[RegisterBase[Any]]]: - """The device's address -> register-class map.""" - return self._device.REGISTER_MAP + """The module's address -> register-class map (its ``REGISTER_MAP``).""" + return self._module.REGISTER_MAP @property def files(self) -> Mapping[int, list[Path]]: @@ -196,24 +198,24 @@ def create_dataset_reader( """Build a :class:`DatasetReader` for a dataset folder, device and all. Convenience wrapper that finds the device schema inside ``root`` (``device.yml`` - by default), generates a device from it with :func:`~harp.device.create_device`, - and returns a reader ready to :meth:`~DatasetReader.read`:: + by default), builds its module with :func:`~harp.device.create_module`, and + returns a reader ready to :meth:`~DatasetReader.read`:: reader = create_dataset_reader("session.harp") df = reader.read(44) ``schema`` points at the schema file explicitly when it isn't ``root/device.yml``. - ``converters`` and ``strict`` are forwarded to :func:`~harp.device.create_device` + ``converters`` and ``strict`` are forwarded to :func:`~harp.device.create_module` for custom ``interfaceType`` decoding; ``name`` and ``resolver`` are forwarded to - :class:`DatasetReader`. Use ``DatasetReader(device, root)`` directly when you - already have a (e.g. pre-generated) device class. + :class:`DatasetReader`. Use ``DatasetReader(module, root)`` directly when you + already have a (e.g. pre-generated) device module. """ root_path = Path(root) schema_path = Path(schema) if schema is not None else root_path / DEVICE_SCHEMA_FILENAME if not schema_path.is_file(): raise FileNotFoundError( f"No device schema at '{schema_path}'. Pass schema= to point at a device.yml, " - f"or build the device yourself and use DatasetReader(device, root)." + f"or build the module yourself and use DatasetReader(module, root)." ) - device = create_device(schema_path.read_text(), converters=converters, strict=strict) - return DatasetReader(device, root_path, name=name, resolver=resolver) + module = create_module(schema_path.read_text(), converters=converters, strict=strict) + return DatasetReader(module, root_path, name=name, resolver=resolver) diff --git a/src/packages/harp-device/README.md b/src/packages/harp-device/README.md index 3221dd8..7059e86 100644 --- a/src/packages/harp-device/README.md +++ b/src/packages/harp-device/README.md @@ -19,8 +19,9 @@ device.write(OperationControl, payload) # write a register ## Extending for a specific device -Downstream (often generated) packages add their registers and spread the core -`REGISTER_MAP`, and may set `__whoami__` for identity validation on connect: +A device's registers live in its module. Downstream (often generated) packages +declare them at module level and spread the core `REGISTER_MAP` beside them, and may +subclass `Device` to set `__whoami__` for identity validation on connect: ```python from harp.device import Device, REGISTER_MAP as _CORE_REGISTER_MAP @@ -31,30 +32,39 @@ class MyDevice(Device): REGISTER_MAP = {**_CORE_REGISTER_MAP, 32: DigitalInputState, ...} ``` +`Device` itself holds no register collection: `read`, `write` and `subscribe` take a +register class, so the module namespace is the only place registers need to live. + A new transport is just an object implementing the `ITransport` protocol (`open`/`write`/`read`/`close`). -## Generating a device from a `device.yml` +## Generating registers from a `device.yml` -If you don't have a pre-generated device package, `create_device` builds a -`Device` from Harp `device.yml` text. Registers are reached by address through -`REGISTER_MAP`; field and enum names come from the yml verbatim. +If you don't have a pre-generated device package, `create_module` builds the same +shape at runtime from Harp `device.yml` text: register classes at module level, a +`REGISTER_MAP` beside them, and the schema's identity as `WHO_AM_I`. Field and enum +names come from the yml verbatim. ```python from pathlib import Path -from harp.device import create_device +from harp.device import create_module -Behavior = create_device(Path("device.yml").read_text()) -reg = Behavior.REGISTER_MAP[44] +behavior = create_module(Path("device.yml").read_text()) +reg = behavior.AnalogData # by name +reg = behavior.REGISTER_MAP[44] # or by address ``` +The module is not registered in `sys.modules`, so bind it yourself rather than +`import`-ing it; names come from the schema at runtime, so they don't autocomplete +and aren't statically checked, which is what a generated package on disk buys you. + For a custom `interfaceType`, pass its converter via `converters=` (keyed by `{InterfaceType}Converter` / `{MemberName}Converter`); an unresolved custom type raises `UnknownConverterError`, or pass `strict=False` to decode it natively: ```python -create_device(yml_text, converters={"DataConverter": DataConverter()}) +create_module(yml_text, converters={"DataConverter": DataConverter()}) ``` `parse_device_schema(yml_text)` is also public if you just want the parsed -schema model (registers, masks, and optional device identity) without a `Device`. +schema model (registers, masks, and optional device identity) without a module. diff --git a/src/packages/harp-device/src/harp/device/__init__.py b/src/packages/harp-device/src/harp/device/__init__.py index d352a19..1be1efd 100644 --- a/src/packages/harp-device/src/harp/device/__init__.py +++ b/src/packages/harp-device/src/harp/device/__init__.py @@ -1,5 +1,5 @@ from ._device import Device, EventHandler, Subscription -from ._emit_device import create_device +from ._emit_device import create_module from ._framer import HarpFramer from ._registers import ( AssemblyVersion, @@ -34,7 +34,7 @@ "Device", "EventHandler", "Subscription", - "create_device", + "create_module", "parse_device_schema", "ConverterContext", "HarpFramer", diff --git a/src/packages/harp-device/src/harp/device/_device.py b/src/packages/harp-device/src/harp/device/_device.py index d765da3..45a5479 100644 --- a/src/packages/harp-device/src/harp/device/_device.py +++ b/src/packages/harp-device/src/harp/device/_device.py @@ -71,9 +71,17 @@ class Device: """Harp device protocol logic (framing, request/reply, register access) over an :class:`~harp.device.ITransport`. - Must be opened before use, via ``with`` or :meth:`open`. Subclasses add - register class attributes and set :attr:`__whoami__` to validate device - identity on open (``0x0`` skips the check). + 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.create_module`, or the + ``harp-device`` README for the statically generated equivalent). + + A subclass sets :attr:`__whoami__` to validate device identity on open + (``0x0`` skips the check):: + + class Behavior(Device): + __whoami__ = 1216 """ REPLY_TIMEOUT: ClassVar[float] = 5.0 # seconds @@ -81,9 +89,6 @@ class Device: #: Expected ``WhoAmI`` of the device this class models; ``0x0`` skips the check. __whoami__: ClassVar[int] = 0x0 - #: Address -> register class; empty on the base, overridden by generated devices. - REGISTER_MAP: ClassVar[dict[int, type[RegisterBase[Any]]]] = {} - def __init__(self, transport: ITransport, *, raise_on_error: bool = True) -> None: self._transport = transport self.raise_on_error = raise_on_error diff --git a/src/packages/harp-device/src/harp/device/_emit_device.py b/src/packages/harp-device/src/harp/device/_emit_device.py index 82f43b4..f5afafb 100644 --- a/src/packages/harp-device/src/harp/device/_emit_device.py +++ b/src/packages/harp-device/src/harp/device/_emit_device.py @@ -1,37 +1,84 @@ +"""Emit a Python module of register classes from a device schema. + +A generated device package is already a module: register classes at module level +and a ``REGISTER_MAP`` beside them (see the ``harp-device`` README). +:func:`create_module` builds that same shape at runtime from a ``device.yml``, so a +schema-driven device and a generated one are reached the same way, by name from the +module or by address through ``REGISTER_MAP``. +""" + +import types from typing import Any, Mapping, Optional, Union -from ._device import Device +from harp.protocol import RegisterBase + from ._register_map import REGISTER_MAP as CORE_REGISTER_MAP from ._schema import create_registers, parse_device_schema from ._schema._emit import ConverterValue from ._schema._model import DeviceModel +#: Module name used when the schema carries no ``device`` header. +_DEFAULT_NAME = "Device" -def create_device( + +def create_module( source: Union[str, DeviceModel], *, name: Optional[str] = None, converters: Optional[Mapping[str, ConverterValue]] = None, strict: bool = True, exclude_private: bool = True, -) -> type[Device]: - """Emit a :class:`Device` subclass from a device schema. - - The returned class exposes its registers through the ``REGISTER_MAP`` class - attribute (address -> register class) and carries ``__whoami__`` from the schema - (``0x0`` when absent). The device's registers are spread on top of the core common - map; on an address clash the device's register wins. ``exclude_private=True`` drops - registers whose DSL ``visibility`` is ``private``. A header-less register - fragment yields a device with no ``device`` name (falls back to ``"Device"``). +) -> types.ModuleType: + """Emit a module of register classes from a device schema. + + The module holds the schema's registers merged with the common Harp registers, + each reachable by name (``behavior.AnalogData``), plus: + + * ``REGISTER_MAP``, the address -> register-class map; + * ``WHO_AM_I``, the schema's identity (``0`` when absent); + * ``__name__``, the schema's ``device`` name, or ``name`` when given + (``"Device"`` for a header-less register fragment). + + Because the names come from the schema at runtime they don't autocomplete and + aren't statically checked; a generated device package is a real module on disk + and does both. On a collision the device's register wins over the common one. + ``exclude_private=True`` drops registers whose DSL ``visibility`` is ``private``. + + The module is **not** registered in :data:`sys.modules`, so it cannot be reached + by ``import`` and two schemas may share a name without clashing. Bind it + yourself:: + + behavior = create_module(Path("device.yml").read_text()) + behavior.AnalogData """ device = source if isinstance(source, DeviceModel) else parse_device_schema(source) registers = create_registers( device, converters=converters, strict=strict, exclude_private=exclude_private ) - by_address = {cls.address: cls for cls in registers.values()} + module_name = name or device.device or _DEFAULT_NAME - namespace: dict[str, Any] = { - "__whoami__": int(device.whoAmI or 0), - "REGISTER_MAP": {**CORE_REGISTER_MAP, **by_address}, + # A device register replaces the common one it collides with, and displaces it + # from *both* views at once: a common register whose address or whose name the + # schema claims is left out entirely, so `module..address` and + # `REGISTER_MAP[address]` can never disagree about what sits at an address. + claimed = {cls.address for cls in registers.values()} + contents: dict[str, type[RegisterBase[Any]]] = { + cls.__name__: cls + for cls in CORE_REGISTER_MAP.values() + if cls.address not in claimed and cls.__name__ not in registers } - return type(name or device.device or "Device", (Device,), namespace) + contents.update(registers) + + for register in registers.values(): + # The emitter built these; hand them to the module that now owns them, so a + # repr reads `` instead of naming the emitter. + register.__module__ = module_name + + module = types.ModuleType(module_name, f"Harp registers for {module_name}, from a schema.") + vars(module).update( + contents, + REGISTER_MAP={cls.address: cls for cls in contents.values()}, + WHO_AM_I=int(device.whoAmI or 0), + __all__=[*sorted(contents), "REGISTER_MAP", "WHO_AM_I"], + ) + return module diff --git a/tests/data/test_dataset.py b/tests/data/test_dataset.py index 581f415..38ea047 100644 --- a/tests/data/test_dataset.py +++ b/tests/data/test_dataset.py @@ -9,7 +9,7 @@ create_dataset_reader, parse_to_dataframe, ) -from harp.device import create_device +from harp.device import create_module def _records(cls, n, seed): @@ -20,42 +20,50 @@ def _records(cls, n, seed): @pytest.fixture -def emitted_device(device_yml): +def emitted_module(device_yml): # strict=False: the test device.yml uses a custom DataConverter we don't inject # here; native decoding is enough to exercise file resolution and parsing. - return create_device(device_yml, strict=False) + return create_module(device_yml, strict=False) @pytest.fixture -def dataset(emitted_device, tmp_path): +def dataset(emitted_module, tmp_path): """A dataset folder with three app registers; the first is timestamped.""" - dev = emitted_device - name = dev.__name__ - addresses = [a for a in sorted(dev.REGISTER_MAP) if a >= 32][:3] + mod = emitted_module + name = mod.__name__ + addresses = [a for a in sorted(mod.REGISTER_MAP) if a >= 32][:3] specs = {} for i, address in enumerate(addresses): - cls = dev.REGISTER_MAP[address] + cls = mod.REGISTER_MAP[address] records = _records(cls, 5, seed=address) timestamped = i == 0 timestamps = np.arange(5, dtype=np.float64) if timestamped else None buf = bytes(cls.format_bulk(records, timestamps=timestamps)) (tmp_path / f"{name}_{address}.bin").write_bytes(buf) specs[address] = (cls, timestamped, buf) - return dev, name, tmp_path, specs + return mod, name, tmp_path, specs def test_read_by_class_and_by_address(dataset): - dev, _name, root, specs = dataset - reader = DatasetReader(dev, root) + mod, _name, root, specs = dataset + reader = DatasetReader(mod, root) for address, (cls, timestamped, buf) in specs.items(): expected = parse_to_dataframe(cls, buf, timestamp=timestamped) assert reader.read(cls).equals(expected) assert reader.read(address).equals(expected) +def test_read_by_name_from_the_module(dataset): + mod, _name, root, specs = dataset + reader = DatasetReader(mod, root) + for address, (cls, _timestamped, _buf) in specs.items(): + # The register reached by name off the module is the one at that address. + assert reader.read(getattr(mod, cls.__name__)).equals(reader.read(address)) + + def test_timestamp_is_auto_detected(dataset): - dev, _name, root, specs = dataset - reader = DatasetReader(dev, root) + mod, _name, root, specs = dataset + reader = DatasetReader(mod, root) for address, (_cls, timestamped, _buf) in specs.items(): df = reader.read(address) # Timestamped frames get a "Time" index; untimestamped keep a plain RangeIndex. @@ -63,8 +71,8 @@ def test_timestamp_is_auto_detected(dataset): def test_time_index_is_float_seconds_without_epoch(dataset): - dev, _name, root, specs = dataset - reader = DatasetReader(dev, root) + mod, _name, root, specs = dataset + reader = DatasetReader(mod, root) address = next(a for a, (_c, ts, _b) in specs.items() if ts) # the timestamped register df = reader.read(address) assert df.index.name == "Time" @@ -72,8 +80,8 @@ def test_time_index_is_float_seconds_without_epoch(dataset): def test_epoch_gives_absolute_datetime_index(dataset): - dev, _name, root, specs = dataset - reader = DatasetReader(dev, root) + mod, _name, root, specs = dataset + reader = DatasetReader(mod, root) address = next(a for a, (_c, ts, _b) in specs.items() if ts) df = reader.read(address, epoch=REFERENCE_EPOCH) assert isinstance(df.index, pd.DatetimeIndex) @@ -84,25 +92,25 @@ def test_epoch_gives_absolute_datetime_index(dataset): def test_read_all_keyed_by_register_name(dataset): - dev, _name, root, specs = dataset - reader = DatasetReader(dev, root) + mod, _name, root, specs = dataset + reader = DatasetReader(mod, root) frames = reader.read_all() assert set(frames) == {cls.__name__ for cls, _ts, _buf in specs.values()} for cls, _timestamped, _buf in specs.values(): assert frames[cls.__name__].equals(reader.read(cls.address)) -def test_suffix_chunks_are_concatenated(emitted_device, tmp_path): - dev = emitted_device - name = dev.__name__ - address = next(a for a in sorted(dev.REGISTER_MAP) if a >= 32) - cls = dev.REGISTER_MAP[address] +def test_suffix_chunks_are_concatenated(emitted_module, tmp_path): + mod = emitted_module + name = mod.__name__ + address = next(a for a in sorted(mod.REGISTER_MAP) if a >= 32) + cls = mod.REGISTER_MAP[address] chunk0 = bytes(cls.format_bulk(_records(cls, 3, seed=1))) chunk1 = bytes(cls.format_bulk(_records(cls, 2, seed=2))) (tmp_path / f"{name}_{address}_0.bin").write_bytes(chunk0) (tmp_path / f"{name}_{address}_1.bin").write_bytes(chunk1) - reader = DatasetReader(dev, tmp_path) + reader = DatasetReader(mod, tmp_path) combined = parse_to_dataframe(cls, chunk0 + chunk1, timestamp=False) assert reader.read(cls).reset_index(drop=True).equals(combined) # A specific chunk can still be selected by suffix. @@ -110,42 +118,42 @@ def test_suffix_chunks_are_concatenated(emitted_device, tmp_path): assert reader.read(cls, suffix="0").equals(only0) -def test_non_device_raises_on_register_access(dataset): - _dev, _name, root, _specs = dataset - # Registers are derived lazily; a non-Device fails when they are accessed. +def test_non_module_raises_on_register_access(dataset): + _mod, _name, root, _specs = dataset + # Registers are derived lazily; anything without a REGISTER_MAP fails on access. reader = DatasetReader(object, root) with pytest.raises(AttributeError, match="REGISTER_MAP"): _ = reader.registers def test_explicit_name_overrides(dataset): - dev, name, root, _specs = dataset - reader = DatasetReader(dev, root, name=name) + mod, name, root, _specs = dataset + reader = DatasetReader(mod, root, name=name) assert isinstance(reader, DatasetReader) assert reader.name == name def test_missing_register_file_raises(dataset): - dev, _name, root, _specs = dataset - reader = DatasetReader(dev, root) + mod, _name, root, _specs = dataset + reader = DatasetReader(mod, root) # WhoAmI (address 0) is in the map but has no file in this dataset. with pytest.raises(FileNotFoundError): reader.read(0) def test_unknown_address_raises(dataset): - dev, _name, root, _specs = dataset - reader = DatasetReader(dev, root) + mod, _name, root, _specs = dataset + reader = DatasetReader(mod, root) with pytest.raises(KeyError): reader.read(9999) -def test_custom_file_resolver_supports_alternative_layout(emitted_device, tmp_path): - dev = emitted_device - addresses = [a for a in sorted(dev.REGISTER_MAP) if a >= 32][:2] +def test_custom_file_resolver_supports_alternative_layout(emitted_module, tmp_path): + mod = emitted_module + addresses = [a for a in sorted(mod.REGISTER_MAP) if a >= 32][:2] expected = {} for address in addresses: - cls = dev.REGISTER_MAP[address] + cls = mod.REGISTER_MAP[address] buf = bytes(cls.format_bulk(_records(cls, 3, seed=address))) (tmp_path / f"reg{address}.bin").write_bytes(buf) # not the Harp layout expected[cls.__name__] = parse_to_dataframe(cls, buf, timestamp=False) @@ -158,7 +166,7 @@ def resolver(root, _name): found.setdefault(int(match.group(1)), []).append(path) return found - reader = DatasetReader(dev, tmp_path, resolver=resolver) + reader = DatasetReader(mod, tmp_path, resolver=resolver) assert set(reader.files) == set(addresses) frames = reader.read_all() assert set(frames) == set(expected) @@ -167,17 +175,17 @@ def resolver(root, _name): def test_files_property_lists_discovered_bins(dataset): - dev, _name, root, specs = dataset - reader = DatasetReader(dev, root) + mod, _name, root, specs = dataset + reader = DatasetReader(mod, root) assert set(reader.files) == set(specs) -def test_read_all_registers_of_mock_device(emitted_device, tmp_path): +def test_read_all_registers_of_mock_device(emitted_module, tmp_path): """Write one .bin per register of the device.yml device, then read them all back.""" - dev = emitted_device - name = dev.__name__ + mod = emitted_module + name = mod.__name__ expected = {} - for address, cls in dev.REGISTER_MAP.items(): + for address, cls in mod.REGISTER_MAP.items(): records = _records(cls, 4, seed=address) # Alternate timestamped/untimestamped to exercise both parse paths. timestamped = address % 2 == 0 @@ -186,39 +194,39 @@ def test_read_all_registers_of_mock_device(emitted_device, tmp_path): (tmp_path / f"{name}_{address}.bin").write_bytes(buf) expected[cls.__name__] = parse_to_dataframe(cls, buf, timestamp=timestamped) - reader = DatasetReader(dev, tmp_path) + reader = DatasetReader(mod, tmp_path) frames = reader.read_all() - assert set(reader.files) == set(dev.REGISTER_MAP) + assert set(reader.files) == set(mod.REGISTER_MAP) assert set(frames) == set(expected) - assert len(frames) == len(dev.REGISTER_MAP) + assert len(frames) == len(mod.REGISTER_MAP) for register_name, df in frames.items(): assert len(df) == 4 assert df.equals(expected[register_name]) -def test_reader_derives_name_and_registers_from_device(dataset): - dev, name, root, _specs = dataset - reader = DatasetReader(dev, root) - assert reader.device is dev +def test_reader_derives_name_and_registers_from_module(dataset): + mod, name, root, _specs = dataset + reader = DatasetReader(mod, root) + assert reader.module is mod assert reader.name == name - assert reader.registers == dev.REGISTER_MAP + assert reader.registers == mod.REGISTER_MAP -def test_create_dataset_reader_builds_device_from_device_yml(dataset, device_yml): - dev, _name, root, specs = dataset +def test_create_dataset_reader_builds_module_from_device_yml(dataset, device_yml): + mod, _name, root, specs = dataset (root / "device.yml").write_text(device_yml) - # strict=False mirrors the emitted_device fixture (custom DataConverter not injected). + # strict=False mirrors the emitted_module fixture (custom DataConverter not injected). reader = create_dataset_reader(root, strict=False) assert isinstance(reader, DatasetReader) - # Reads match a reader built from an explicitly-generated device. - reference = DatasetReader(dev, root) + # Reads match a reader built from an explicitly-generated module. + reference = DatasetReader(mod, root) for address, (cls, _timestamped, _buf) in specs.items(): assert reader.read(address).equals(reference.read(cls)) def test_create_dataset_reader_accepts_explicit_schema_path(dataset, device_yml, tmp_path): - _dev, _name, root, specs = dataset + _mod, _name, root, specs = dataset schema_path = tmp_path / "elsewhere.yml" # not inside the dataset folder schema_path.write_text(device_yml) reader = create_dataset_reader(root, schema=schema_path, strict=False) @@ -227,6 +235,6 @@ def test_create_dataset_reader_accepts_explicit_schema_path(dataset, device_yml, def test_create_dataset_reader_missing_schema_raises(dataset): - _dev, _name, root, _specs = dataset # no device.yml written into the folder + _mod, _name, root, _specs = dataset # no device.yml written into the folder with pytest.raises(FileNotFoundError, match="device.yml"): create_dataset_reader(root) diff --git a/tests/device/test_create_module.py b/tests/device/test_create_module.py new file mode 100644 index 0000000..303302d --- /dev/null +++ b/tests/device/test_create_module.py @@ -0,0 +1,127 @@ +import sys +import types + +import pytest +from harp.device import create_module + +from .converters import DataConverter + +CONVERTERS = {"DataConverter": DataConverter()} + + +@pytest.fixture +def test_module(device_yml): + return create_module(device_yml, converters=CONVERTERS) + + +def test_returns_module_named_after_schema(test_module): + assert isinstance(test_module, types.ModuleType) + assert test_module.__name__ == "Tests" + + +def test_whoami_defaults_to_zero_when_absent(test_module): + # device.yml (application-device metadata) omits whoAmI. + assert test_module.WHO_AM_I == 0 + + +def test_whoami_from_schema(): + mod = create_module( + "device: D\nwhoAmI: 1216\nregisters:\n Foo: {address: 40, type: U16, access: Read}\n" + ) + assert mod.WHO_AM_I == 1216 + + +def test_registers_are_reachable_by_name(test_module): + assert test_module.AnalogData.address == 33 + assert test_module.EncoderMode.address == 103 + + +def test_registers_are_reachable_by_address(test_module): + reg_map = test_module.REGISTER_MAP + assert reg_map[33].__name__ == "AnalogData" + assert reg_map[103].__name__ == "EncoderMode" + + +def test_register_map_spreads_core(test_module): + reg_map = test_module.REGISTER_MAP + assert reg_map[0].__name__ == "WhoAmI" # core register, always spread in + assert reg_map[33].__name__ == "AnalogData" # device-specific + assert reg_map[103].__name__ == "EncoderMode" + + +def test_core_registers_are_reachable_by_name(test_module): + from harp.device import WhoAmI + + assert test_module.WhoAmI is WhoAmI # the very same class, not a copy + + +def test_unknown_name_raises_attribute_error(test_module): + with pytest.raises(AttributeError): + _ = test_module.Nonexistent + + +def test_name_and_address_views_agree(test_module): + # Both views index one set of classes, so they can never disagree about which + # register sits at an address. + registers = {cls.__name__: cls for cls in test_module.REGISTER_MAP.values()} + assert len(registers) == len(test_module.REGISTER_MAP) # no two names per address + for name, cls in registers.items(): + assert getattr(test_module, name) is cls + assert cls.address in test_module.REGISTER_MAP + + +def test_device_register_overrides_core_on_clash(): + # A device register at a core address wins over the spread-in common one, and + # displaces it from the name view too, so the two views stay consistent. + mod = create_module( + "device: Clash\nregisters:\n Shadow: {address: 0, type: U32, access: Read}\n" + ) + assert mod.REGISTER_MAP[0].__name__ == "Shadow" + assert mod.Shadow.address == 0 + assert not hasattr(mod, "WhoAmI") # the common register it replaced is gone + + +def test_device_register_overrides_core_on_name_clash(): + # Same rule the other way round: the schema claiming a common *name* displaces + # the common register entirely, rather than leaving the address view stale. + mod = create_module( + "device: Clash\nregisters:\n WhoAmI: {address: 40, type: U16, access: Read}\n" + ) + assert mod.WhoAmI.address == 40 + assert 0 not in mod.REGISTER_MAP + + +def test_headerless_fragment_builds_default_module(): + # A register-only fragment is a valid (nameless) device; name falls back to "Device". + mod = create_module("registers:\n Foo: {address: 40, type: U16, access: Read}\n") + assert mod.__name__ == "Device" + assert mod.WHO_AM_I == 0 + assert mod.REGISTER_MAP[40].__name__ == "Foo" + assert mod.Foo.address == 40 + + +def test_all_covers_registers_and_module_constants(test_module): + exported = set(test_module.__all__) + assert {"REGISTER_MAP", "WHO_AM_I"} <= exported + assert {"AnalogData", "EncoderMode", "WhoAmI"} <= exported + assert exported - {"REGISTER_MAP", "WHO_AM_I"} == { + cls.__name__ for cls in test_module.REGISTER_MAP.values() + } + + +def test_module_is_not_registered_in_sys_modules(test_module): + # Two schemas may share a device name, so the module is handed back unbound. + assert sys.modules.get(test_module.__name__) is not test_module + + +def test_emitted_registers_carry_the_module_name(test_module): + assert test_module.AnalogData.__module__ == "Tests" + + +def test_emitted_registers_are_usable(test_module): + reg = test_module.AnalogData + # The emitted register class round-trips through the Device.read/write frame path. + frame = reg.format( + reg.payload_class(Analog0=1.0, Analog1=2.0, Analog2=3.0, Accelerometer=[4, 5, 6]) + ) + assert isinstance(frame, (bytes, bytearray)) diff --git a/tests/device/test_device_emit.py b/tests/device/test_device_emit.py deleted file mode 100644 index c3e996c..0000000 --- a/tests/device/test_device_emit.py +++ /dev/null @@ -1,66 +0,0 @@ -import pytest -from harp.device import Device, create_device - -from .converters import DataConverter - -CONVERTERS = {"DataConverter": DataConverter()} - - -@pytest.fixture -def test_device(device_yml): - return create_device(device_yml, converters=CONVERTERS) - - -def test_returns_device_subclass(test_device): - assert issubclass(test_device, Device) - assert test_device.__name__ == "Tests" - - -def test_whoami_defaults_to_zero_when_absent(test_device): - # device.yml (application-device metadata) omits whoAmI. - assert test_device.__whoami__ == 0 - - -def test_whoami_from_schema(): - Dev = create_device( - "device: D\nwhoAmI: 1216\nregisters:\n Foo: {address: 40, type: U16, access: Read}\n" - ) - assert Dev.__whoami__ == 1216 - - -def test_registers_are_reachable_by_address(test_device): - reg_map = test_device.REGISTER_MAP - assert reg_map[33].__name__ == "AnalogData" - assert reg_map[103].__name__ == "EncoderMode" - - -def test_register_map_spreads_core(test_device): - reg_map = test_device.REGISTER_MAP - assert reg_map[0].__name__ == "WhoAmI" # core register, always spread in - assert reg_map[33].__name__ == "AnalogData" # device-specific - assert reg_map[103].__name__ == "EncoderMode" - - -def test_device_register_overrides_core_on_clash(): - # A device register at a core address wins over the spread-in common one. - Dev = create_device( - "device: Clash\nregisters:\n Shadow: {address: 0, type: U32, access: Read}\n" - ) - assert Dev.REGISTER_MAP[0].__name__ == "Shadow" - - -def test_headerless_fragment_builds_default_device(): - # A register-only fragment is a valid (nameless) device; name falls back to "Device". - Dev = create_device("registers:\n Foo: {address: 40, type: U16, access: Read}\n") - assert Dev.__name__ == "Device" - assert Dev.__whoami__ == 0 - assert Dev.REGISTER_MAP[40].__name__ == "Foo" - - -def test_emitted_device_registers_are_usable(test_device): - reg = test_device.REGISTER_MAP[33] # AnalogData - # The emitted register class round-trips through the Device.read/write frame path. - frame = reg.format( - reg.payload_class(Analog0=1.0, Analog1=2.0, Analog2=3.0, Accelerometer=[4, 5, 6]) - ) - assert isinstance(frame, (bytes, bytearray)) From 3dfd6b12a5f63cb138a69b2ed0a1dbaa6f750fe3 Mon Sep 17 00:00:00 2001 From: glopesdev Date: Sun, 9 Aug 2026 18:31:53 +0100 Subject: [PATCH 2/7] Declare the emitted device module type create_module now returns a DeviceModule, a ModuleType subclass that declares REGISTER_MAP, WHO_AM_I and, through __getattr__, the register names the schema supplies. Register access resolves as type[RegisterBase[Any]] rather than Any, and a checker that infers without typeshed resolves the members instead of reporting behavior.AnalogData as a missing attribute. An absent name raises AttributeError naming the module and the register. DatasetReader reaches REGISTER_MAP dynamically, since it also accepts a generated device package, which is a plain module carrying no such declaration. The emitter module is renamed _emit_module, after what it now builds. --- .../harp-data/src/harp/data/_dataset.py | 4 +-- .../harp-device/src/harp/device/__init__.py | 3 +- .../{_emit_device.py => _emit_module.py} | 36 +++++++++++++------ tests/device/test_create_module.py | 12 +++++-- 4 files changed, 39 insertions(+), 16 deletions(-) rename src/packages/harp-device/src/harp/device/{_emit_device.py => _emit_module.py} (69%) diff --git a/src/packages/harp-data/src/harp/data/_dataset.py b/src/packages/harp-data/src/harp/data/_dataset.py index 3173ebd..d572c17 100644 --- a/src/packages/harp-data/src/harp/data/_dataset.py +++ b/src/packages/harp-data/src/harp/data/_dataset.py @@ -85,8 +85,8 @@ def name(self) -> str: @property def registers(self) -> Mapping[int, type[RegisterBase[Any]]]: - """The module's address -> register-class map (its ``REGISTER_MAP``).""" - return self._module.REGISTER_MAP + """The address -> register-class map the module carries as ``REGISTER_MAP``.""" + return getattr(self._module, "REGISTER_MAP") @property def files(self) -> Mapping[int, list[Path]]: diff --git a/src/packages/harp-device/src/harp/device/__init__.py b/src/packages/harp-device/src/harp/device/__init__.py index 1be1efd..b49d858 100644 --- a/src/packages/harp-device/src/harp/device/__init__.py +++ b/src/packages/harp-device/src/harp/device/__init__.py @@ -1,5 +1,5 @@ from ._device import Device, EventHandler, Subscription -from ._emit_device import create_module +from ._emit_module import DeviceModule, create_module from ._framer import HarpFramer from ._registers import ( AssemblyVersion, @@ -35,6 +35,7 @@ "EventHandler", "Subscription", "create_module", + "DeviceModule", "parse_device_schema", "ConverterContext", "HarpFramer", diff --git a/src/packages/harp-device/src/harp/device/_emit_device.py b/src/packages/harp-device/src/harp/device/_emit_module.py similarity index 69% rename from src/packages/harp-device/src/harp/device/_emit_device.py rename to src/packages/harp-device/src/harp/device/_emit_module.py index f5afafb..f5c9a83 100644 --- a/src/packages/harp-device/src/harp/device/_emit_device.py +++ b/src/packages/harp-device/src/harp/device/_emit_module.py @@ -21,6 +21,25 @@ _DEFAULT_NAME = "Device" +class DeviceModule(types.ModuleType): + """The module :func:`create_module` returns, describing what a device module holds. + + Declaring the members is what lets a linter resolve them. Register names come + from the schema, so they can only be described collectively, through + :meth:`__getattr__`; ``REGISTER_MAP`` and ``WHO_AM_I`` are named and keep their + own types. A statically generated device package is a plain module and needs + none of this, since its registers are written out. + """ + + #: Address -> register class, the common Harp registers merged with the schema's. + REGISTER_MAP: dict[int, type[RegisterBase[Any]]] + #: The device identity declared by the schema; ``0`` when absent. + WHO_AM_I: int + + def __getattr__(self, name: str) -> type[RegisterBase[Any]]: + raise AttributeError(f"module {self.__name__!r} has no register named {name!r}") + + def create_module( source: Union[str, DeviceModel], *, @@ -28,7 +47,7 @@ def create_module( converters: Optional[Mapping[str, ConverterValue]] = None, strict: bool = True, exclude_private: bool = True, -) -> types.ModuleType: +) -> DeviceModule: """Emit a module of register classes from a device schema. The module holds the schema's registers merged with the common Harp registers, @@ -39,9 +58,10 @@ def create_module( * ``__name__``, the schema's ``device`` name, or ``name`` when given (``"Device"`` for a header-less register fragment). - Because the names come from the schema at runtime they don't autocomplete and - aren't statically checked; a generated device package is a real module on disk - and does both. On a collision the device's register wins over the common one. + Because the names come from the schema at runtime they don't autocomplete, and + each resolves as ``type[RegisterBase[Any]]`` rather than its own register type; + a generated device package is a real module on disk and gives both. On a + collision the device's register wins over the common one. ``exclude_private=True`` drops registers whose DSL ``visibility`` is ``private``. The module is **not** registered in :data:`sys.modules`, so it cannot be reached @@ -57,10 +77,6 @@ def create_module( ) module_name = name or device.device or _DEFAULT_NAME - # A device register replaces the common one it collides with, and displaces it - # from *both* views at once: a common register whose address or whose name the - # schema claims is left out entirely, so `module..address` and - # `REGISTER_MAP[address]` can never disagree about what sits at an address. claimed = {cls.address for cls in registers.values()} contents: dict[str, type[RegisterBase[Any]]] = { cls.__name__: cls @@ -70,11 +86,9 @@ def create_module( contents.update(registers) for register in registers.values(): - # The emitter built these; hand them to the module that now owns them, so a - # repr reads `` instead of naming the emitter. register.__module__ = module_name - module = types.ModuleType(module_name, f"Harp registers for {module_name}, from a schema.") + module = DeviceModule(module_name, f"Harp registers for {module_name}, from a schema.") vars(module).update( contents, REGISTER_MAP={cls.address: cls for cls in contents.values()}, diff --git a/tests/device/test_create_module.py b/tests/device/test_create_module.py index 303302d..6353a07 100644 --- a/tests/device/test_create_module.py +++ b/tests/device/test_create_module.py @@ -2,7 +2,7 @@ import types import pytest -from harp.device import create_module +from harp.device import DeviceModule, create_module from .converters import DataConverter @@ -19,6 +19,12 @@ def test_returns_module_named_after_schema(test_module): assert test_module.__name__ == "Tests" +def test_returns_a_device_module(test_module): + # The subclass is what declares REGISTER_MAP, WHO_AM_I and the register names, + # so a linter can resolve them on a module built at runtime. + assert isinstance(test_module, DeviceModule) + + def test_whoami_defaults_to_zero_when_absent(test_module): # device.yml (application-device metadata) omits whoAmI. assert test_module.WHO_AM_I == 0 @@ -56,7 +62,9 @@ def test_core_registers_are_reachable_by_name(test_module): def test_unknown_name_raises_attribute_error(test_module): - with pytest.raises(AttributeError): + # The message names the module and the register, since a schema-built module + # cannot offer the name in an editor. + with pytest.raises(AttributeError, match="'Tests' has no register named 'Nonexistent'"): _ = test_module.Nonexistent From 6a28f8da2b43f13ce7da55ffc5ab9aa338508a5b Mon Sep 17 00:00:00 2001 From: glopesdev Date: Sun, 9 Aug 2026 21:16:25 +0100 Subject: [PATCH 3/7] Type-check the examples and the public API pyright now covers docs/examples and tests/conformance.py, a module of assert_type fixtures pinning the types the documented API resolves to. The suites stay out, since they pass registers deliberately wrong values and import private symbols, and tests was previously excluded outright, which suppressed those paths even when passed explicitly on the command line. Checking the examples caught five errors in files nothing had been checking. A TimestampSeconds handler now takes the uint32 payload the register parses to rather than float, and OperationControlPayload is built with every field, using EnableFlag members where it declares them. --- .../read_and_write_from_registers.py | 24 +++++++-- .../subscribing_to_events.py | 10 ++-- pyproject.toml | 3 +- tests/conformance.py | 50 +++++++++++++++++++ 4 files changed, 79 insertions(+), 8 deletions(-) create mode 100644 tests/conformance.py diff --git a/docs/examples/read_and_write_from_registers/read_and_write_from_registers.py b/docs/examples/read_and_write_from_registers/read_and_write_from_registers.py index 10371d3..8b735ae 100755 --- a/docs/examples/read_and_write_from_registers/read_and_write_from_registers.py +++ b/docs/examples/read_and_write_from_registers/read_and_write_from_registers.py @@ -1,4 +1,11 @@ -from harp.device import Device, OperationControl, OperationControlPayload, OperationMode, WhoAmI +from harp.device import ( + Device, + EnableFlag, + OperationControl, + OperationControlPayload, + OperationMode, + WhoAmI, +) from harp.serial import open_serial_device SERIAL_PORT = "/dev/ttyUSB0" # or "COMx" in Windows ("x" is the number of the serial port) @@ -11,7 +18,18 @@ control = device.read(OperationControl).parsed print("operation_mode before:", control.operation_mode) - # Write the register, then read it back to confirm the change. - device.write(OperationControl, OperationControlPayload(operation_mode=OperationMode.ACTIVE)) + # Write the register, then read it back to confirm the change. A struct payload + # is built whole, so every field is given a value. + device.write( + OperationControl, + OperationControlPayload( + operation_mode=OperationMode.ACTIVE, + dump_registers=False, + mute_replies=False, + visual_indicators=EnableFlag.ENABLED, + operation_led=EnableFlag.ENABLED, + heartbeat=EnableFlag.DISABLED, + ), + ) control = device.read(OperationControl).parsed print("operation_mode after:", control.operation_mode) diff --git a/docs/examples/subscribing_to_events/subscribing_to_events.py b/docs/examples/subscribing_to_events/subscribing_to_events.py index c649514..42ccca6 100644 --- a/docs/examples/subscribing_to_events/subscribing_to_events.py +++ b/docs/examples/subscribing_to_events/subscribing_to_events.py @@ -1,6 +1,8 @@ +import numpy as np from harp.device import ( REGISTER_MAP, Device, + EnableFlag, OperationControl, OperationControlPayload, OperationMode, @@ -12,7 +14,7 @@ SERIAL_PORT = "/dev/ttyUSB0" # or "COMx" in Windows ("x" is the number of the serial port) -def print_timestamp(msg: ParsedHarpMessage[float]) -> None: +def print_timestamp(msg: ParsedHarpMessage[np.uint32]) -> None: print(f"[timestamp] {msg.timestamp:.6f} {msg.parsed}") @@ -34,10 +36,10 @@ def print_any_event(msg: HarpMessage) -> None: OperationControlPayload( operation_mode=OperationMode.ACTIVE, dump_registers=True, - heartbeat=True, + heartbeat=EnableFlag.ENABLED, mute_replies=False, - operation_led=True, - visual_indicators=True, + operation_led=EnableFlag.ENABLED, + visual_indicators=EnableFlag.ENABLED, ), ) diff --git a/pyproject.toml b/pyproject.toml index c7d9169..1f3ce03 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -118,13 +118,14 @@ include = [ "src/packages/harp-device/src", "src/packages/harp-serial/src", "src/packages/harp-data/src", + "docs/examples", + "tests/conformance.py", ] exclude = [ "**/node_modules", "**/__pycache__", "**/.*", ".venv", - "tests", ] venvPath = "." venv = ".venv" diff --git a/tests/conformance.py b/tests/conformance.py new file mode 100644 index 0000000..63b318f --- /dev/null +++ b/tests/conformance.py @@ -0,0 +1,50 @@ +"""Static conformance checks for the documented public API. + +Nothing here runs. Every function is a type-checker fixture, asserting the type a +documented expression resolves to, so a change that silently degrades an inferred +type fails the build rather than being noticed downstream. +""" + +from types import ModuleType +from typing import Any, assert_type + +import numpy as np +from harp.data import DatasetReader +from harp.device import ( + Device, + DeviceModule, + OperationControl, + OperationControlPayload, + WhoAmI, + create_module, +) +from harp.protocol import ParsedHarpMessage, RegisterBase + + +def schema_built_registers(yml: str) -> None: + """A module built from a schema types its registers collectively.""" + behavior = create_module(yml) + assert_type(behavior, DeviceModule) + assert_type(behavior.AnalogData, type[RegisterBase[Any]]) + assert_type(behavior.REGISTER_MAP, dict[int, type[RegisterBase[Any]]]) + assert_type(behavior.WHO_AM_I, int) + + +def statically_declared_registers(device: Device) -> None: + """A register written out in a module carries its payload type through read.""" + assert_type(device.read(WhoAmI), ParsedHarpMessage[np.uint16]) + assert_type(device.read(WhoAmI).parsed, np.uint16) + assert_type(device.read(OperationControl).parsed, OperationControlPayload) + + +def register_writes(device: Device, payload: OperationControlPayload) -> None: + """Write accepts the payload type its register parses to.""" + assert_type(device.write(OperationControl, payload).parsed, OperationControlPayload) + + +def dataset_reader_accepts_either_module(schema_built: DeviceModule, generated: ModuleType) -> None: + """The reader takes a schema-built module and a generated package alike.""" + DatasetReader(schema_built, "session.harp") + reader = DatasetReader(generated, "session.harp") + reader.read(WhoAmI) + reader.read(44) From db1d62ea459b09779f9d1bb16e6beb6dc661b684 Mon Sep 17 00:00:00 2001 From: glopesdev Date: Mon, 10 Aug 2026 00:11:45 +0100 Subject: [PATCH 4/7] Name the schema emitter after what it returns create_module becomes create_device_module, matching the DeviceModule it returns and the create_dataset_reader convention in harp-data, and keeping the meaning at a call site that imported it directly. The example directory and the test module follow the function name. The harp-device README now describes a device module by what it holds: the identity as WHO_AM_I, the register classes at module level, and a REGISTER_MAP expanding the core one, which is the structure both the generator and create_device_module produce. A WHO_AM_I of 0 is documented as an unregistered device whose identity checks are skipped, and the core module carries none. Subclassing Device is no longer presented as the way to extend a device, only as what validates WhoAmI on connect until identity is read from the module. --- README.md | 6 +- docs/api/device.md | 2 +- .../create_device_module.md} | 10 +-- .../create_device_module.py} | 8 +-- docs/examples/index.md | 2 +- docs/examples/read_dataset/read_dataset.py | 6 +- mkdocs.yml | 2 +- src/packages/harp-data/README.md | 6 +- .../harp-data/src/harp/data/_dataset.py | 10 +-- src/packages/harp-device/README.md | 62 ++++++++++++------- .../harp-device/src/harp/device/__init__.py | 4 +- .../harp-device/src/harp/device/_device.py | 2 +- .../src/harp/device/_emit_module.py | 8 +-- tests/conformance.py | 4 +- tests/data/test_dataset.py | 4 +- ...module.py => test_create_device_module.py} | 12 ++-- 16 files changed, 84 insertions(+), 64 deletions(-) rename docs/examples/{create_module/create_module.md => create_device_module/create_device_module.md} (86%) rename docs/examples/{create_module/create_module.py => create_device_module/create_device_module.py} (87%) rename tests/device/{test_create_module.py => test_create_device_module.py} (93%) diff --git a/README.md b/README.md index 8d4d11b..9368bfd 100644 --- a/README.md +++ b/README.md @@ -80,15 +80,15 @@ everything = reader.read_all() # {register_name: DataFrame} ``` Both paths are driven by a device schema. If you have only a `device.yml` and no -pre-generated package, `create_module` compiles it into a module of register classes +pre-generated package, `create_device_module` compiles it into a module of register classes at runtime — no code-generation step — which is exactly what `create_dataset_reader` does under the hood: ```python from pathlib import Path -from harp.device import create_module +from harp.device import create_device_module -behavior = create_module(Path("device.yml").read_text()) +behavior = create_device_module(Path("device.yml").read_text()) AnalogData = behavior.AnalogData # registers are reached by name... assert behavior.REGISTER_MAP[44] is AnalogData # ...or by address ``` diff --git a/docs/api/device.md b/docs/api/device.md index 98eabe5..f1ea7a1 100644 --- a/docs/api/device.md +++ b/docs/api/device.md @@ -3,7 +3,7 @@ --- ::: harp.device.Device -::: harp.device.create_module +::: harp.device.create_device_module ::: harp.device.parse_device_schema ::: harp.device.ConverterContext ::: harp.device.HarpFramer diff --git a/docs/examples/create_module/create_module.md b/docs/examples/create_device_module/create_device_module.md similarity index 86% rename from docs/examples/create_module/create_module.md rename to docs/examples/create_device_module/create_device_module.md index 41a37c1..5eb61e7 100644 --- a/docs/examples/create_module/create_module.md +++ b/docs/examples/create_device_module/create_device_module.md @@ -1,12 +1,12 @@ # Generating Registers from a Schema This example demonstrates how to turn a Harp `device.yml` into a module of register -classes at runtime with `create_module`, without a code-generation step. This is the +classes at runtime with `create_device_module`, without a code-generation step. This is the quickest way to get started when you have only a device's schema and no pre-generated package for it. A generated device package is a module: register classes at module level, with a -`REGISTER_MAP` beside them keyed by address. `create_module` builds that same shape +`REGISTER_MAP` beside them keyed by address. `create_device_module` builds that same shape from a schema, so registers are reached the same way either way — by name (`behavior.AnalogData`) or by address (`behavior.REGISTER_MAP[44]`). From there they work exactly like a pre-generated package's: drive them over a transport with @@ -15,7 +15,7 @@ data. ## When to use runtime generation -`create_module` trades statically generated device packages for schema-driven +`create_device_module` trades statically generated device packages for schema-driven convenience. It's worth understanding what that buys you and what it costs. **You gain:** @@ -44,7 +44,7 @@ convenience. It's worth understanding what that buys you and what it costs. For shipped, widely-used devices a pre-generated package from the [Harp C# generator](https://github.com/harp-tech/generators) remains the authoritative choice — better editor support and static typing. Reach for -`create_module` when you want to go from a schema to working code with no +`create_device_module` when you want to go from a schema to working code with no generation step. !!! warning @@ -52,6 +52,6 @@ generation step. ```python -[](./create_module.py) +[](./create_device_module.py) ``` diff --git a/docs/examples/create_module/create_module.py b/docs/examples/create_device_module/create_device_module.py similarity index 87% rename from docs/examples/create_module/create_module.py rename to docs/examples/create_device_module/create_device_module.py index 56a25d6..4bca277 100644 --- a/docs/examples/create_module/create_module.py +++ b/docs/examples/create_device_module/create_device_module.py @@ -1,17 +1,17 @@ from pathlib import Path from harp.data import parse_to_dataframe -from harp.device import Device, create_module +from harp.device import Device, create_device_module from harp.serial import open_serial_device SERIAL_PORT = "/dev/ttyUSB0" # or "COMx" in Windows ("x" is the number of the serial port) -# `create_module` compiles a Harp `device.yml` into a module of register classes at +# `create_device_module` compiles a Harp `device.yml` into a module of register classes at # runtime — no code-generation step. This is the quickest way to work with a device # when you don't have a pre-generated package for it: point it at the schema and you # get the same shape a generated package has, registers at module level beside a # `REGISTER_MAP`. -behavior = create_module(Path("device.yml").read_text()) +behavior = create_device_module(Path("device.yml").read_text()) print("WhoAmI:", behavior.WHO_AM_I) # device identity, taken from the schema AnalogData = behavior.AnalogData # registers are reached by name... @@ -39,7 +39,7 @@ # A register with a custom `interfaceType` needs a converter so its field decodes # to the right Python type. Pass it via `converters=`, keyed by "Converter": # -# behavior = create_module(yml_text, converters={"DataConverter": DataConverter()}) +# behavior = create_device_module(yml_text, converters={"DataConverter": DataConverter()}) # # An unresolved custom type raises `UnknownConverterError`; pass `strict=False` to # decode it natively instead. `exclude_private=True` (the default) drops registers diff --git a/docs/examples/index.md b/docs/examples/index.md index f2e4c86..0feb522 100644 --- a/docs/examples/index.md +++ b/docs/examples/index.md @@ -4,7 +4,7 @@ This section contains some examples to help you get started with `harp`. Working from a device schema: -- [Generating Registers from a Schema](./create_module/create_module.md) - compile a `device.yml` into a module of register classes at runtime with `create_module`. +- [Generating Registers from a Schema](./create_device_module/create_device_module.md) - compile a `device.yml` into a module of register classes at runtime with `create_device_module`. Talking to a device: diff --git a/docs/examples/read_dataset/read_dataset.py b/docs/examples/read_dataset/read_dataset.py index 390e14f..b2186ae 100644 --- a/docs/examples/read_dataset/read_dataset.py +++ b/docs/examples/read_dataset/read_dataset.py @@ -35,12 +35,12 @@ print(absolute.index[:3]) # --- Already have a device module? ------------------------------------------- -# A pre-generated device package, or one you built yourself with `create_module`, +# A pre-generated device package, or one you built yourself with `create_device_module`, # can drive the reader directly — construct `DatasetReader(module, folder)`: # # from harp.data import DatasetReader -# from harp.device import create_module +# from harp.device import create_device_module # from pathlib import Path # -# behavior = create_module((Path("session.harp") / "device.yml").read_text()) +# behavior = create_device_module((Path("session.harp") / "device.yml").read_text()) # reader = DatasetReader(behavior, "session.harp") diff --git a/mkdocs.yml b/mkdocs.yml index 87b7511..23d980e 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -73,7 +73,7 @@ nav: - Home: index.md - Examples: - examples/index.md - - Generating Registers from a Schema: examples/create_module/create_module.md + - Generating Registers from a Schema: examples/create_device_module/create_device_module.md - Getting Device Info: examples/get_info/get_info.md - Read and Write from Registers: examples/read_and_write_from_registers/read_and_write_from_registers.md - Subscribing to Events: examples/subscribing_to_events/subscribing_to_events.md diff --git a/src/packages/harp-data/README.md b/src/packages/harp-data/README.md index ca30744..ebc9aba 100644 --- a/src/packages/harp-data/README.md +++ b/src/packages/harp-data/README.md @@ -38,14 +38,14 @@ everything = reader.read_all() # {register_name: DataFrame} ``` Already have a device module (e.g. a pre-generated package, or one built with -`create_module`)? Drive `DatasetReader` with it directly: +`create_device_module`)? Drive `DatasetReader` with it directly: ```python from pathlib import Path from harp.data import DatasetReader -from harp.device import create_module +from harp.device import create_device_module -behavior = create_module((Path("session.harp") / "device.yml").read_text()) +behavior = create_device_module((Path("session.harp") / "device.yml").read_text()) reader = DatasetReader(behavior, "session.harp") ``` diff --git a/src/packages/harp-data/src/harp/data/_dataset.py b/src/packages/harp-data/src/harp/data/_dataset.py index d572c17..6c8a60d 100644 --- a/src/packages/harp-data/src/harp/data/_dataset.py +++ b/src/packages/harp-data/src/harp/data/_dataset.py @@ -7,7 +7,7 @@ from typing import Any import pandas as pd -from harp.device import create_module +from harp.device import create_device_module from harp.protocol import RegisterBase from harp.protocol._constants import _TIMESTAMP_FLAG @@ -44,7 +44,7 @@ class DatasetReader: everything = reader.read_all() # {register_name: DataFrame} ``module`` is a device module -- a generated device package, or one built from a - schema with :func:`~harp.device.create_module`. Its ``REGISTER_MAP`` and + schema with :func:`~harp.device.create_device_module`. Its ``REGISTER_MAP`` and ``__name__`` are read on demand. ``name`` overrides the ```` file prefix, which defaults to the module name. @@ -198,14 +198,14 @@ def create_dataset_reader( """Build a :class:`DatasetReader` for a dataset folder, device and all. Convenience wrapper that finds the device schema inside ``root`` (``device.yml`` - by default), builds its module with :func:`~harp.device.create_module`, and + by default), builds its module with :func:`~harp.device.create_device_module`, and returns a reader ready to :meth:`~DatasetReader.read`:: reader = create_dataset_reader("session.harp") df = reader.read(44) ``schema`` points at the schema file explicitly when it isn't ``root/device.yml``. - ``converters`` and ``strict`` are forwarded to :func:`~harp.device.create_module` + ``converters`` and ``strict`` are forwarded to :func:`~harp.device.create_device_module` for custom ``interfaceType`` decoding; ``name`` and ``resolver`` are forwarded to :class:`DatasetReader`. Use ``DatasetReader(module, root)`` directly when you already have a (e.g. pre-generated) device module. @@ -217,5 +217,5 @@ def create_dataset_reader( f"No device schema at '{schema_path}'. Pass schema= to point at a device.yml, " f"or build the module yourself and use DatasetReader(module, root)." ) - module = create_module(schema_path.read_text(), converters=converters, strict=strict) + module = create_device_module(schema_path.read_text(), converters=converters, strict=strict) return DatasetReader(module, root_path, name=name, resolver=resolver) diff --git a/src/packages/harp-device/README.md b/src/packages/harp-device/README.md index 7059e86..d785308 100644 --- a/src/packages/harp-device/README.md +++ b/src/packages/harp-device/README.md @@ -19,52 +19,72 @@ device.write(OperationControl, payload) # write a register ## Extending for a specific device -A device's registers live in its module. Downstream (often generated) packages -declare them at module level and spread the core `REGISTER_MAP` beside them, and may -subclass `Device` to set `__whoami__` for identity validation on connect: +A device is described by a module. Downstream, often generated, packages record the +device identity as `WHO_AM_I`, declare the register classes at module level, and expand +the core `REGISTER_MAP` beside them: ```python -from harp.device import Device, REGISTER_MAP as _CORE_REGISTER_MAP - -class MyDevice(Device): - __whoami__ = 1216 +from harp.device import REGISTER_MAP as _CORE_REGISTER_MAP +WHO_AM_I: int = 1216 REGISTER_MAP = {**_CORE_REGISTER_MAP, 32: DigitalInputState, ...} ``` -`Device` itself holds no register collection: `read`, `write` and `subscribe` take a -register class, so the module namespace is the only place registers need to live. +This is the same structure `create_device_module` builds from a schema, so a device +reads the same way whether it was generated ahead of time or compiled at runtime. A +`WHO_AM_I` of `0` marks an unregistered device, used while a device is in development +or outside the official registry, and identity checks are skipped for it. + +The common registers are not a device, so the core module 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: + +```python +from harp import behavior + +# `device` is a Device opened over some transport (see harp-serial) +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 +``` A new transport is just an object implementing the `ITransport` protocol (`open`/`write`/`read`/`close`). ## Generating registers from a `device.yml` -If you don't have a pre-generated device package, `create_module` builds the same -shape at runtime from Harp `device.yml` text: register classes at module level, a -`REGISTER_MAP` beside them, and the schema's identity as `WHO_AM_I`. Field and enum -names come from the yml verbatim. +Without a pre-generated device package, `create_device_module` builds the same +structure at runtime from Harp `device.yml` text: register classes at module level, a +`REGISTER_MAP` beside them, and the identity declared by the schema as `WHO_AM_I`. +Field and enum names come from the yml verbatim. ```python from pathlib import Path -from harp.device import create_module +from harp.device import create_device_module -behavior = create_module(Path("device.yml").read_text()) +behavior = create_device_module(Path("device.yml").read_text()) reg = behavior.AnalogData # by name reg = behavior.REGISTER_MAP[44] # or by address ``` -The module is not registered in `sys.modules`, so bind it yourself rather than -`import`-ing it; names come from the schema at runtime, so they don't autocomplete -and aren't statically checked, which is what a generated package on disk buys you. +The module is not registered in `sys.modules`, so it has to be bound rather than +imported. Names come from the schema at runtime, so they don't autocomplete and +aren't statically checked. A generated package on disk gives both. For a custom `interfaceType`, pass its converter via `converters=` (keyed by `{InterfaceType}Converter` / `{MemberName}Converter`); an unresolved custom type raises `UnknownConverterError`, or pass `strict=False` to decode it natively: ```python -create_module(yml_text, converters={"DataConverter": DataConverter()}) +create_device_module(yml_text, converters={"DataConverter": DataConverter()}) ``` -`parse_device_schema(yml_text)` is also public if you just want the parsed -schema model (registers, masks, and optional device identity) without a module. +`parse_device_schema(yml_text)` is also public, returning the parsed schema model +without a module: registers, masks, and optional device identity. diff --git a/src/packages/harp-device/src/harp/device/__init__.py b/src/packages/harp-device/src/harp/device/__init__.py index b49d858..51ae8c6 100644 --- a/src/packages/harp-device/src/harp/device/__init__.py +++ b/src/packages/harp-device/src/harp/device/__init__.py @@ -1,5 +1,5 @@ from ._device import Device, EventHandler, Subscription -from ._emit_module import DeviceModule, create_module +from ._emit_module import DeviceModule, create_device_module from ._framer import HarpFramer from ._registers import ( AssemblyVersion, @@ -34,7 +34,7 @@ "Device", "EventHandler", "Subscription", - "create_module", + "create_device_module", "DeviceModule", "parse_device_schema", "ConverterContext", diff --git a/src/packages/harp-device/src/harp/device/_device.py b/src/packages/harp-device/src/harp/device/_device.py index 45a5479..71098e9 100644 --- a/src/packages/harp-device/src/harp/device/_device.py +++ b/src/packages/harp-device/src/harp/device/_device.py @@ -74,7 +74,7 @@ class Device: 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.create_module`, or the + beside a ``REGISTER_MAP`` (see :func:`~harp.device.create_device_module`, or the ``harp-device`` README for the statically generated equivalent). A subclass sets :attr:`__whoami__` to validate device identity on open diff --git a/src/packages/harp-device/src/harp/device/_emit_module.py b/src/packages/harp-device/src/harp/device/_emit_module.py index f5c9a83..76aae2f 100644 --- a/src/packages/harp-device/src/harp/device/_emit_module.py +++ b/src/packages/harp-device/src/harp/device/_emit_module.py @@ -2,7 +2,7 @@ A generated device package is already a module: register classes at module level and a ``REGISTER_MAP`` beside them (see the ``harp-device`` README). -:func:`create_module` builds that same shape at runtime from a ``device.yml``, so a +:func:`create_device_module` builds that same shape at runtime from a ``device.yml``, so a schema-driven device and a generated one are reached the same way, by name from the module or by address through ``REGISTER_MAP``. """ @@ -22,7 +22,7 @@ class DeviceModule(types.ModuleType): - """The module :func:`create_module` returns, describing what a device module holds. + """The module :func:`create_device_module` returns, describing what a device module holds. Declaring the members is what lets a linter resolve them. Register names come from the schema, so they can only be described collectively, through @@ -40,7 +40,7 @@ def __getattr__(self, name: str) -> type[RegisterBase[Any]]: raise AttributeError(f"module {self.__name__!r} has no register named {name!r}") -def create_module( +def create_device_module( source: Union[str, DeviceModel], *, name: Optional[str] = None, @@ -68,7 +68,7 @@ def create_module( by ``import`` and two schemas may share a name without clashing. Bind it yourself:: - behavior = create_module(Path("device.yml").read_text()) + behavior = create_device_module(Path("device.yml").read_text()) behavior.AnalogData """ device = source if isinstance(source, DeviceModel) else parse_device_schema(source) diff --git a/tests/conformance.py b/tests/conformance.py index 63b318f..fe04bec 100644 --- a/tests/conformance.py +++ b/tests/conformance.py @@ -16,14 +16,14 @@ OperationControl, OperationControlPayload, WhoAmI, - create_module, + create_device_module, ) from harp.protocol import ParsedHarpMessage, RegisterBase def schema_built_registers(yml: str) -> None: """A module built from a schema types its registers collectively.""" - behavior = create_module(yml) + behavior = create_device_module(yml) assert_type(behavior, DeviceModule) assert_type(behavior.AnalogData, type[RegisterBase[Any]]) assert_type(behavior.REGISTER_MAP, dict[int, type[RegisterBase[Any]]]) diff --git a/tests/data/test_dataset.py b/tests/data/test_dataset.py index 38ea047..11e7a9b 100644 --- a/tests/data/test_dataset.py +++ b/tests/data/test_dataset.py @@ -9,7 +9,7 @@ create_dataset_reader, parse_to_dataframe, ) -from harp.device import create_module +from harp.device import create_device_module def _records(cls, n, seed): @@ -23,7 +23,7 @@ def _records(cls, n, seed): def emitted_module(device_yml): # strict=False: the test device.yml uses a custom DataConverter we don't inject # here; native decoding is enough to exercise file resolution and parsing. - return create_module(device_yml, strict=False) + return create_device_module(device_yml, strict=False) @pytest.fixture diff --git a/tests/device/test_create_module.py b/tests/device/test_create_device_module.py similarity index 93% rename from tests/device/test_create_module.py rename to tests/device/test_create_device_module.py index 6353a07..485a38f 100644 --- a/tests/device/test_create_module.py +++ b/tests/device/test_create_device_module.py @@ -2,7 +2,7 @@ import types import pytest -from harp.device import DeviceModule, create_module +from harp.device import DeviceModule, create_device_module from .converters import DataConverter @@ -11,7 +11,7 @@ @pytest.fixture def test_module(device_yml): - return create_module(device_yml, converters=CONVERTERS) + return create_device_module(device_yml, converters=CONVERTERS) def test_returns_module_named_after_schema(test_module): @@ -31,7 +31,7 @@ def test_whoami_defaults_to_zero_when_absent(test_module): def test_whoami_from_schema(): - mod = create_module( + mod = create_device_module( "device: D\nwhoAmI: 1216\nregisters:\n Foo: {address: 40, type: U16, access: Read}\n" ) assert mod.WHO_AM_I == 1216 @@ -81,7 +81,7 @@ def test_name_and_address_views_agree(test_module): def test_device_register_overrides_core_on_clash(): # A device register at a core address wins over the spread-in common one, and # displaces it from the name view too, so the two views stay consistent. - mod = create_module( + mod = create_device_module( "device: Clash\nregisters:\n Shadow: {address: 0, type: U32, access: Read}\n" ) assert mod.REGISTER_MAP[0].__name__ == "Shadow" @@ -92,7 +92,7 @@ def test_device_register_overrides_core_on_clash(): def test_device_register_overrides_core_on_name_clash(): # Same rule the other way round: the schema claiming a common *name* displaces # the common register entirely, rather than leaving the address view stale. - mod = create_module( + mod = create_device_module( "device: Clash\nregisters:\n WhoAmI: {address: 40, type: U16, access: Read}\n" ) assert mod.WhoAmI.address == 40 @@ -101,7 +101,7 @@ def test_device_register_overrides_core_on_name_clash(): def test_headerless_fragment_builds_default_module(): # A register-only fragment is a valid (nameless) device; name falls back to "Device". - mod = create_module("registers:\n Foo: {address: 40, type: U16, access: Read}\n") + mod = create_device_module("registers:\n Foo: {address: 40, type: U16, access: Read}\n") assert mod.__name__ == "Device" assert mod.WHO_AM_I == 0 assert mod.REGISTER_MAP[40].__name__ == "Foo" From 96504aeae87dfd5f4d808921ee381ef7a37d7fe0 Mon Sep 17 00:00:00 2001 From: glopesdev Date: Mon, 10 Aug 2026 08:59:13 +0100 Subject: [PATCH 5/7] Type the dataset reader by module contract DatasetReader takes device_module rather than module, exposes it as DatasetReader.device_module, and hints it as DeviceModuleLike, a protocol matching any module that carries __name__ and REGISTER_MAP. A generated device package is a plain module, so a nominal hint would reject it. Matching structurally accepts both it and DeviceModule, and rejects a module following neither. Registers are read as an attribute rather than through getattr. The harp-protocol README documents why register values are numpy scalars, beside the parse example that first returns one. The value carries the width its register declares, which a Python int has no way to represent. --- .../harp-data/src/harp/data/_dataset.py | 27 ++++++++++--------- .../harp-device/src/harp/device/__init__.py | 3 ++- .../src/harp/device/_emit_module.py | 15 ++++++++++- src/packages/harp-protocol/README.md | 11 ++++++++ tests/conformance.py | 6 +++-- tests/data/test_dataset.py | 2 +- 6 files changed, 46 insertions(+), 18 deletions(-) diff --git a/src/packages/harp-data/src/harp/data/_dataset.py b/src/packages/harp-data/src/harp/data/_dataset.py index 6c8a60d..6047769 100644 --- a/src/packages/harp-data/src/harp/data/_dataset.py +++ b/src/packages/harp-data/src/harp/data/_dataset.py @@ -3,11 +3,10 @@ from datetime import datetime from os import PathLike from pathlib import Path -from types import ModuleType from typing import Any import pandas as pd -from harp.device import create_device_module +from harp.device import DeviceModuleLike, create_device_module from harp.protocol import RegisterBase from harp.protocol._constants import _TIMESTAMP_FLAG @@ -43,7 +42,7 @@ class DatasetReader: df = reader.read(44) # by address everything = reader.read_all() # {register_name: DataFrame} - ``module`` is a device module -- a generated device package, or one built from a + ``device_module`` is a device module -- a generated device package, or one built from a schema with :func:`~harp.device.create_device_module`. Its ``REGISTER_MAP`` and ``__name__`` are read on demand. ``name`` overrides the ```` file prefix, which defaults to the module name. @@ -56,13 +55,13 @@ class DatasetReader: def __init__( self, - module: ModuleType, + device_module: DeviceModuleLike, root: str | PathLike[str], *, name: str | None = None, resolver: FileNameResolver = default_file_resolver, ) -> None: - self._module = module + self._device_module = device_module self._root = Path(root) self._name_override = name self._resolver = resolver @@ -74,19 +73,19 @@ def root(self) -> Path: return self._root @property - def module(self) -> ModuleType: + def device_module(self) -> DeviceModuleLike: """The device module this reader parses against.""" - return self._module + return self._device_module @property def name(self) -> str: """The ```` prefix used to match binary files.""" - return self._name_override or self._module.__name__ + return self._name_override or self._device_module.__name__ @property def registers(self) -> Mapping[int, type[RegisterBase[Any]]]: """The address -> register-class map the module carries as ``REGISTER_MAP``.""" - return getattr(self._module, "REGISTER_MAP") + return self._device_module.REGISTER_MAP @property def files(self) -> Mapping[int, list[Path]]: @@ -207,7 +206,7 @@ def create_dataset_reader( ``schema`` points at the schema file explicitly when it isn't ``root/device.yml``. ``converters`` and ``strict`` are forwarded to :func:`~harp.device.create_device_module` for custom ``interfaceType`` decoding; ``name`` and ``resolver`` are forwarded to - :class:`DatasetReader`. Use ``DatasetReader(module, root)`` directly when you + :class:`DatasetReader`. Use ``DatasetReader(device_module, root)`` directly when you already have a (e.g. pre-generated) device module. """ root_path = Path(root) @@ -215,7 +214,9 @@ def create_dataset_reader( if not schema_path.is_file(): raise FileNotFoundError( f"No device schema at '{schema_path}'. Pass schema= to point at a device.yml, " - f"or build the module yourself and use DatasetReader(module, root)." + f"or build the device module yourself and use DatasetReader(device_module, root)." ) - module = create_device_module(schema_path.read_text(), converters=converters, strict=strict) - return DatasetReader(module, root_path, name=name, resolver=resolver) + device_module = create_device_module( + schema_path.read_text(), converters=converters, strict=strict + ) + return DatasetReader(device_module, root_path, name=name, resolver=resolver) diff --git a/src/packages/harp-device/src/harp/device/__init__.py b/src/packages/harp-device/src/harp/device/__init__.py index 51ae8c6..3bed88a 100644 --- a/src/packages/harp-device/src/harp/device/__init__.py +++ b/src/packages/harp-device/src/harp/device/__init__.py @@ -1,5 +1,5 @@ from ._device import Device, EventHandler, Subscription -from ._emit_module import DeviceModule, create_device_module +from ._emit_module import DeviceModule, DeviceModuleLike, create_device_module from ._framer import HarpFramer from ._registers import ( AssemblyVersion, @@ -36,6 +36,7 @@ "Subscription", "create_device_module", "DeviceModule", + "DeviceModuleLike", "parse_device_schema", "ConverterContext", "HarpFramer", diff --git a/src/packages/harp-device/src/harp/device/_emit_module.py b/src/packages/harp-device/src/harp/device/_emit_module.py index 76aae2f..358e367 100644 --- a/src/packages/harp-device/src/harp/device/_emit_module.py +++ b/src/packages/harp-device/src/harp/device/_emit_module.py @@ -8,7 +8,7 @@ """ import types -from typing import Any, Mapping, Optional, Union +from typing import Any, Mapping, Optional, Protocol, Union, runtime_checkable from harp.protocol import RegisterBase @@ -21,6 +21,19 @@ _DEFAULT_NAME = "Device" +@runtime_checkable +class DeviceModuleLike(Protocol): + """Any module describing a device, however it was produced. + + A generated device package is a plain module, so it cannot be named by a class; + what identifies it is carrying the register map. Matching structurally accepts + both it and :class:`DeviceModule`, and rejects a module that follows neither. + """ + + __name__: str + REGISTER_MAP: dict[int, type[RegisterBase[Any]]] + + class DeviceModule(types.ModuleType): """The module :func:`create_device_module` returns, describing what a device module holds. diff --git a/src/packages/harp-protocol/README.md b/src/packages/harp-protocol/README.md index 1afee4d..8f7b044 100644 --- a/src/packages/harp-protocol/README.md +++ b/src/packages/harp-protocol/README.md @@ -19,4 +19,15 @@ frame = WhoAmI.format(np.uint16(1216)) # build a Write frame value = WhoAmI.parse(HarpMessage.parse(frame)) # -> np.uint16(1216) ``` +## Register value types + +`parse` returns numpy scalars rather than plain `int` or `float`, so a value carries the width its register declares. A Python `int` has no width and no upper bound, so it cannot distinguish a `U8` from a `U32`, nor detect a value leaving the register range. + +```python +np.uint16(65535) + 1 # RuntimeWarning: overflow encountered in scalar add +65535 + 1 # 65536, wider than the register can hold +``` + +Numpy scalars behave like plain Python numbers in arithmetic, comparison and formatting. Use `int()` or `float()` where a built-in type is required. + It carries no transport or device logic — see [`harp-device`](../harp-device) for the device layer. diff --git a/tests/conformance.py b/tests/conformance.py index fe04bec..89316c7 100644 --- a/tests/conformance.py +++ b/tests/conformance.py @@ -5,7 +5,6 @@ type fails the build rather than being noticed downstream. """ -from types import ModuleType from typing import Any, assert_type import numpy as np @@ -13,6 +12,7 @@ from harp.device import ( Device, DeviceModule, + DeviceModuleLike, OperationControl, OperationControlPayload, WhoAmI, @@ -42,7 +42,9 @@ def register_writes(device: Device, payload: OperationControlPayload) -> None: assert_type(device.write(OperationControl, payload).parsed, OperationControlPayload) -def dataset_reader_accepts_either_module(schema_built: DeviceModule, generated: ModuleType) -> None: +def dataset_reader_accepts_either_module( + schema_built: DeviceModule, generated: DeviceModuleLike +) -> None: """The reader takes a schema-built module and a generated package alike.""" DatasetReader(schema_built, "session.harp") reader = DatasetReader(generated, "session.harp") diff --git a/tests/data/test_dataset.py b/tests/data/test_dataset.py index 11e7a9b..6cc45c7 100644 --- a/tests/data/test_dataset.py +++ b/tests/data/test_dataset.py @@ -208,7 +208,7 @@ def test_read_all_registers_of_mock_device(emitted_module, tmp_path): def test_reader_derives_name_and_registers_from_module(dataset): mod, name, root, _specs = dataset reader = DatasetReader(mod, root) - assert reader.module is mod + assert reader.device_module is mod assert reader.name == name assert reader.registers == mod.REGISTER_MAP From e3cd76993e79d63198b93f3b76f5d0d7a11d6f89 Mon Sep 17 00:00:00 2001 From: glopesdev Date: Mon, 10 Aug 2026 18:49:39 +0100 Subject: [PATCH 6/7] Scope a device module to its own registers A device module now names only the registers its schema declares, so REGISTER_MAP carries the common ones without the namespace repeating them. A dataset reader still decodes common registers, and this matches what a generated package already produces. DeviceModuleLike also requires WHO_AM_I, so the common register set no longer satisfies it and cannot be passed where a device module is expected. --- src/packages/harp-device/README.md | 5 +- .../src/harp/device/_emit_module.py | 32 ++++---- tests/data/test_dataset.py | 22 ++++- tests/device/expected_device.py | 3 + tests/device/test_create_device_module.py | 81 ++++++++++++------- 5 files changed, 94 insertions(+), 49 deletions(-) diff --git a/src/packages/harp-device/README.md b/src/packages/harp-device/README.md index d785308..fe70936 100644 --- a/src/packages/harp-device/README.md +++ b/src/packages/harp-device/README.md @@ -35,7 +35,10 @@ reads the same way whether it was generated ahead of time or compiled at runtime `WHO_AM_I` of `0` marks an unregistered device, used while a device is in development or outside the official registry, and identity checks are skipped for it. -The common registers are not a device, so the core module carries no `WHO_AM_I`. +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 +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: diff --git a/src/packages/harp-device/src/harp/device/_emit_module.py b/src/packages/harp-device/src/harp/device/_emit_module.py index 358e367..5c5c3a0 100644 --- a/src/packages/harp-device/src/harp/device/_emit_module.py +++ b/src/packages/harp-device/src/harp/device/_emit_module.py @@ -26,12 +26,14 @@ class DeviceModuleLike(Protocol): """Any module describing a device, however it was produced. A generated device package is a plain module, so it cannot be named by a class; - what identifies it is carrying the register map. Matching structurally accepts - both it and :class:`DeviceModule`, and rejects a module that follows neither. + what identifies it is describing a device. Matching structurally accepts both it + and :class:`DeviceModule`, and rejects the common register set, which carries + registers but is not a device. """ __name__: str REGISTER_MAP: dict[int, type[RegisterBase[Any]]] + WHO_AM_I: int class DeviceModule(types.ModuleType): @@ -63,18 +65,20 @@ def create_device_module( ) -> DeviceModule: """Emit a module of register classes from a device schema. - The module holds the schema's registers merged with the common Harp registers, - each reachable by name (``behavior.AnalogData``), plus: + The module names the registers the schema declares, so ``behavior.AnalogData`` + resolves while a common register such as ``WhoAmI`` is imported from + :mod:`harp.device`, keeping one definition of each. Beside them it holds: - * ``REGISTER_MAP``, the address -> register-class map; - * ``WHO_AM_I``, the schema's identity (``0`` when absent); + * ``REGISTER_MAP``, the device address space, so the common registers are + present here even though the module does not name them; + * ``WHO_AM_I``, the schema's identity (``0`` for an unregistered device); * ``__name__``, the schema's ``device`` name, or ``name`` when given (``"Device"`` for a header-less register fragment). Because the names come from the schema at runtime they don't autocomplete, and each resolves as ``type[RegisterBase[Any]]`` rather than its own register type; - a generated device package is a real module on disk and gives both. On a - collision the device's register wins over the common one. + a generated device package is a real module on disk and gives both. On an + address clash the device's register replaces the common one in ``REGISTER_MAP``. ``exclude_private=True`` drops registers whose DSL ``visibility`` is ``private``. The module is **not** registered in :data:`sys.modules`, so it cannot be reached @@ -90,13 +94,9 @@ def create_device_module( ) module_name = name or device.device or _DEFAULT_NAME - claimed = {cls.address for cls in registers.values()} - contents: dict[str, type[RegisterBase[Any]]] = { - cls.__name__: cls - for cls in CORE_REGISTER_MAP.values() - if cls.address not in claimed and cls.__name__ not in registers - } - contents.update(registers) + contents: dict[str, type[RegisterBase[Any]]] = dict(registers) + register_map = {cls.address: cls for cls in CORE_REGISTER_MAP.values()} + register_map.update({cls.address: cls for cls in registers.values()}) for register in registers.values(): register.__module__ = module_name @@ -104,7 +104,7 @@ def create_device_module( module = DeviceModule(module_name, f"Harp registers for {module_name}, from a schema.") vars(module).update( contents, - REGISTER_MAP={cls.address: cls for cls in contents.values()}, + REGISTER_MAP=register_map, WHO_AM_I=int(device.whoAmI or 0), __all__=[*sorted(contents), "REGISTER_MAP", "WHO_AM_I"], ) diff --git a/tests/data/test_dataset.py b/tests/data/test_dataset.py index 6cc45c7..ba30ee1 100644 --- a/tests/data/test_dataset.py +++ b/tests/data/test_dataset.py @@ -9,7 +9,7 @@ create_dataset_reader, parse_to_dataframe, ) -from harp.device import create_device_module +from harp.device import TimestampSeconds, WhoAmI, create_device_module def _records(cls, n, seed): @@ -53,7 +53,7 @@ def test_read_by_class_and_by_address(dataset): assert reader.read(address).equals(expected) -def test_read_by_name_from_the_module(dataset): +def test_read_by_name_from_module(dataset): mod, _name, root, specs = dataset reader = DatasetReader(mod, root) for address, (cls, _timestamped, _buf) in specs.items(): @@ -61,6 +61,24 @@ def test_read_by_name_from_the_module(dataset): assert reader.read(getattr(mod, cls.__name__)).equals(reader.read(address)) +def test_reads_common_registers_not_named_by_module(emitted_module, tmp_path): + """A device module names only its own registers, but a session folder also holds + files for the common ones, so the reader must still decode those.""" + mod = emitted_module + assert not hasattr(mod, "WhoAmI") # imported from harp.device, not re-exported + + for cls in (WhoAmI, TimestampSeconds): + records = _records(cls, 4, seed=cls.address) + buf = bytes(cls.format_bulk(records)) + (tmp_path / f"{mod.__name__}_{cls.address}.bin").write_bytes(buf) + + reader = DatasetReader(mod, tmp_path) + # By address, and by the class imported from harp.device, and in read_all. + assert len(reader.read(WhoAmI.address)) == 4 + assert reader.read(WhoAmI).equals(reader.read(WhoAmI.address)) + assert set(reader.read_all()) == {"WhoAmI", "TimestampSeconds"} + + def test_timestamp_is_auto_detected(dataset): mod, _name, root, specs = dataset reader = DatasetReader(mod, root) diff --git a/tests/device/expected_device.py b/tests/device/expected_device.py index d2fbf4f..304fc07 100644 --- a/tests/device/expected_device.py +++ b/tests/device/expected_device.py @@ -30,6 +30,9 @@ ) +WHO_AM_I: int = 0 + + class PortDigitalIOS(enum.IntFlag): DIO0 = 0x1 DIO1 = 0x2 diff --git a/tests/device/test_create_device_module.py b/tests/device/test_create_device_module.py index 485a38f..a56fb43 100644 --- a/tests/device/test_create_device_module.py +++ b/tests/device/test_create_device_module.py @@ -1,12 +1,16 @@ import sys import types +import harp.device import pytest -from harp.device import DeviceModule, create_device_module +from harp.device import REGISTER_MAP as CORE_REGISTER_MAP +from harp.device import DeviceModule, DeviceModuleLike, WhoAmI, create_device_module +from . import expected_device from .converters import DataConverter CONVERTERS = {"DataConverter": DataConverter()} +MODULE_CONSTANTS = {"REGISTER_MAP", "WHO_AM_I"} @pytest.fixture @@ -19,7 +23,7 @@ def test_returns_module_named_after_schema(test_module): assert test_module.__name__ == "Tests" -def test_returns_a_device_module(test_module): +def test_returns_device_module(test_module): # The subclass is what declares REGISTER_MAP, WHO_AM_I and the register names, # so a linter can resolve them on a module built at runtime. assert isinstance(test_module, DeviceModule) @@ -55,10 +59,39 @@ def test_register_map_spreads_core(test_module): assert reg_map[103].__name__ == "EncoderMode" -def test_core_registers_are_reachable_by_name(test_module): - from harp.device import WhoAmI +def test_core_registers_are_not_named_by_module(test_module): + # A common register has one definition, in harp.device, so a device module does + # not re-export it. It is still in the address space the device can send from. + assert not hasattr(test_module, "WhoAmI") + assert test_module.REGISTER_MAP[0] is WhoAmI - assert test_module.WhoAmI is WhoAmI # the very same class, not a copy + +def test_module_names_exactly_schema_registers(test_module): + named = {n for n in vars(test_module) if not n.startswith("_") and n not in MODULE_CONSTANTS} + addresses = {cls.address for cls in test_module.REGISTER_MAP.values()} + # Everything the module names is in the address space, and the map carries the + # common registers on top, which is the whole difference between the two. + assert all(getattr(test_module, n).address in addresses for n in named) + assert {c.__name__ for c in CORE_REGISTER_MAP.values()}.isdisjoint(named) + assert len(test_module.REGISTER_MAP) > len(named) + + +def test_emitted_module_matches_device_protocol(test_module): + assert isinstance(test_module, DeviceModuleLike) + + +def test_generated_package_matches_device_protocol(): + # expected_device is a sample of generator output, so this pins that what the + # generator emits is accepted wherever a device module is required. + assert isinstance(expected_device, DeviceModuleLike) + + +def test_common_registers_are_not_device_module(): + # They carry REGISTER_MAP but describe no device, so they cannot be passed + # where a device module is required, such as to a DatasetReader. + assert hasattr(harp.device, "REGISTER_MAP") + assert not hasattr(harp.device, "WHO_AM_I") + assert not isinstance(harp.device, DeviceModuleLike) def test_unknown_name_raises_attribute_error(test_module): @@ -68,35 +101,22 @@ def test_unknown_name_raises_attribute_error(test_module): _ = test_module.Nonexistent -def test_name_and_address_views_agree(test_module): - # Both views index one set of classes, so they can never disagree about which - # register sits at an address. - registers = {cls.__name__: cls for cls in test_module.REGISTER_MAP.values()} - assert len(registers) == len(test_module.REGISTER_MAP) # no two names per address - for name, cls in registers.items(): - assert getattr(test_module, name) is cls - assert cls.address in test_module.REGISTER_MAP +def test_named_registers_are_subset_of_address_space(test_module): + # The two are deliberately different sets: the module names what the schema + # declares, the map is everything the device can send. + for cls in test_module.REGISTER_MAP.values(): + if cls.address >= 32: + assert getattr(test_module, cls.__name__) is cls + assert 0 in test_module.REGISTER_MAP def test_device_register_overrides_core_on_clash(): - # A device register at a core address wins over the spread-in common one, and - # displaces it from the name view too, so the two views stay consistent. + # A device register at a common address replaces it in the address space. mod = create_device_module( "device: Clash\nregisters:\n Shadow: {address: 0, type: U32, access: Read}\n" ) assert mod.REGISTER_MAP[0].__name__ == "Shadow" assert mod.Shadow.address == 0 - assert not hasattr(mod, "WhoAmI") # the common register it replaced is gone - - -def test_device_register_overrides_core_on_name_clash(): - # Same rule the other way round: the schema claiming a common *name* displaces - # the common register entirely, rather than leaving the address view stale. - mod = create_device_module( - "device: Clash\nregisters:\n WhoAmI: {address: 40, type: U16, access: Read}\n" - ) - assert mod.WhoAmI.address == 40 - assert 0 not in mod.REGISTER_MAP def test_headerless_fragment_builds_default_module(): @@ -111,9 +131,10 @@ def test_headerless_fragment_builds_default_module(): def test_all_covers_registers_and_module_constants(test_module): exported = set(test_module.__all__) assert {"REGISTER_MAP", "WHO_AM_I"} <= exported - assert {"AnalogData", "EncoderMode", "WhoAmI"} <= exported - assert exported - {"REGISTER_MAP", "WHO_AM_I"} == { - cls.__name__ for cls in test_module.REGISTER_MAP.values() + assert {"AnalogData", "EncoderMode"} <= exported + assert "WhoAmI" not in exported # a common register is not re-exported + assert exported - MODULE_CONSTANTS == { + cls.__name__ for cls in test_module.REGISTER_MAP.values() if cls.address >= 32 } @@ -122,7 +143,7 @@ def test_module_is_not_registered_in_sys_modules(test_module): assert sys.modules.get(test_module.__name__) is not test_module -def test_emitted_registers_carry_the_module_name(test_module): +def test_emitted_registers_carry_module_name(test_module): assert test_module.AnalogData.__module__ == "Tests" From edeb2f916ad5ac98de2797f5baf4c40e7af0f154 Mon Sep 17 00:00:00 2001 From: glopesdev Date: Mon, 10 Aug 2026 18:51:32 +0100 Subject: [PATCH 7/7] Take schema text rather than a parsed model create_device_module accepts str alone, dropping the DeviceModel overload. The parameter is renamed from source to text, matching the sibling parse_device_schema. The word source is what the path-taking convention uses, so it suggested a path where contents were expected. --- .../harp-device/src/harp/device/_emit_module.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/packages/harp-device/src/harp/device/_emit_module.py b/src/packages/harp-device/src/harp/device/_emit_module.py index 5c5c3a0..19e23db 100644 --- a/src/packages/harp-device/src/harp/device/_emit_module.py +++ b/src/packages/harp-device/src/harp/device/_emit_module.py @@ -8,14 +8,13 @@ """ import types -from typing import Any, Mapping, Optional, Protocol, Union, runtime_checkable +from typing import Any, Mapping, Optional, Protocol, runtime_checkable from harp.protocol import RegisterBase from ._register_map import REGISTER_MAP as CORE_REGISTER_MAP from ._schema import create_registers, parse_device_schema from ._schema._emit import ConverterValue -from ._schema._model import DeviceModel #: Module name used when the schema carries no ``device`` header. _DEFAULT_NAME = "Device" @@ -56,14 +55,14 @@ def __getattr__(self, name: str) -> type[RegisterBase[Any]]: def create_device_module( - source: Union[str, DeviceModel], + text: str, *, name: Optional[str] = None, converters: Optional[Mapping[str, ConverterValue]] = None, strict: bool = True, exclude_private: bool = True, ) -> DeviceModule: - """Emit a module of register classes from a device schema. + """Emit a module of register classes from ``device.yml`` text. The module names the registers the schema declares, so ``behavior.AnalogData`` resolves while a common register such as ``WhoAmI`` is imported from @@ -81,14 +80,15 @@ def create_device_module( address clash the device's register replaces the common one in ``REGISTER_MAP``. ``exclude_private=True`` drops registers whose DSL ``visibility`` is ``private``. - The module is **not** registered in :data:`sys.modules`, so it cannot be reached - by ``import`` and two schemas may share a name without clashing. Bind it - yourself:: + ``text`` is the schema itself rather than a path to it, matching + :func:`parse_device_schema`, so read the file first. The module is **not** + registered in :data:`sys.modules`, so it cannot be reached by ``import`` and two + schemas may share a name without clashing. Bind it yourself:: behavior = create_device_module(Path("device.yml").read_text()) behavior.AnalogData """ - device = source if isinstance(source, DeviceModel) else parse_device_schema(source) + device = parse_device_schema(text) registers = create_registers( device, converters=converters, strict=strict, exclude_private=exclude_private )