diff --git a/docs/05_architecture_overview.md b/docs/05_architecture_overview.md index 2d329d5..ddc6352 100644 --- a/docs/05_architecture_overview.md +++ b/docs/05_architecture_overview.md @@ -228,7 +228,9 @@ What each path buys you: The LDF object (currently `LdfDatabase`) is consumed by both `FrameIO` 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: @@ -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 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 - Add new bus adapters by implementing `LinInterface` diff --git a/docs/24_test_wiring.md b/docs/24_test_wiring.md index 8a00d30..eb5a115 100644 --- a/docs/24_test_wiring.md +++ b/docs/24_test_wiring.md @@ -191,6 +191,13 @@ Two details that matter: `yield lin` then `disconnect()` means **one shared connection** for the whole 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` `tests/conftest.py:113-126`: