From 262aeafc066af2b6ceded1ca9882c70a5cb90a0f Mon Sep 17 00:00:00 2001 From: Kevin Weiss Date: Mon, 21 Sep 2026 14:39:03 +0200 Subject: [PATCH 1/2] feat(driver_cfg): add DriverCfg base for tool config sections A DriverCfg holds exactly one driver's constructor keyword arguments, so a config section can be declared next to the tool it describes and handed to that tool with as_kwargs(). None is dropped at every layer, keeping the "None means use the known default" contract the drivers rely on. The extra dict is the escape hatch for driver options a config has not been taught yet and for one-off debugging sessions. It is merged key by key, so an unknown key raises TypeError from the driver constructor rather than being silently swallowed. Lives in lob-hlpr because it has no install requirements, which keeps lob-cfg out of the driver repositories that will declare sections. Co-Authored-By: Claude Opus 5 (1M context) --- src/lob_hlpr/__init__.py | 2 + src/lob_hlpr/driver_cfg.py | 65 +++++++++++++++++++++++++ tests/test_driver_cfg.py | 98 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 165 insertions(+) create mode 100644 src/lob_hlpr/driver_cfg.py create mode 100644 tests/test_driver_cfg.py diff --git a/src/lob_hlpr/__init__.py b/src/lob_hlpr/__init__.py index d8ec8af..94b6cf7 100644 --- a/src/lob_hlpr/__init__.py +++ b/src/lob_hlpr/__init__.py @@ -4,11 +4,13 @@ """ from lob_hlpr.cli import DeprecatedAliasAction, add_renamed_argument +from lob_hlpr.driver_cfg import DriverCfg from lob_hlpr.hlpr import LobHlpr from lob_hlpr.lib_types import FirmwareID, FirmwareVersion __all__ = [ "LobHlpr", + "DriverCfg", "FirmwareID", "FirmwareVersion", "add_renamed_argument", diff --git a/src/lob_hlpr/driver_cfg.py b/src/lob_hlpr/driver_cfg.py new file mode 100644 index 0000000..4544590 --- /dev/null +++ b/src/lob_hlpr/driver_cfg.py @@ -0,0 +1,65 @@ +"""Base for driver configuration sections.""" + +from dataclasses import dataclass, field, fields +from typing import Any + + +@dataclass +class DriverCfg: + """Configuration of a single tool, forwardable to its constructor. + + The fields are exactly the driver's constructor keyword arguments. If a + value does not fit the constructor, the constructor is what changes, so a + section can never drift away from the tool it describes. + + Deliberately independent of any configuration file format. A config class + picks these up as nested sections, but a section is equally usable alone:: + + >>> @dataclass + ... class PpkCfg(DriverCfg): + ... port: str | None = None + ... voltage: float | None = None + >>> PpkCfg(port="/dev/ttyUSB0").as_kwargs() + {'port': '/dev/ttyUSB0'} + """ + + extra: dict[str, Any] = field(default_factory=dict) + """Undocumented keyword arguments passed straight to the driver. + + Escape hatch for a driver option this configuration has not been taught + yet, and for one-off debugging sessions. Nothing here is validated: an + unknown key raises ``TypeError`` from the driver constructor, which is the + intended feedback. + """ + + def as_kwargs(self, **overrides: Any) -> dict[str, Any]: + """Returns the constructor keyword arguments for this tool. + + Precedence, lowest first: the declared fields, then :attr:`extra`, + then *overrides*. ``None`` is dropped at every layer so the driver's + own default or autodiscovery applies, keeping the "None means use the + known default" contract. + + Args: + **overrides: Values that win over the declared fields and + :attr:`extra`, e.g. a command line argument. + + Returns: + The keyword arguments to build the driver with. + + Example: + >>> @dataclass + ... class LaserCfg(DriverCfg): + ... host: str = "127.0.0.1" + ... port: int = 3000 + >>> cfg = LaserCfg(extra={"timeout": 2.0}) + >>> cfg.as_kwargs(port=4000) + {'host': '127.0.0.1', 'port': 4000, 'timeout': 2.0} + """ + kwargs = { + f.name: getattr(self, f.name) for f in fields(self) if f.name != "extra" + } + kwargs = {k: v for k, v in kwargs.items() if v is not None} + kwargs.update(self.extra) + kwargs.update({k: v for k, v in overrides.items() if v is not None}) + return kwargs diff --git a/tests/test_driver_cfg.py b/tests/test_driver_cfg.py new file mode 100644 index 0000000..1c32050 --- /dev/null +++ b/tests/test_driver_cfg.py @@ -0,0 +1,98 @@ +"""Tests for the DriverCfg base.""" + +from dataclasses import dataclass + +import pytest + +from lob_hlpr import DriverCfg + + +@dataclass +class _ExampleCfg(DriverCfg): + """A tool with one optional and one defaulted argument.""" + + port: str | None = None + baudrate: int = 115200 + verbose: bool = False + + +def test_defaults_drop_none(): + """None means "use the driver default" and is not passed on.""" + assert _ExampleCfg().as_kwargs() == {"baudrate": 115200, "verbose": False} + + +def test_false_is_kept(): + """Only None is dropped, a falsy value is a real setting.""" + assert _ExampleCfg(verbose=False).as_kwargs()["verbose"] is False + + +def test_declared_fields_are_passed(): + """Every declared field reaches the driver.""" + assert _ExampleCfg(port="/dev/ttyUSB0").as_kwargs() == { + "port": "/dev/ttyUSB0", + "baudrate": 115200, + "verbose": False, + } + + +def test_extra_is_merged_but_not_itself_a_kwarg(): + """Extra is forwarded key by key, never as an ``extra=`` argument.""" + kwargs = _ExampleCfg(extra={"parity": "N"}).as_kwargs() + assert kwargs["parity"] == "N" + assert "extra" not in kwargs + + +def test_extra_wins_over_declared_fields(): + """A debugging session can override a documented value.""" + assert ( + _ExampleCfg(baudrate=9600, extra={"baudrate": 921600}).as_kwargs()["baudrate"] + == 921600 + ) + + +def test_overrides_win_over_extra(): + """An explicit argument beats both the field and extra.""" + cfg = _ExampleCfg(baudrate=9600, extra={"baudrate": 921600}) + assert cfg.as_kwargs(baudrate=4800)["baudrate"] == 4800 + + +def test_none_override_does_not_clear_a_field(): + """An unset optional argument must not wipe a configured value.""" + cfg = _ExampleCfg(port="/dev/ttyUSB0") + assert cfg.as_kwargs(port=None)["port"] == "/dev/ttyUSB0" + + +def test_extra_defaults_are_independent(): + """Each instance gets its own extra dict.""" + first, second = _ExampleCfg(), _ExampleCfg() + first.extra["parity"] = "N" + assert second.extra == {} + + +def test_kwargs_build_the_driver(): + """The whole point: the result is directly usable as **kwargs.""" + + class _Driver: + def __init__(self, port=None, baudrate=9600, verbose=False, parity="E"): + self.port, self.baudrate = port, baudrate + self.verbose, self.parity = verbose, parity + + cfg = _ExampleCfg(port="/dev/ttyUSB0", extra={"parity": "N"}) + driver = _Driver(**cfg.as_kwargs()) + assert (driver.port, driver.baudrate, driver.parity) == ( + "/dev/ttyUSB0", + 115200, + "N", + ) + + +def test_unknown_extra_key_raises_from_the_driver(): + """An unknown extra is not swallowed, it fails where it is used.""" + + class _Driver: + def __init__(self, port=None, baudrate=9600, verbose=False): + pass + + cfg = _ExampleCfg(extra={"nonsense": 1}) + with pytest.raises(TypeError): + _Driver(**cfg.as_kwargs()) From 279bc56eb1a79748ea42766473befe121d0ef477 Mon Sep 17 00:00:00 2001 From: Kevin Weiss Date: Wed, 23 Sep 2026 06:28:18 +0200 Subject: [PATCH 2/2] fix(driver_cfg): keep extra keyword only and drop None from it Review findings on #20, both real. extra carried a default and came first, so a subclass could not declare a driver argument without one: the dataclass raised "non-default argument follows default argument". It is keyword only now, which is what Cfg already does for its own extra and source. The extra layer skipped the None filtering the other two layers apply, so extra={"timeout": None} reached the driver and overrode its default or its autodiscovery, which is exactly what the "None means use the known default" contract exists to prevent. Co-Authored-By: Claude Opus 5 (1M context) --- src/lob_hlpr/driver_cfg.py | 7 +++++-- tests/test_driver_cfg.py | 21 +++++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/lob_hlpr/driver_cfg.py b/src/lob_hlpr/driver_cfg.py index 4544590..ad76eb7 100644 --- a/src/lob_hlpr/driver_cfg.py +++ b/src/lob_hlpr/driver_cfg.py @@ -23,13 +23,16 @@ class DriverCfg: {'port': '/dev/ttyUSB0'} """ - extra: dict[str, Any] = field(default_factory=dict) + extra: dict[str, Any] = field(default_factory=dict, kw_only=True) """Undocumented keyword arguments passed straight to the driver. Escape hatch for a driver option this configuration has not been taught yet, and for one-off debugging sessions. Nothing here is validated: an unknown key raises ``TypeError`` from the driver constructor, which is the intended feedback. + + Keyword only, so a subclass can still declare a driver argument that has + no default. """ def as_kwargs(self, **overrides: Any) -> dict[str, Any]: @@ -60,6 +63,6 @@ def as_kwargs(self, **overrides: Any) -> dict[str, Any]: f.name: getattr(self, f.name) for f in fields(self) if f.name != "extra" } kwargs = {k: v for k, v in kwargs.items() if v is not None} - kwargs.update(self.extra) + kwargs.update({k: v for k, v in self.extra.items() if v is not None}) kwargs.update({k: v for k, v in overrides.items() if v is not None}) return kwargs diff --git a/tests/test_driver_cfg.py b/tests/test_driver_cfg.py index 1c32050..f6e0db4 100644 --- a/tests/test_driver_cfg.py +++ b/tests/test_driver_cfg.py @@ -96,3 +96,24 @@ def __init__(self, port=None, baudrate=9600, verbose=False): cfg = _ExampleCfg(extra={"nonsense": 1}) with pytest.raises(TypeError): _Driver(**cfg.as_kwargs()) + + +def test_none_in_extra_is_dropped(): + """Extra follows the same contract, a None there is not a setting.""" + assert "timeout" not in _ExampleCfg(extra={"timeout": None}).as_kwargs() + + +def test_extra_none_does_not_unset_a_field(): + """A None in extra leaves the declared value alone.""" + cfg = _ExampleCfg(baudrate=9600, extra={"baudrate": None}) + assert cfg.as_kwargs()["baudrate"] == 9600 + + +def test_subclass_may_declare_a_required_argument(): + """Extra is keyword only, so it does not block a required field.""" + + @dataclass + class _RequiredCfg(DriverCfg): + port: str + + assert _RequiredCfg("/dev/ttyUSB0").as_kwargs() == {"port": "/dev/ttyUSB0"}