Testing helpers — re-exports + fixture-file loader.
Importing from this module keeps downstream test code one import deep:
.. code-block:: python
from alicatlib.testing import FakeTransport, FakeTransportFromFixture
The fixture format is plaintext, intentionally skimmable by humans so
captured hardware sessions round-trip through code review::
# scenario: identify-flow-controller (10v05, MC-100SCCM-D)
> A??M*
< A M01 Alicat Scientific
< A M02 www.example.com
< A M03 +1 555-0000
< A M04 MC-100SCCM-D
...
Parsing rules are deliberately narrow (design §6.2):
- Lines starting with
# are comments; ignored.
- Blank lines are ignored.
> introduces a send — the carriage-return terminator is appended
automatically so the fixture stays readable.
< introduces one reply line (\\r-terminated).
- Multiple
< lines after a single > concatenate into one
:class:FakeTransport scripted reply — the right shape for
multiline commands like ??M*.
- Duplicate
> entries are a file-format error rather than a silent
overwrite.
Not shipped yet: record_session(device, scenario, path), which
captures live hardware traffic into this format. Planned alongside the
hardware integration suite.
FakeTransport
FakeTransport(
script=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 known write queues
the corresponding reply into the read buffer. Unknown writes are
recorded but produce no reply — subsequent reads will then hit
idle_timeout / timeout, which is the intended failure
mode (tests see a real timeout if they forgot to script a
command).
|
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/alicatlib/transport/fake.py
| def __init__(
self,
script: Mapping[bytes, ScriptedReply] | None = None,
*,
label: str = "fake://test",
latency_s: float = 0.0,
) -> None:
self._script: dict[bytes, ScriptedReply] = dict(script or {})
self._writes: 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
self._force_disconnected = False
# Track calls to ``reopen`` so baud-change tests can assert the
# transport observed the reconfiguration. ``None`` until
# :meth:`reopen` is called; then holds the last requested
# baudrate. ``reopen_count`` lets tests distinguish "never
# called" from "called with the default baud".
self._last_reopen_baud: int | None = None
self._reopen_count: int = 0
self._force_reopen_error: bool = False
|
last_reopen_baud
property
Baud rate requested by the most recent :meth:reopen, or None.
reopen_count
property
Number of :meth:reopen calls since construction.
writes
property
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/alicatlib/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
|
feed
Push unsolicited bytes into the read buffer.
Useful for simulating a device that was left streaming, or garbage on
the line that the session must drain on recovery.
Source code in src/alicatlib/transport/fake.py
| def feed(self, data: bytes) -> None:
"""Push unsolicited bytes into the read buffer.
Useful for simulating a device that was left streaming, or garbage on
the line that the session must drain on recovery.
"""
self._read_buffer.extend(data)
|
force_read_timeout
force_read_timeout(enabled=True)
Force the next :meth:read_until to raise AlicatTimeoutError.
Source code in src/alicatlib/transport/fake.py
| def force_read_timeout(self, enabled: bool = True) -> None:
"""Force the next :meth:`read_until` to raise ``AlicatTimeoutError``."""
self._force_read_timeout = enabled
|
force_reopen_error
force_reopen_error(enabled=True)
Force the next :meth:reopen to raise :class:AlicatConnectionError.
Used by :class:Session.change_baud_rate tests to exercise
the BROKEN-state transition without a real serial adapter.
Source code in src/alicatlib/transport/fake.py
| def force_reopen_error(self, enabled: bool = True) -> None:
"""Force the next :meth:`reopen` to raise :class:`AlicatConnectionError`.
Used by :class:`Session.change_baud_rate` tests to exercise
the BROKEN-state transition without a real serial adapter.
"""
self._force_reopen_error = enabled
|
force_write_timeout
force_write_timeout(enabled=True)
Force the next :meth:write to raise AlicatTimeoutError.
Source code in src/alicatlib/transport/fake.py
| def force_write_timeout(self, enabled: bool = True) -> None:
"""Force the next :meth:`write` to raise ``AlicatTimeoutError``."""
self._force_write_timeout = enabled
|
reopen
async
Simulate a baud-rate change — close, record, reopen.
If force_reopen_error() has been called the reopen raises
:class:AlicatConnectionError after the close, leaving the
transport closed. That's the "reopen wedged" path tested at
the session layer for :attr:SessionState.BROKEN transitions.
Source code in src/alicatlib/transport/fake.py
| async def reopen(self, *, baudrate: int) -> None:
"""Simulate a baud-rate change — close, record, reopen.
If ``force_reopen_error()`` has been called the reopen raises
:class:`AlicatConnectionError` after the close, leaving the
transport closed. That's the "reopen wedged" path tested at
the session layer for :attr:`SessionState.BROKEN` transitions.
"""
await self.close()
self._reopen_count += 1
self._last_reopen_baud = baudrate
if self._force_reopen_error:
raise AlicatConnectionError(
f"forced reopen error on {self._label}",
context=ErrorContext(port=self._label),
)
await self.open()
|
FakeTransportFromFixture
FakeTransportFromFixture(path, *, label=None)
Load a fixture file into a new, already-built :class:FakeTransport.
The convenience for test code is that one line replaces the
boilerplate "parse fixture, construct transport". The returned
transport is not open — the caller awaits .open() as usual —
because :class:FakeTransport's construction is synchronous but
opening isn't.
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 :class:ErrorContext.port
entries point at the actual fixture file during failures.
|
None
|
Source code in src/alicatlib/testing.py
| def FakeTransportFromFixture( # noqa: N802 — public factory, title-case matches the class it returns
path: str | Path,
*,
label: str | None = None,
) -> FakeTransport:
"""Load a fixture file into a new, already-built :class:`FakeTransport`.
The convenience for test code is that one line replaces the
boilerplate "parse fixture, construct transport". The returned
transport is *not* open — the caller awaits ``.open()`` as usual —
because :class:`FakeTransport`'s construction is synchronous but
opening isn't.
Args:
path: Path to the ``.txt`` fixture.
label: Optional override for :attr:`FakeTransport.label`; defaults
to ``"fixture://<basename>"`` so :class:`ErrorContext.port`
entries point at the actual fixture file during failures.
"""
script = parse_fixture(path)
resolved_label = label if label is not None else f"fixture://{Path(path).name}"
return FakeTransport(script, label=resolved_label)
|
parse_fixture
Parse a fixture file into a :class:FakeTransport script map.
The returned dict maps send_bytes → reply_bytes. Both payloads
are ASCII-encoded with a trailing \r on every logical line — the
exact shape :class:FakeTransport expects.
Raises:
| Type |
Description |
ValueError
|
On malformed lines, a < before any >, or a
duplicate > entry. Every error message names the
offending line number in the source file.
|
FileNotFoundError
|
Via the underlying :meth:Path.read_text.
|
Source code in src/alicatlib/testing.py
| def parse_fixture(path: str | Path) -> dict[bytes, bytes]:
r"""Parse a fixture file into a :class:`FakeTransport` script map.
The returned dict maps ``send_bytes → reply_bytes``. Both payloads
are ASCII-encoded with a trailing ``\r`` on every logical line — the
exact shape :class:`FakeTransport` expects.
Raises:
ValueError: On malformed lines, a ``<`` before any ``>``, or a
duplicate ``>`` entry. Every error message names the
offending line number in the source file.
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 = []
# File is read as UTF-8 so comments can include non-ASCII characters.
# Individual ``>`` / ``<`` payloads are encoded back to ASCII at the
# byte-emit step below — any non-ASCII payload surfaces as UnicodeEncodeError
# at that point, which is the right behavior (Alicat wire is ASCII-only).
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()
payload = _content_after_marker(line, ">")
current_send = payload.encode("ascii") + b"\r"
elif lean.startswith("<"):
if current_send is None:
raise ValueError(
f"{fixture_path}:{line_number}: '<' line without preceding '>'",
)
payload = _content_after_marker(line, "<")
current_reply_chunks.append(payload.encode("ascii") + b"\r")
else:
raise ValueError(
f"{fixture_path}:{line_number}: unrecognized line {line!r}; "
f"lines must start with '>', '<', or '#'",
)
_flush()
return script
|