Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/lob_hlpr/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
68 changes: 68 additions & 0 deletions src/lob_hlpr/driver_cfg.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
"""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, 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]:
"""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({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
119 changes: 119 additions & 0 deletions tests/test_driver_cfg.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
"""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())


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"}