Refactor Device API to allow binding device's registers - #16
Refactor Device API to allow binding device's registers#16bruno-f-cruz wants to merge 22 commits into
Device API to allow binding device's registers#16Conversation
Narrow the Any-typed inputs of format_bulk / to_buffer / to_file to
PayloadBase | ArrayLike, ArrayLike | None, and MessageType | ArrayLike.
Give Device a base REGISTER_MAP ClassVar default ({}) that generated
subclasses override, and tidy the create_device docstring.
Add a "Generating a Device from a Schema" example walking through `create_device`, including a win/loss discussion of runtime generation vs. pre-generated device packages. Wire it into the examples index and nav, surface `create_device`/`parse_device_schema`/`ConverterContext` in the device API reference, and add a Quickstart to the root README.
a31e01e to
7261bbf
Compare
| _Register = type[RegisterBase[Any]] | ||
|
|
||
|
|
||
| class RegisterNamespace: |
There was a problem hiding this comment.
Can we rename to RegisterMap?
| precise types. | ||
| """ | ||
|
|
||
| def __init__(self, registers: Iterable[_Register]) -> None: |
There was a problem hiding this comment.
Should be possible to make this take no argument other than self, and extract all available registers by class introspection.
| ) | ||
|
|
||
|
|
||
| class CoreRegistersNamespace(RegisterNamespace): |
There was a problem hiding this comment.
maybe rename to CoreRegisters?
| merged: dict[int, type[RegisterBase[Any]]] = dict(cls.registers.by_address) | ||
| for register in cls.__dict__.get("__REGISTERS__", ()): | ||
| merged[register.address] = register | ||
| # ``type.__setattr__`` (not ``cls.registers = ...``) keeps pyright treating | ||
| # ``registers`` as read-only; it shadows the base descriptor on the subclass. | ||
| type.__setattr__(cls, "registers", CoreRegistersNamespace(merged.values())) |
There was a problem hiding this comment.
If we redesign the register map to work with class introspection we should not need the device class to emit __REGISTERS__.
| TODO: hand-maintained for now. Auto-generating it from the upstream | ||
| ``harp-tech/protocol`` JSON schema is deferred until that schema stabilises. |
There was a problem hiding this comment.
Yes, I would defer this until we have a unified C# + Python repo.
|
|
||
| # -- enums ------------------------------------------------------------ | ||
| def _build_enums(self) -> dict[str, Any]: | ||
| # Enum names and members are kept verbatim from the yml. |
There was a problem hiding this comment.
Would be nice to convert them to SCREAMING_SNAKE to align with the typed generated code.
| return _NO_DEFAULT # custom domain interfaceType: no numeric default | ||
|
|
||
| # -- fields ----------------------------------------------------------- | ||
| def _build_field(self, key: str, member: PayloadMember, reg: Register) -> tuple[str, Any]: |
There was a problem hiding this comment.
Would be nice to convert the names of fields to camelCase to align with the typed interface.
| def create_registers( | ||
| source: Union[str, DeviceModel, Registers], | ||
| *, | ||
| converters: Optional[Mapping[str, ConverterValue]] = None, |
There was a problem hiding this comment.
Sounds good to pass a mapping to resolve custom converters, this is runtime emission after all.
|
Closing in favor of #17 |
Closes #10 (register-binding half). Replaces the address-keyed
REGISTER_MAPwith a name-addressable register namespace that works for both runtime-generated (create_device) and statically generated devices, and gives static devices real editor autocomplete and payload-aware types. It also partially addresses a previous concern in #5 (comment) by making registers discoverable from the device itself.What this adds
A device exposes its registers by name through
device.registers, on the class or an instance:CoreRegisterssubclass, and names that class asDevice's type parameter. SubclassingCoreRegistersis what merges in the common Harp registers, through ordinary inheritance (the device's register wins on a name clash).RegisterMap. Attribute access,by_name/by_addressviews, iteration,in,len, and__dir__for REPL discovery. It builds those maps by introspecting the class for register attributes — which is why members are assignments (AnalogData = AnalogData) and notAnalogData: type[AnalogData]annotations: a bare annotation carries no runtime value. Static typing is unaffected; each member still infers astype[AnalogData]. Undeclared attribute access falls back to__getattr__typedtype[RegisterBase[Any]], so a register that only exists at runtime still type-checks.REGISTER_MAPis gone. No address dict to hand-build, no**_CORE_REGISTER_MAPspread. Address lookups move todevice.registers.by_address.Static type hints
CoreRegistersis a typed namespace for the common Harp registers. A statically generated device subclasses it, adds its own registers, and passes the result asDevice's type parameter:With this,
device.registers.AnalogDataautocompletes andread/writeinfer the payload type.Deviceis generic over its register-namespace type precisely so this needs noreportIncompatibleVariableOverridesuppression: a mutable attribute is invariant under override, so re-annotatingregistersin the subclass would be an error, whereas specializing a type parameter is not an override at all. It also means a type checker verifies the assigned instance matches the parameter, so the two can't drift. The parameter has a PEP 696 default (CoreRegisters), so a bareDevicestill meansDevice[CoreRegisters]— that default is whyharp-devicenow depends ontyping-extensions(typing.TypeVaronly grew defaults in 3.13).Runtime devices (
create_device) get the samedevice.registerssurface for free — without static autocomplete, since the names come from the schema at runtime and resolve astype[RegisterBase[Any]].What downstream code generators must emit
A statically generated device package emits, per device:
RegisterBasesubclass (unchanged), at module scope — the namespace'sName = Nameassignments resolve their right-hand side through module globals.CoreRegisters, oneName = Nameassignment per device register. Do not include the common Harp registers; they come from the base.Device[<Namespace>]subclass setting only__whoami__(when known) andregisters = <Namespace>().The reference shape is tests/device/expected_device.py and the hand-written docs/examples/static_device/static_device.py. example.py is a runnable tour of all four cases (root, static, runtime, data loading).
Generators must not: emit
REGISTER_MAP, spread the core registers into the namespace, declare members asName: type[Name]annotations, or re-annotateregistersinstead of passing the namespace as the type parameter.Dataset reader
harp-data'sDatasetReaderis generic over the same namespace type and surfaces it viareader.registers(e.g.reader.registers.by_address), so demultiplexed files can be matched to registers by name or address without a separate map.