Skip to content

watlowlib.testing

Public testing surface — FakeTransport, FakeSlave, fixture records (StdBusRound, ModbusRound, Fixture), load_fixture, and controller_from_fixture. See Testing.

Public surface

watlowlib.testing

Public testing surface for downstream packages.

The test seam is a contract: anyone consuming :mod:watlowlib can import watlowlib.testing and write facade-level tests against captured PM3 round-trips without touching a serial port.

What's here:

  • :class:FakeTransport — re-export of the in-process scripted transport (fixture-replay grade: ordered queue + unmatched-write capture).
  • :class:FakeSlave — stub :class:anymodbus.Slave for the Modbus facade path (the equivalent shape for variants that emit a :class:ModbusOp rather than wire bytes).
  • :class:StdBusRound / :class:ModbusRound / :class:Fixture — typed records for one captured round-trip and a captured scenario.
  • :func:load_fixture — JSONL file → :class:Fixture.
  • :func:controller_from_fixture — JSONL file → opened :class:Controller ready for facade-level assertions.
  • :func:parse_arrow_fixture — plaintext arrow file → dict[bytes, bytes] script map for :class:FakeTransport. The arrow format matches :mod:alicatlib.testing and :mod:sartoriuslib.testing for cross-package consistency.
  • :func:FakeTransportFromArrowFixture — plaintext arrow file → built :class:FakeTransport.

Two fixture formats are supported:

Plaintext arrow (Std Bus only — recommended for code review)::

# scenario: read_pv
> 55 FF 05 10 00 00 06 E8 01 03 01 04 01 01 E3 99
< 55 FF 06 00 10 00 0B 88 02 03 01 04 01 01 08 45 1E 3C D4 A7 28

Bytes are space-separated hex. # introduces comments; blank lines are ignored. Each > line names a request; one or more following < lines name the reply (concatenated into one scripted reply).

JSONL (rich — required for Modbus, optional for Std Bus). One JSON object per line. The first line may be a header recording the protocol and serial framing; everything else is a round.

Std Bus round::

{
    "protocol": "stdbus",
    "label": "read_pv",
    "request_hex": "55FF051000...",
    "response_hex": "55FF0600...",
}

Modbus round::

{
    "protocol": "modbus_rtu",
    "label": "read_pv",
    "method": "read_holding_registers",
    "address": 360,
    "count": 2,
    "response_words": [17299, 29054],
}

{
    "protocol": "modbus_rtu",
    "label": "set_setpoint",
    "method": "write_registers",
    "address": 2160,
    "values": [17348, 0],
}

Optional header ("kind": "header") sets address, baudrate, and parity for the whole capture::

{
    "kind": "header",
    "protocol": "stdbus",
    "address": 1,
    "baudrate": 38400,
    "parity": "none",
}

FakeSlave

FakeSlave(script=None)

Scripted :class:anymodbus.Slave stand-in for tests.

Mirrors the surface :class:watlowlib.protocol.modbus.client.ModbusProtocolClient actually calls (read_holding_registers, read_input_registers, write_register, write_registers) and records every call. Tests assert on :attr:reads and :attr:writes to verify the :class:ModbusOp lowered correctly.

Parameters:

Name Type Description Default
script Mapping[tuple[str, int], ScriptedSlaveEntry] | None

(method, address) → reply map. method is one of the four call names above. The reply is a tuple of register words, an anymodbus exception class (raised at call time, with the right constructor args), or None (treat the call as a no-op success). Missing entries surface a :class:KeyError so an unscripted call fails the test rather than returning empty results.

None
Source code in src/watlowlib/transport/fake.py
def __init__(self, script: Mapping[tuple[str, int], ScriptedSlaveEntry] | None = None) -> None:
    self._script: dict[tuple[str, int], ScriptedSlaveEntry] = dict(script or {})
    self.reads: list[tuple[str, int, int]] = []
    self.writes: list[tuple[str, int, tuple[int, ...]]] = []

add_script

add_script(method, address, reply)

Register or overwrite a scripted reply for (method, address).

Source code in src/watlowlib/transport/fake.py
def add_script(
    self,
    method: str,
    address: int,
    reply: ScriptedSlaveEntry,
) -> None:
    """Register or overwrite a scripted reply for ``(method, address)``."""
    self._script[(method, address)] = reply

FakeTransport

FakeTransport(
    script=None,
    *,
    queue=None,
    label="fake://test",
    latency_s=0.0,
)

Scripted :class:Transport for tests.

Parameters:

Name Type Description Default
script Mapping[bytes, ScriptedReply] | None

Mapping of write_bytes → reply. Every scripted write queues the corresponding reply into the read buffer. Unknown writes are recorded but produce no reply; the next read times out.

None
label str

Identifier used in errors.

'fake://test'
latency_s float

Per-operation artificial delay, useful for simulating a slow device.

0.0
Source code in src/watlowlib/transport/fake.py
def __init__(
    self,
    script: Mapping[bytes, ScriptedReply] | None = None,
    *,
    queue: Iterable[tuple[bytes, ScriptedReply]] | None = None,
    label: str = "fake://test",
    latency_s: float = 0.0,
) -> None:
    self._script: dict[bytes, ScriptedReply] = dict(script or {})
    self._queue: deque[tuple[bytes, ScriptedReply]] = deque(queue or [])
    self._writes: list[bytes] = []
    self._unmatched: list[bytes] = []
    self._read_buffer = bytearray()
    self._is_open = False
    self._label = label
    self._latency_s = latency_s
    self._force_read_timeout = False
    self._force_write_timeout = False

remaining_queue property

remaining_queue

Queue entries that have not been consumed yet.

unmatched_writes property

unmatched_writes

Writes that didn't match any scripted reply, in order.

A test can assert transport.unmatched_writes == () to catch accidentally-unscripted traffic — the corresponding read would have timed out, but a precise assertion fails faster and points at the right call.

writes property

writes

Every write payload recorded since construction, in order.

add_script

add_script(command, reply)

Register or overwrite a scripted reply for command.

Source code in src/watlowlib/transport/fake.py
def add_script(self, command: bytes, reply: ScriptedReply) -> None:
    """Register or overwrite a scripted reply for ``command``."""
    self._script[bytes(command)] = reply

extend_queue

extend_queue(rounds)

Append more ordered (write, reply) pairs to the FIFO queue.

Source code in src/watlowlib/transport/fake.py
def extend_queue(self, rounds: Iterable[tuple[bytes, ScriptedReply]]) -> None:
    """Append more ordered ``(write, reply)`` pairs to the FIFO queue."""
    self._queue.extend(rounds)

feed

feed(data)

Push unsolicited bytes into the read buffer.

Useful for simulating a device that left chatter on the line which the protocol client has to drain on recovery.

Source code in src/watlowlib/transport/fake.py
def feed(self, data: bytes) -> None:
    """Push unsolicited bytes into the read buffer.

    Useful for simulating a device that left chatter on the line
    which the protocol client has to drain on recovery.
    """
    self._read_buffer.extend(data)

force_read_timeout

force_read_timeout(enabled=True)

Force the next read to raise :class:WatlowTimeoutError.

Source code in src/watlowlib/transport/fake.py
def force_read_timeout(self, enabled: bool = True) -> None:
    """Force the next read to raise :class:`WatlowTimeoutError`."""
    self._force_read_timeout = enabled

force_write_timeout

force_write_timeout(enabled=True)

Force the next :meth:write to raise :class:WatlowTimeoutError.

Source code in src/watlowlib/transport/fake.py
def force_write_timeout(self, enabled: bool = True) -> None:
    """Force the next :meth:`write` to raise :class:`WatlowTimeoutError`."""
    self._force_write_timeout = enabled

Fixture dataclass

Fixture(
    protocol,
    address=1,
    serial_settings=(
        lambda: SerialSettings(port="fake://fixture")
    )(),
    stdbus_rounds=(),
    modbus_rounds=(),
)

A captured scenario — one wire protocol, many rounds.

fake_slave

fake_slave()

Build a :class:FakeSlave scripted with the Modbus rounds.

Read rounds populate the (method, address) → response_words script entry; write rounds need no entry — :class:FakeSlave treats unscripted writes as silent successes and records them on :attr:FakeSlave.writes so tests can assert on the lowered register words.

Source code in src/watlowlib/testing.py
def fake_slave(self) -> FakeSlave:
    """Build a :class:`FakeSlave` scripted with the Modbus rounds.

    Read rounds populate the ``(method, address) → response_words``
    script entry; write rounds need no entry — :class:`FakeSlave`
    treats unscripted writes as silent successes and records them
    on :attr:`FakeSlave.writes` so tests can assert on the lowered
    register words.
    """
    slave = FakeSlave()
    for round_ in self.modbus_rounds:
        if round_.response_words:
            slave.add_script(round_.method, round_.address, round_.response_words)
    return slave

fake_transport

fake_transport(*, label='fake://fixture')

Build a :class:FakeTransport scripted with the Std Bus rounds.

Each captured request maps to its captured response in the dict-based script, so a test that issues the same parameter read twice gets the same response both times — appropriate for facade smoke tests where ordering doesn't matter.

Source code in src/watlowlib/testing.py
def fake_transport(self, *, label: str = "fake://fixture") -> FakeTransport:
    """Build a :class:`FakeTransport` scripted with the Std Bus rounds.

    Each captured request maps to its captured response in the
    dict-based script, so a test that issues the same parameter
    read twice gets the same response both times — appropriate for
    facade smoke tests where ordering doesn't matter.
    """
    script = {round_.request: round_.response for round_ in self.stdbus_rounds}
    return FakeTransport(script, label=label)

ModbusRound dataclass

ModbusRound(
    label,
    method,
    address,
    count,
    response_words=(),
    values=(),
)

One captured Modbus call.

For reads response_words carries the register tuple the slave returned; values is empty. For writes values carries the register words the controller sent and response_words is empty (a successful write returns no payload).

StdBusRound dataclass

StdBusRound(label, request, response)

One captured Std Bus request/response pair.

FakeTransportFromArrowFixture

FakeTransportFromArrowFixture(path, *, label=None)

Load a plaintext-arrow fixture into a built :class:FakeTransport.

Convenience wrapper around :func:parse_arrow_fixture plus :class:FakeTransport construction. The returned transport is not opened — the caller awaits .open() as usual.

Parameters:

Name Type Description Default
path str | Path

Path to the .txt fixture.

required
label str | None

Optional override for :attr:FakeTransport.label; defaults to "fixture://<basename>" so error contexts point at the actual fixture during failures.

None
Source code in src/watlowlib/testing.py
def FakeTransportFromArrowFixture(  # noqa: N802 — public factory, title-case matches the class it returns
    path: str | Path,
    *,
    label: str | None = None,
) -> FakeTransport:
    """Load a plaintext-arrow fixture into a built :class:`FakeTransport`.

    Convenience wrapper around :func:`parse_arrow_fixture` plus
    :class:`FakeTransport` construction. The returned transport is
    not opened — the caller awaits ``.open()`` as usual.

    Args:
        path: Path to the ``.txt`` fixture.
        label: Optional override for :attr:`FakeTransport.label`;
            defaults to ``"fixture://<basename>"`` so error contexts
            point at the actual fixture during failures.
    """
    script = parse_arrow_fixture(path)
    resolved_label = label if label is not None else f"fixture://{Path(path).name}"
    return FakeTransport(script, label=resolved_label)

controller_from_fixture async

controller_from_fixture(path, *, profile=EZZONE_PROFILE)

Build an unopened :class:Controller scripted by path.

Returned in unopened form so the caller drives lifecycle through async with. Std Bus fixtures wire through :class:FakeTransport; Modbus fixtures wire through :class:FakeSlave (the :class:Transport shim only carries lifecycle, since the Modbus protocol client talks to its slave provider directly).

Parameters:

Name Type Description Default
path str | Path

JSONL fixture path.

required
profile DeviceProfile

Device profile to decode against. Defaults to :data:~watlowlib.devices.profile.EZZONE_PROFILE; pass :data:~watlowlib.devices.profile.SERIES_SD_PROFILE so an SD fixture decodes against SD_PARAMETERS.

EZZONE_PROFILE
Source code in src/watlowlib/testing.py
async def controller_from_fixture(
    path: str | Path,
    *,
    profile: DeviceProfile = EZZONE_PROFILE,
) -> Controller:
    """Build an unopened :class:`Controller` scripted by ``path``.

    Returned in unopened form so the caller drives lifecycle through
    ``async with``. Std Bus fixtures wire through :class:`FakeTransport`;
    Modbus fixtures wire through :class:`FakeSlave` (the
    :class:`Transport` shim only carries lifecycle, since the Modbus
    protocol client talks to its slave provider directly).

    Args:
        path: JSONL fixture path.
        profile: Device profile to decode against. Defaults to
            :data:`~watlowlib.devices.profile.EZZONE_PROFILE`; pass
            :data:`~watlowlib.devices.profile.SERIES_SD_PROFILE` so an
            SD fixture decodes against ``SD_PARAMETERS``.
    """
    fixture = load_fixture(path)
    if fixture.protocol is ProtocolKind.STDBUS:
        transport: Transport = fixture.fake_transport()
        return await _open_fixture_controller(
            transport,
            protocol=ProtocolKind.STDBUS,
            address=fixture.address,
            serial_settings=fixture.serial_settings,
            profile=profile,
        )

    # Modbus path: build the protocol client over a FakeSlave directly,
    # and hand the controller a lifecycle-only :class:`FakeTransport`.
    slave = fixture.fake_slave()
    transport = FakeTransport(label="fake://fixture-modbus")
    # FakeSlave is structurally compatible with the
    # :class:`SlaveLike` Protocol the Modbus client expects.
    client = ModbusProtocolClient(
        slave_provider=lambda _addr: slave,
        port=transport.label,
    )
    session = Session(
        client,
        profile=profile,
        address=fixture.address,
        port=transport.label,
    )
    return Controller(session, transport, serial_settings=fixture.serial_settings)

load_fixture

load_fixture(path)

Parse path as a JSONL fixture and return a :class:Fixture.

The first line may be a header ({"kind": "header", ...}) that sets address and the serial framing for the capture. Subsequent lines are rounds; their protocol field must agree with the header (or with the first round, if the header is omitted).

Raises:

Type Description
ValueError

malformed JSONL, missing fields, or a row whose protocol field disagrees with the rest of the file.

Source code in src/watlowlib/testing.py
def load_fixture(path: str | Path) -> Fixture:
    """Parse ``path`` as a JSONL fixture and return a :class:`Fixture`.

    The first line may be a header (``{"kind": "header", ...}``) that
    sets ``address`` and the serial framing for the capture. Subsequent
    lines are rounds; their ``protocol`` field must agree with the
    header (or with the first round, if the header is omitted).

    Raises:
        ValueError: malformed JSONL, missing fields, or a row whose
            ``protocol`` field disagrees with the rest of the file.
    """
    p = Path(path)
    rows: list[dict[str, Any]] = []
    for line_no, raw in enumerate(p.read_text(encoding="utf-8").splitlines(), start=1):
        line = raw.strip()
        if not line or line.startswith("#"):
            continue
        try:
            rows.append(json.loads(line))
        except json.JSONDecodeError as exc:
            raise ValueError(f"{p}:{line_no}: not valid JSON: {exc}") from exc
    if not rows:
        raise ValueError(f"{p}: fixture is empty")

    header: dict[str, Any] = {}
    body: list[dict[str, Any]] = list(rows)
    if rows[0].get("kind") == "header":
        header = rows[0]
        body = rows[1:]

    protocol = _resolve_protocol(header, body, source=p)

    address = int(header.get("address", 1) or 1)
    serial_settings = _settings_from_header(header)

    if protocol is ProtocolKind.STDBUS:
        stdbus_rounds = tuple(_parse_stdbus_row(row, p, idx) for idx, row in enumerate(body, 2))
        return Fixture(
            protocol=protocol,
            address=address,
            serial_settings=serial_settings,
            stdbus_rounds=stdbus_rounds,
        )

    modbus_rounds = tuple(_parse_modbus_row(row, p, idx) for idx, row in enumerate(body, 2))
    return Fixture(
        protocol=protocol,
        address=address,
        serial_settings=serial_settings,
        modbus_rounds=modbus_rounds,
    )

open_test_controller async

open_test_controller(
    transport,
    *,
    protocol=ProtocolKind.STDBUS,
    address=1,
    serial_settings=None,
    profile=EZZONE_PROFILE,
    wire_temperature_unit=None,
)

Build an opened :class:Controller over an existing :class:Transport.

Test-surface companion to :func:watlowlib.open_device. Production code always goes through :func:open_device (which opens a real serial port and runs auto-detect when asked); tests drive the facade through a :class:FakeTransport and use this helper to skip the serial-port plumbing.

Parameters:

Name Type Description Default
transport Transport

The transport to wire the controller to. Typically a :class:FakeTransport scripted with captured round- trips.

required
protocol ProtocolKind

Wire protocol. AUTO is rejected — auto-detection is a real-port-only path on :func:open_device.

STDBUS
address int

Bus address.

1
serial_settings SerialSettings | None

Optional override; defaults to a fake://test placeholder so call sites that don't care about framing stay terse.

None
profile DeviceProfile

Device profile to decode against. Defaults to :data:~watlowlib.devices.profile.EZZONE_PROFILE; pass :data:~watlowlib.devices.profile.SERIES_SD_PROFILE to decode an SD fixture against SD_PARAMETERS.

EZZONE_PROFILE
wire_temperature_unit Unit | None

Already-coerced :class:Unit to drive :class:Reading.unit / :class:Sample.unit for temperature parameters. Tests usually leave this None.

None

Returns:

Type Description
Controller

An opened :class:Controller. The caller is responsible for

Controller

closing it (typically via async with).

Source code in src/watlowlib/testing.py
async def open_test_controller(
    transport: Transport,
    *,
    protocol: ProtocolKind = ProtocolKind.STDBUS,
    address: int = 1,
    serial_settings: SerialSettings | None = None,
    profile: DeviceProfile = EZZONE_PROFILE,
    wire_temperature_unit: Unit | None = None,
) -> Controller:
    """Build an opened :class:`Controller` over an existing :class:`Transport`.

    Test-surface companion to :func:`watlowlib.open_device`. Production
    code always goes through :func:`open_device` (which opens a real
    serial port and runs auto-detect when asked); tests drive the
    facade through a :class:`FakeTransport` and use this helper to
    skip the serial-port plumbing.

    Args:
        transport: The transport to wire the controller to. Typically
            a :class:`FakeTransport` scripted with captured round-
            trips.
        protocol: Wire protocol. ``AUTO`` is rejected — auto-detection
            is a real-port-only path on :func:`open_device`.
        address: Bus address.
        serial_settings: Optional override; defaults to a
            ``fake://test`` placeholder so call sites that don't care
            about framing stay terse.
        profile: Device profile to decode against. Defaults to
            :data:`~watlowlib.devices.profile.EZZONE_PROFILE`; pass
            :data:`~watlowlib.devices.profile.SERIES_SD_PROFILE` to
            decode an SD fixture against ``SD_PARAMETERS``.
        wire_temperature_unit: Already-coerced :class:`Unit` to drive
            :class:`Reading.unit` / :class:`Sample.unit` for
            temperature parameters. Tests usually leave this ``None``.

    Returns:
        An opened :class:`Controller`. The caller is responsible for
        closing it (typically via ``async with``).
    """
    settings = serial_settings or SerialSettings(port=transport.label)
    return await _open_fixture_controller(
        transport,
        protocol=protocol,
        address=address,
        serial_settings=settings,
        profile=profile,
        wire_temperature_unit=wire_temperature_unit,
    )

parse_arrow_fixture

parse_arrow_fixture(path)

Parse a plaintext-arrow fixture into a :class:FakeTransport script.

The fixture format is intentionally human-skimmable so captured Std Bus traffic round-trips through code review (cross-package convention shared with :mod:alicatlib.testing and :mod:sartoriuslib.testing)::

# scenario: read_pv (PM3, parameter 4001)
> 55 FF 05 10 00 00 06 E8 01 03 01 04 01 01 E3 99
< 55 FF 06 00 10 00 0B 88 02 03 01 04 01 01 08 45 1E 3C D4 A7 28

Parsing rules:

  • Lines starting with # are comments; ignored.
  • Blank lines are ignored.
  • > introduces a request — bytes after the marker are decoded as space-separated hex. Whitespace within a payload is ignored so callers can group bytes for readability.
  • < introduces one reply — same hex encoding. Multiple < lines after a single > concatenate into one scripted reply (useful when a logical reply was captured across multiple reads).
  • Duplicate > entries are a fixture error rather than a silent overwrite.

Returns:

Type Description
dict[bytes, bytes]

Mapping request_bytes → reply_bytes ready to feed

dict[bytes, bytes]

class:FakeTransport.

Raises:

Type Description
ValueError

On malformed lines, a < before any >, or a duplicate > entry. Every error message names the offending line number.

FileNotFoundError

Via the underlying :meth:Path.read_text.

Source code in src/watlowlib/testing.py
def parse_arrow_fixture(path: str | Path) -> dict[bytes, bytes]:
    r"""Parse a plaintext-arrow fixture into a :class:`FakeTransport` script.

    The fixture format is intentionally human-skimmable so captured
    Std Bus traffic round-trips through code review (cross-package
    convention shared with :mod:`alicatlib.testing` and
    :mod:`sartoriuslib.testing`)::

        # scenario: read_pv (PM3, parameter 4001)
        > 55 FF 05 10 00 00 06 E8 01 03 01 04 01 01 E3 99
        < 55 FF 06 00 10 00 0B 88 02 03 01 04 01 01 08 45 1E 3C D4 A7 28

    Parsing rules:

    - Lines starting with ``#`` are comments; ignored.
    - Blank lines are ignored.
    - ``>`` introduces a request — bytes after the marker are decoded
      as space-separated hex. Whitespace within a payload is ignored
      so callers can group bytes for readability.
    - ``<`` introduces one reply — same hex encoding. Multiple ``<``
      lines after a single ``>`` concatenate into one scripted reply
      (useful when a logical reply was captured across multiple
      reads).
    - Duplicate ``>`` entries are a fixture error rather than a
      silent overwrite.

    Returns:
        Mapping ``request_bytes → reply_bytes`` ready to feed
        :class:`FakeTransport`.

    Raises:
        ValueError: On malformed lines, a ``<`` before any ``>``, or
            a duplicate ``>`` entry. Every error message names the
            offending line number.
        FileNotFoundError: Via the underlying :meth:`Path.read_text`.
    """
    fixture_path = Path(path)
    script: dict[bytes, bytes] = {}
    current_send: bytes | None = None
    current_reply_chunks: list[bytes] = []

    def _flush() -> None:
        nonlocal current_send, current_reply_chunks
        if current_send is None:
            return
        if current_send in script:
            raise ValueError(
                f"{fixture_path}: duplicate send entry {current_send!r}",
            )
        script[current_send] = b"".join(current_reply_chunks)
        current_send = None
        current_reply_chunks = []

    text = fixture_path.read_text(encoding="utf-8")
    for line_number, line in _iter_semantic_lines(text.splitlines()):
        lean = line.lstrip()
        if lean.startswith(">"):
            _flush()
            current_send = _hex_payload(
                line,
                ">",
                source=fixture_path,
                line_no=line_number,
            )
        elif lean.startswith("<"):
            if current_send is None:
                raise ValueError(
                    f"{fixture_path}:{line_number}: '<' line without preceding '>'",
                )
            current_reply_chunks.append(
                _hex_payload(line, "<", source=fixture_path, line_no=line_number),
            )
        else:
            raise ValueError(
                f"{fixture_path}:{line_number}: unrecognized line {line!r}; "
                f"lines must start with '>', '<', or '#'",
            )
    _flush()
    return script