docs(architecture): add Duck typing section with FrameIO and lin-fixture examples

The previous commit fixed the FrameIO/LDF diagram by labeling the
ldf-lookup edge as "duck-typed" without defining the term. This commit
adds a dedicated section explaining what duck typing means in this
codebase, why both architectural seams (FrameIO's ldf injection and the
lin fixture's adapter swap) rely on it, and the Python idioms behind it.

Content covers:

- The "walks like a duck" slogan and what it means in code: shape of
  used methods is the contract, not the class.
- Example 1 — FrameIO and the untyped `ldf` parameter: shows the
  contract (single .frame() call) and the absence of any
  `from ecu_framework.lin.ldf import LdfDatabase`. Includes the
  counter-example of what nominal typing would have meant for
  module dependencies and testability.
- Example 2 — the lin fixture and adapter polymorphism: same idiom,
  with LinInterface providing the nominal anchor.
- EAFP ("Easier to Ask Forgiveness than Permission") as the supporting
  Python idiom, contrasted with LBYL.
- The trade-off section: implicit contracts and runtime-only errors,
  and how the codebase mitigates them.

Cross-linked from 24_test_wiring.md's `lin` polymorphism-boundary
discussion so readers of either doc can navigate to the explanation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hosam-Eldin Mostafa 2026-05-14 20:30:30 +02:00
parent ec218bd5fe
commit a3c50eabf2
2 changed files with 159 additions and 1 deletions

View File

@ -228,7 +228,9 @@ What each path buys you:
The LDF object (currently `LdfDatabase`) is consumed by both `FrameIO` The LDF object (currently `LdfDatabase`) is consumed by both `FrameIO`
and any direct-use code path. `FrameIO`'s use is via injection — it and any direct-use code path. `FrameIO`'s use is via injection — it
never imports `LdfDatabase` and can be tested against a stub. never imports `LdfDatabase` and can be tested against a stub. The next
section explains what "duck-typed" means in this codebase and why it
matters architecturally.
Removing any of the three entry points collapses a distinct affordance: Removing any of the three entry points collapses a distinct affordance:
@ -240,6 +242,155 @@ Removing any of the three entry points collapses a distinct affordance:
- Drop direct `LdfDatabase` usage → tests can no longer pack a frame - Drop direct `LdfDatabase` usage → tests can no longer pack a frame
without sending it, or inspect frame metadata without an I/O attempt. without sending it, or inspect frame metadata without an I/O attempt.
## Duck typing: how the polymorphism actually works
Both architectural seams above (`FrameIO`'s `ldf` injection, the `lin`
fixture's adapter selection) rely on **duck typing** rather than static
type hierarchies. The Python idiom is:
> If it walks like a duck and quacks like a duck, it's a duck.
Translation: Python doesn't check *what type* of object you pass — it
just calls the methods you call and trusts they work. If they do, the
object is "duck enough." The contract is the **shape of the methods
used**, not the class.
### Example 1: `FrameIO` and the `ldf` parameter
Look at `tests/hardware/frame_io.py` line 44:
```python
class FrameIO:
def __init__(self, lin: LinInterface, ldf) -> None:
self._lin = lin
self._ldf = ldf
```
Two parameters, two very different contracts:
- `lin` carries an annotation (`LinInterface`). That's a **nominal** contract:
a type checker expects an instance of that class (or a subclass).
- `ldf` has **no annotation** at all. Anything is accepted at the call site.
Then on line 65 `FrameIO` uses `ldf` exactly once, this way:
```python
f = self._ldf.frame(name)
```
That single method call — `.frame(name)` returning something with `.id`,
`.pack(**signals)`, `.unpack(bytes)`, and `.length`**is** the contract.
Anything with that surface works:
- The real `LdfDatabase` (production)
- A unit-test stub (`class _StubLdf: def frame(self, n): return _StubFrame(n)`)
- A future schema source (cached JSON, in-memory dict, etc.)
`grep` will confirm: `frame_io.py` never writes `from ecu_framework.lin.ldf import LdfDatabase`,
never writes `isinstance(ldf, LdfDatabase)`. The module is structurally
unaware of `LdfDatabase`. That's what "no static dependency" meant in
the previous section's diagram label `duck-typed lookup`.
### Counter-example: what static typing would look like
If `FrameIO` had been written nominally, it would be:
```python
from ecu_framework.lin.ldf import LdfDatabase
class FrameIO:
def __init__(self, lin: LinInterface, ldf: LdfDatabase) -> None:
...
```
The consequences:
- `frame_io.py` would carry a hard module-level dependency on
`ecu_framework/lin/ldf.py`.
- A unit test could no longer pass a stub without subclassing
`LdfDatabase` or monkey-patching.
- The `frame_io → ecu_framework/lin/ldf.py` edge in the architecture
diagram would represent a real coupling.
The codebase deliberately avoided that — the `ldf` parameter being
untyped is intentional, not an oversight.
### Example 2: the `lin` fixture and adapter polymorphism
The same idiom drives the LIN adapter swap. `tests/conftest.py:34` returns
something annotated as `LinInterface`:
```python
@pytest.fixture(scope="session")
def lin(config: EcuTestConfig) -> Iterator[LinInterface]:
if config.interface.type == "mock": lin = MockBabyLinInterface(...)
elif config.interface.type == "mum": lin = MumLinInterface(...)
elif config.interface.type == "babylin": lin = BabyLinInterface(...)
...
```
This case has a nominal anchor (`LinInterface` is an `abc.ABC` declaring
the required methods), but the day-to-day swap is duck-typed in spirit:
tests call `lin.send(frame)` / `lin.receive(...)` without caring which
concrete adapter is underneath. All three quack `.send()` / `.receive()`
identically, so one YAML config switch reroutes every test in the suite
without touching a single test body.
### Why this matters
Two practical wins, both load-bearing in this codebase:
1. **Swappability.** A new adapter (CAN, FlexRay, a different LIN master)
only needs to expose the same method surface. No edits to FrameIO,
no edits to tests.
2. **Testability.** Unit tests pass minimal stubs — `tests/unit/test_mum_adapter_mocked.py`
builds fake `pylin` / `pymumclient` objects with just enough method
surface to exercise the adapter, never importing the real Melexis stack.
### The Python idiom in play: EAFP
The supporting philosophy has a name: **EAFP**, "Easier to Ask Forgiveness
than Permission." Instead of:
```python
if isinstance(ldf, LdfDatabase) and hasattr(ldf, "frame"):
f = ldf.frame(name)
else:
raise TypeError(...)
```
…you just write:
```python
f = ldf.frame(name)
```
…and let Python raise `AttributeError` at the point of misuse. The other
half of the idiom is **LBYL**, "Look Before You Leap" — the explicit-checks
style. Python idiomatically prefers EAFP because it composes better with
duck typing: you don't need to enumerate every valid type, only the
behaviours.
### The trade-off
Duck typing is not free. Two costs to be aware of:
- **Implicit contracts.** The type signature `ldf` tells you nothing.
A reader has to scan the method body to learn that `.frame(name)`,
`.id`, `.pack()`, `.length` are required. Mitigated here by the
injection happening in one place (the `fio` fixture) so the duck
shape is easy to track.
- **Runtime, not compile-time, errors.** A misshaped duck blows up at
the call site, not at construction. Type checkers can't catch it.
Mitigated here by the limited number of concrete duck-shapes in the
codebase — there's really only `LdfDatabase`, and the fixture wires
it in centrally.
The codebase accepts those costs in exchange for the swappability and
testability wins above. The `LinInterface` abstract base class is the
formal seam where the team chose to spend annotation effort; the `ldf`
slot is where the team chose to keep things light.
## Extending the architecture ## Extending the architecture
- Add new bus adapters by implementing `LinInterface` - Add new bus adapters by implementing `LinInterface`

View File

@ -191,6 +191,13 @@ Two details that matter:
`yield lin` then `disconnect()` means **one shared connection** for the whole `yield lin` then `disconnect()` means **one shared connection** for the whole
session, with deterministic teardown. session, with deterministic teardown.
The mechanism that makes the swap actually work is **duck typing**
tests call `lin.send(...)` and `lin.receive(...)` without caring which
concrete adapter is underneath. See
[`05_architecture_overview.md` § Duck typing](05_architecture_overview.md#duck-typing-how-the-polymorphism-actually-works)
for the full explanation, the `FrameIO` example, and the Python idiom
(EAFP) it relies on.
### `flash_ecu` — built on top of `lin` ### `flash_ecu` — built on top of `lin`
`tests/conftest.py:113-126`: `tests/conftest.py:113-126`: