diff --git a/patterns/behavioral/command/README.md b/patterns/behavioral/command/README.md index 93c117d..c881020 100644 --- a/patterns/behavioral/command/README.md +++ b/patterns/behavioral/command/README.md @@ -14,32 +14,17 @@ stdlib_sightings: [functools.partial, sched.scheduler, unittest.mock.call] # Command -## Problem - -A menu button, a job queue, or an undo stack must trigger operations without -knowing what they do. Reify the request: an object carrying everything needed -to perform (and possibly reverse) it. - -## Naive solution - -`naive.py` is the classic remote-control shape: a `Command` interface with -`execute`/`undo`, concrete commands closing over a receiver, and an invoker -that runs them and keeps a history for undo. - -## Pythonic solution - -Functions are first-class, so *a command is just a callable*. `pythonic.py` -queues `functools.partial` objects for the execute-only case, and uses a pair -of callables (do, undo) where reversibility matters — no interface, no -hierarchy. - -## In the wild - -Every callback API is the Command pattern: `sched.scheduler.enter` takes the -action as a callable, Tkinter buttons take `command=`, `atexit.register` -queues commands to run at shutdown. - -## Verdict - -**Use with care.** Callables for deferral, the class form only once commands -need undo, serialization, or introspection beyond "run me". +Package a request as an object so it can be queued, logged, undone, or run by +code that doesn't know its details. **Verdict: use with care** — a callable is +the whole pattern until commands need undo, logs, or metadata. + +| Where | What | +|---|---| +| [`pattern/`](pattern/) | The importable code: `Undoable`, `UndoStack`, `Action` | +| [`docs/`](docs/) | [Fundamentals](docs/fundamentals.md) · [Implementation guide](docs/implementation.md) · [External examples](docs/examples.md) | +| [`examples/editor_undo/`](examples/editor_undo/) | Mini-project: text-editor undo/redo built on `pattern/` | +| [`tests/`](tests/) | Behavioral tests for the pattern and the mini-project | + +```bash +uv run python -m patterns.behavioral.command.examples.editor_undo +``` diff --git a/patterns/behavioral/command/__init__.py b/patterns/behavioral/command/__init__.py index 9f0d6a2..7e3f1f0 100644 --- a/patterns/behavioral/command/__init__.py +++ b/patterns/behavioral/command/__init__.py @@ -1 +1,8 @@ -"""Command: reify a request so it can be queued, logged, or undone.""" +"""Command — public API. + +>>> from patterns.behavioral.command import UndoStack, Undoable +""" + +from patterns.behavioral.command.pattern import Action, Undoable, UndoStack + +__all__ = ["Action", "UndoStack", "Undoable"] diff --git a/patterns/behavioral/command/docs/examples.md b/patterns/behavioral/command/docs/examples.md new file mode 100644 index 0000000..72555f5 --- /dev/null +++ b/patterns/behavioral/command/docs/examples.md @@ -0,0 +1,39 @@ +# Command — where it lives outside this repo + +Cited, real implementations to study (or point an agent at) when designing or +reviewing command-shaped code. + +## Python standard library + +- **`functools.partial`** — the execute-only half of the pattern as a + builtin: a call and its arguments packaged into one object. + [docs.python.org/3/library/functools.html#functools.partial](https://docs.python.org/3/library/functools.html#functools.partial) +- **`sched.scheduler`** — queues `Event` records (time, priority, sequence, + action, argument, kwargs) — commands with metadata — and its run loop is + the invoker. + [docs.python.org/3/library/sched.html](https://docs.python.org/3/library/sched.html) +- **`unittest.mock.call`** — recorded invocations as inspectable, comparable + objects: the audit-log face of the pattern. + [docs.python.org/3/library/unittest.mock.html#unittest.mock.call](https://docs.python.org/3/library/unittest.mock.html#unittest.mock.call) + +## Major ecosystems + +- **Django migration operations.** Each operation implements + `database_forwards` *and* `database_backwards` — execute plus undo — and + the migration executor is an invoker replaying them in order, either way. + [docs.djangoproject.com/en/stable/ref/migration-operations/](https://docs.djangoproject.com/en/stable/ref/migration-operations/) +- **Qt's `QUndoCommand` / `QUndoStack`** (exposed by PyQt/PySide) — the + canonical GUI undo architecture: commands with `redo()`/`undo()`, an + invoker stack with exactly the clear-redo-on-push contract this module's + `UndoStack` implements. [doc.qt.io/qt-6/qundocommand.html](https://doc.qt.io/qt-6/qundocommand.html) *(unverified)* +- **Celery tasks.** A task invocation serialized onto a broker queue is the + pattern at distributed scale: the worker (invoker) executes requests it + never saw created. [docs.celeryq.dev](https://docs.celeryq.dev/) *(unverified)* + +## What to notice across all of them + +The dividing line is always the same: plain callables until requests need to +be *stored, inspected, or reversed*, objects after. Django's migrations and +Qt's undo stack both pay the class-per-operation cost precisely because they +need the backwards direction — and neither uses a command class where a +forward-only callback would do. diff --git a/patterns/behavioral/command/docs/fundamentals.md b/patterns/behavioral/command/docs/fundamentals.md new file mode 100644 index 0000000..25a9e18 --- /dev/null +++ b/patterns/behavioral/command/docs/fundamentals.md @@ -0,0 +1,73 @@ +# Command — fundamentals + +## Intent + +Package a request as an object carrying everything needed to perform it, so +code that triggers requests (menus, queues, schedulers) need not know what +they do — and so requests can be queued, logged, undone, or replayed. + +## Participants + +| Role | Classic (GoF) form | Python form | +|---|---|---| +| Command contract | Interface with `execute()` (and often `undo()`) | Any callable for execute-only; [`Undoable`](../pattern/commands.py) — a (do, undo) pair — when reversible | +| Concrete commands | One class per operation, binding a Receiver | A closure or `functools.partial` capturing its arguments | +| Invoker | Runs commands, may keep history | [`UndoStack`](../pattern/commands.py): push / undo / redo / log | +| Receiver | The object acted upon | Any object the callables close over | + +## Mechanism + +1. The moment a request is *created*, everything it needs is captured in it. +2. The invoker executes commands without inspecting them. +3. Because executed commands are objects, history is a list: undo pops and + reverses; a log is a projection; a queue is deferral. +4. Pushing a new command after undoing clears the redo branch — history is + linear, and that is a deliberate contract, not an accident. + +## The classic form, and what Python absorbs + +The textbook shape is an interface and a class per operation: + +```python +class Command(ABC): + @abstractmethod + def execute(self) -> None: ... + @abstractmethod + def undo(self) -> None: ... + + +class AppendText(Command): # one class per operation + def __init__(self, doc: Document, text: str) -> None: + self.doc, self.text = doc, text + + def execute(self) -> None: + self.doc.text += self.text + + def undo(self) -> None: + self.doc.text = self.doc.text[: -len(self.text)] +``` + +Python absorbs the *deferral* half completely: functions are first-class, so +`partial(log.append, "line")` **is** a packaged request — no interface, no +hierarchy. What survives is the *reversibility* half: a bare callable cannot +carry its own inverse or its own label, so the moment you need undo, audit +logs, or serialization, the request must become data again. That is the +line this module draws: `Action` (a plain callable) below it, `Undoable` +above it. + +## When to use it + +- Undo/redo — the canonical justification. +- Audit trails and macro recording: executed operations must be inspectable. +- Queues and schedulers where requests outlive the code that created them. + +## When not to use it + +- "Call this later" with no undo, no log, no metadata → a callable or + `functools.partial` is the whole pattern; a class hierarchy is ceremony. +- One-off callbacks → pass the function. + +## Verdict: use with care + +Callables for deferral; the (do, undo) pair exactly when commands need to be +reversed, logged, or stored. See the unit's [caveats](../README.md). diff --git a/patterns/behavioral/command/docs/implementation.md b/patterns/behavioral/command/docs/implementation.md new file mode 100644 index 0000000..c6ebf41 --- /dev/null +++ b/patterns/behavioral/command/docs/implementation.md @@ -0,0 +1,66 @@ +# Command — putting it into a system + +## The smell it fixes + +Undo implemented by snapshotting entire state ("save a copy of the document +before every change"), or an event log reverse-engineered from side effects. +Both grow unbounded and neither can answer "what exactly did the user do?". + +## Steps + +1. **Identify the operations** users trigger that must be reversible or + auditable. Each becomes a command factory, not a subclass. +2. **Write each factory to capture its own inverse.** The critical rule: + capture undo state *at execution time*. Deleting text must remember what + it deleted — that memory is the command's whole reason to be an object: + + ```python + def delete_span(doc: Document, position: int, length: int) -> Undoable: + removed: list[str] = [] # filled by do, consumed by undo + + def do() -> None: + removed.append(doc.delete(position, length)) + + def undo() -> None: + doc.insert(position, removed.pop()) + + return Undoable(do=do, undo=undo, label=f"delete {length}@{position}") + ``` + +3. **Route every mutation through one invoker.** `UndoStack.push` is the + single door: nothing edits the receiver directly, or history lies. +4. **Give commands labels.** `stack.log()` is your audit trail and your + macro recording for free. +5. **Test the round-trip property**: for any command, `do(); undo()` must + restore the receiver exactly. Property-style tests catch asymmetric pairs. + +## Python idioms that keep it small + +- Command factories are **plain functions returning `Undoable`** — closures + capture receiver and arguments; no Receiver/ConcreteCommand classes. +- Execute-only queues are **lists of callables**; `functools.partial` + packages arguments without ceremony. +- A macro is `[stack.push(cmd) for cmd in recorded]` — replay is iteration. + +## Pitfalls + +- **Undo state captured too early.** Computing the inverse when the command + is *built* (not executed) breaks as soon as commands run against a state + that changed since construction. +- **Bypassing the invoker.** One direct mutation makes every later undo + corrupt the receiver. The receiver's mutators should be package-private by + convention. +- **Non-invertible operations** (send email, charge card) don't belong on an + undo stack — model them as compensations (a *new* command), not undos. +- **Forgetting to clear redo on new pushes** — replaying a stale future + corrupts state; `UndoStack` does this for you, keep the contract if you + write your own. + +## Worked example + +[`examples/editor_undo/`](../examples/editor_undo/) applies every step to a +text editor — insert/delete/replace with undo, redo, and a session log: + +```bash +uv run python -m patterns.behavioral.command.examples.editor_undo +``` diff --git a/patterns/behavioral/command/examples/__init__.py b/patterns/behavioral/command/examples/__init__.py new file mode 100644 index 0000000..8971b23 --- /dev/null +++ b/patterns/behavioral/command/examples/__init__.py @@ -0,0 +1 @@ +"""Mini-projects demonstrating the Command pattern in practice.""" diff --git a/patterns/behavioral/command/examples/editor_undo/__init__.py b/patterns/behavioral/command/examples/editor_undo/__init__.py new file mode 100644 index 0000000..34f60f2 --- /dev/null +++ b/patterns/behavioral/command/examples/editor_undo/__init__.py @@ -0,0 +1,13 @@ +"""A text editor's undo/redo built on the Command pattern. + +Run it: ``uv run python -m patterns.behavioral.command.examples.editor_undo`` +""" + +from patterns.behavioral.command.examples.editor_undo.editing import ( + delete_span, + insert_text, + replace_span, +) +from patterns.behavioral.command.examples.editor_undo.models import Document + +__all__ = ["Document", "delete_span", "insert_text", "replace_span"] diff --git a/patterns/behavioral/command/examples/editor_undo/__main__.py b/patterns/behavioral/command/examples/editor_undo/__main__.py new file mode 100644 index 0000000..62840b8 --- /dev/null +++ b/patterns/behavioral/command/examples/editor_undo/__main__.py @@ -0,0 +1,33 @@ +"""Demo: an editing session with undo, redo, and a command log.""" + +from __future__ import annotations + +from patterns.behavioral.command.examples.editor_undo.editing import ( + delete_span, + insert_text, + replace_span, +) +from patterns.behavioral.command.examples.editor_undo.models import Document +from patterns.behavioral.command.pattern import UndoStack + + +def main() -> None: + doc = Document() + history = UndoStack() + + history.push(insert_text(doc, 0, "hello world")) + history.push(replace_span(doc, 0, 5, "goodbye")) + history.push(delete_span(doc, 7, 6)) + print(f"after edits: {doc.text!r}") + + history.undo() + history.undo() + print(f"after 2 undos: {doc.text!r}") + + history.redo() + print(f"after redo: {doc.text!r}") + print("session log:", " | ".join(history.log())) + + +if __name__ == "__main__": + main() diff --git a/patterns/behavioral/command/examples/editor_undo/editing.py b/patterns/behavioral/command/examples/editor_undo/editing.py new file mode 100644 index 0000000..54bd2e3 --- /dev/null +++ b/patterns/behavioral/command/examples/editor_undo/editing.py @@ -0,0 +1,52 @@ +"""Edit operations as reversible commands. + +Each factory captures everything its undo needs *at execution time* — +``delete_span`` must remember the text it removed, which is exactly the +state a bare callback cannot carry and the reason Command earns its keep. +""" + +from __future__ import annotations + +from patterns.behavioral.command.examples.editor_undo.models import Document +from patterns.behavioral.command.pattern import Undoable + + +def insert_text(doc: Document, position: int, chunk: str) -> Undoable: + """Insert ``chunk`` at ``position``; undo removes exactly that span.""" + + def undo() -> None: + doc.delete(position, len(chunk)) + + return Undoable( + do=lambda: doc.insert(position, chunk), + undo=undo, + label=f"insert {chunk!r}@{position}", + ) + + +def delete_span(doc: Document, position: int, length: int) -> Undoable: + """Delete ``length`` chars at ``position``; undo restores what was removed.""" + removed: list[str] = [] # captured by do, needed by undo + + def do() -> None: + removed.append(doc.delete(position, length)) + + def undo() -> None: + doc.insert(position, removed.pop()) + + return Undoable(do=do, undo=undo, label=f"delete {length}@{position}") + + +def replace_span(doc: Document, position: int, length: int, chunk: str) -> Undoable: + """Replace ``length`` chars at ``position`` with ``chunk``, reversibly.""" + removed: list[str] = [] + + def do() -> None: + removed.append(doc.delete(position, length)) + doc.insert(position, chunk) + + def undo() -> None: + doc.delete(position, len(chunk)) + doc.insert(position, removed.pop()) + + return Undoable(do=do, undo=undo, label=f"replace {length}@{position} with {chunk!r}") diff --git a/patterns/behavioral/command/examples/editor_undo/models.py b/patterns/behavioral/command/examples/editor_undo/models.py new file mode 100644 index 0000000..0051ba9 --- /dev/null +++ b/patterns/behavioral/command/examples/editor_undo/models.py @@ -0,0 +1,19 @@ +"""Domain type for the editor-undo mini-project: a mutable text buffer.""" + +from __future__ import annotations + + +class Document: + """The receiver: commands operate on this buffer, it knows no history.""" + + def __init__(self, text: str = "") -> None: + self.text = text + + def insert(self, position: int, chunk: str) -> None: + self.text = self.text[:position] + chunk + self.text[position:] + + def delete(self, position: int, length: int) -> str: + """Remove and return ``length`` characters at ``position``.""" + removed = self.text[position : position + length] + self.text = self.text[:position] + self.text[position + length :] + return removed diff --git a/patterns/behavioral/command/naive.py b/patterns/behavioral/command/naive.py deleted file mode 100644 index ad39790..0000000 --- a/patterns/behavioral/command/naive.py +++ /dev/null @@ -1,65 +0,0 @@ -"""The Gang of Four Command: interface, concrete commands, invoker with undo. - -A text editor whose operations are objects. The invoker keeps history, so -undo is popping the stack and asking the command to reverse itself. -""" - -from __future__ import annotations - -from abc import ABC, abstractmethod - - -class Document: - """The receiver: the thing commands operate on.""" - - def __init__(self) -> None: - self.text = "" - - -class Command(ABC): - @abstractmethod - def execute(self) -> None: ... - - @abstractmethod - def undo(self) -> None: ... - - -class AppendText(Command): - def __init__(self, doc: Document, text: str) -> None: - self.doc = doc - self.text = text - - def execute(self) -> None: - self.doc.text += self.text - - def undo(self) -> None: - self.doc.text = self.doc.text[: -len(self.text)] - - -class Editor: - """The invoker: runs commands and remembers them for undo.""" - - def __init__(self) -> None: - self._history: list[Command] = [] - - def do(self, command: Command) -> None: - command.execute() - self._history.append(command) - - def undo(self) -> None: - if self._history: - self._history.pop().undo() - - -def main() -> None: - doc = Document() - editor = Editor() - editor.do(AppendText(doc, "hello")) - editor.do(AppendText(doc, " world")) - print(f"after edits: {doc.text!r}") - editor.undo() - print(f"after undo: {doc.text!r}") - - -if __name__ == "__main__": - main() diff --git a/patterns/behavioral/command/pattern/__init__.py b/patterns/behavioral/command/pattern/__init__.py new file mode 100644 index 0000000..e304667 --- /dev/null +++ b/patterns/behavioral/command/pattern/__init__.py @@ -0,0 +1,9 @@ +"""The Command pattern, importable as library code.""" + +from patterns.behavioral.command.pattern.commands import ( + Action, + Undoable, + UndoStack, +) + +__all__ = ["Action", "UndoStack", "Undoable"] diff --git a/patterns/behavioral/command/pattern/commands.py b/patterns/behavioral/command/pattern/commands.py new file mode 100644 index 0000000..07f2273 --- /dev/null +++ b/patterns/behavioral/command/pattern/commands.py @@ -0,0 +1,71 @@ +"""Command as an importable, typed building block. + +For plain deferral, a callable (or ``functools.partial``) already *is* the +packaged request. The class form earns its keep when commands carry undo: +``Undoable`` pairs a do with its inverse, and ``UndoStack`` is the invoker +that remembers history and replays it in either direction. +""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +Action = Callable[[], None] + + +@dataclass(frozen=True) +class Undoable: + """A reversible command: two callables and a label for the log.""" + + do: Action + undo: Action + label: str = "" + + +class UndoStack: + """The invoker: executes commands, remembers them, undoes and redoes. + + Pushing a new command clears the redo history — after diverging, the + undone future can no longer be replayed (the standard editor contract). + """ + + def __init__(self) -> None: + self._done: list[Undoable] = [] + self._undone: list[Undoable] = [] + + def push(self, command: Undoable) -> None: + """Execute ``command`` and record it as the newest history entry.""" + command.do() + self._done.append(command) + self._undone.clear() + + def undo(self) -> Undoable | None: + """Reverse the newest command; return it, or ``None`` if no history.""" + if not self._done: + return None + command = self._done.pop() + command.undo() + self._undone.append(command) + return command + + def redo(self) -> Undoable | None: + """Re-execute the most recently undone command, if any.""" + if not self._undone: + return None + command = self._undone.pop() + command.do() + self._done.append(command) + return command + + @property + def can_undo(self) -> bool: + return bool(self._done) + + @property + def can_redo(self) -> bool: + return bool(self._undone) + + def log(self) -> tuple[str, ...]: + """Labels of every command currently applied, oldest first.""" + return tuple(command.label for command in self._done) diff --git a/patterns/behavioral/command/pythonic.py b/patterns/behavioral/command/pythonic.py deleted file mode 100644 index 8c7852c..0000000 --- a/patterns/behavioral/command/pythonic.py +++ /dev/null @@ -1,67 +0,0 @@ -"""Commands as callables. - -For plain deferral, ``functools.partial`` packages the call and its -arguments. For undo, a command is a (do, undo) pair -- here a small frozen -dataclass of two callables, still no interface or hierarchy. -""" - -from __future__ import annotations - -from collections.abc import Callable -from dataclasses import dataclass, field -from functools import partial - - -def run_queue(queue: list[Callable[[], None]]) -> None: - """The execute-only invoker: call everything, in order.""" - for command in queue: - command() - - -@dataclass(frozen=True) -class Undoable: - """A reversible command: two callables, no ceremony.""" - - do: Callable[[], None] - undo: Callable[[], None] - - -@dataclass -class Editor: - text: str = "" - _history: list[Undoable] = field(default_factory=list) - - def append(self, chunk: str) -> None: - command = Undoable( - do=partial(self._append, chunk), - undo=partial(self._chop, len(chunk)), - ) - command.do() - self._history.append(command) - - def undo(self) -> None: - if self._history: - self._history.pop().undo() - - def _append(self, chunk: str) -> None: - self.text += chunk - - def _chop(self, n: int) -> None: - self.text = self.text[:-n] - - -def main() -> None: - log: list[str] = [] - queue: list[Callable[[], None]] = [partial(log.append, "a"), partial(log.append, "b")] - run_queue(queue) - print(f"queued callables ran: {log}") - - editor = Editor() - editor.append("hello") - editor.append(" world") - editor.undo() - print(f"after undo: {editor.text!r}") - - -if __name__ == "__main__": - main() diff --git a/patterns/behavioral/command/real_world.py b/patterns/behavioral/command/real_world.py deleted file mode 100644 index 48f1cfc..0000000 --- a/patterns/behavioral/command/real_world.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Callbacks in the stdlib are the Command pattern. - -``sched.scheduler`` queues (time, priority, action, arguments) records -- -commands with metadata -- and its run loop is the invoker. -""" - -from __future__ import annotations - -import sched - - -class FakeClock: - """A clock the scheduler advances by 'sleeping' -- tests run instantly.""" - - def __init__(self) -> None: - self.now = 0.0 - - def time(self) -> float: - return self.now - - def sleep(self, duration: float) -> None: - self.now += duration - - -def run_scheduled(chunks: list[str]) -> list[str]: - """Queue one append-command per chunk; the scheduler invokes them in order.""" - log: list[str] = [] - clock = FakeClock() - scheduler = sched.scheduler(timefunc=clock.time, delayfunc=clock.sleep) - for delay, chunk in enumerate(chunks): - scheduler.enter(float(delay), 1, log.append, argument=(chunk,)) - scheduler.run() - return log - - -def main() -> None: - print(run_scheduled(["first", "second", "third"])) - - -if __name__ == "__main__": - main() diff --git a/patterns/behavioral/command/tests/test_command.py b/patterns/behavioral/command/tests/test_command.py deleted file mode 100644 index ba5da6a..0000000 --- a/patterns/behavioral/command/tests/test_command.py +++ /dev/null @@ -1,43 +0,0 @@ -"""Behavioral tests for all three command variants.""" - -from functools import partial - -from patterns.behavioral.command import naive, pythonic, real_world - - -class TestNaive: - def test_execute_mutates_receiver(self) -> None: - doc, editor = naive.Document(), naive.Editor() - editor.do(naive.AppendText(doc, "hi")) - assert doc.text == "hi" - - def test_undo_reverses_last_command(self) -> None: - doc, editor = naive.Document(), naive.Editor() - editor.do(naive.AppendText(doc, "hello")) - editor.do(naive.AppendText(doc, " world")) - editor.undo() - assert doc.text == "hello" - - def test_undo_on_empty_history_is_a_noop(self) -> None: - naive.Editor().undo() # must not raise - - -class TestPythonic: - def test_partial_queue_runs_in_order(self) -> None: - log: list[str] = [] - pythonic.run_queue([partial(log.append, "a"), partial(log.append, "b")]) - assert log == ["a", "b"] - - def test_undoable_editor_round_trip(self) -> None: - editor = pythonic.Editor() - editor.append("hello") - editor.append(" world") - assert editor.text == "hello world" - editor.undo() - editor.undo() - assert editor.text == "" - - -class TestRealWorld: - def test_scheduler_invokes_queued_commands_in_order(self) -> None: - assert real_world.run_scheduled(["x", "y", "z"]) == ["x", "y", "z"] diff --git a/patterns/behavioral/command/tests/test_commands.py b/patterns/behavioral/command/tests/test_commands.py new file mode 100644 index 0000000..e402154 --- /dev/null +++ b/patterns/behavioral/command/tests/test_commands.py @@ -0,0 +1,74 @@ +"""Behavioral tests for the Command pattern's library code.""" + +from __future__ import annotations + +from patterns.behavioral.command.pattern import Undoable, UndoStack + + +def _append_command(log: list[str], item: str) -> Undoable: + return Undoable( + do=lambda: log.append(item), + undo=lambda: log.remove(item), + label=f"append {item}", + ) + + +class TestUndoStack: + def test_push_executes_and_records(self) -> None: + log: list[str] = [] + stack = UndoStack() + stack.push(_append_command(log, "a")) + stack.push(_append_command(log, "b")) + assert log == ["a", "b"] + assert stack.log() == ("append a", "append b") + + def test_undo_reverses_newest_first(self) -> None: + log: list[str] = [] + stack = UndoStack() + stack.push(_append_command(log, "a")) + stack.push(_append_command(log, "b")) + undone = stack.undo() + assert undone is not None and undone.label == "append b" + assert log == ["a"] + + def test_redo_replays_the_undone_command(self) -> None: + log: list[str] = [] + stack = UndoStack() + stack.push(_append_command(log, "a")) + stack.undo() + assert log == [] + redone = stack.redo() + assert redone is not None and redone.label == "append a" + assert log == ["a"] + + def test_new_push_clears_the_redo_branch(self) -> None: + log: list[str] = [] + stack = UndoStack() + stack.push(_append_command(log, "a")) + stack.undo() + stack.push(_append_command(log, "b")) # diverge: the undone future dies + assert stack.redo() is None + assert log == ["b"] + + def test_undo_redo_on_empty_history_are_safe(self) -> None: + stack = UndoStack() + assert stack.undo() is None + assert stack.redo() is None + assert not stack.can_undo + assert not stack.can_redo + + def test_can_undo_and_can_redo_report_true_when_true(self) -> None: + log: list[str] = [] + stack = UndoStack() + stack.push(_append_command(log, "a")) + assert stack.can_undo and not stack.can_redo + stack.undo() + assert stack.can_redo and not stack.can_undo + + def test_log_reflects_only_applied_commands(self) -> None: + log: list[str] = [] + stack = UndoStack() + stack.push(_append_command(log, "a")) + stack.push(_append_command(log, "b")) + stack.undo() + assert stack.log() == ("append a",) diff --git a/patterns/behavioral/command/tests/test_editor_undo.py b/patterns/behavioral/command/tests/test_editor_undo.py new file mode 100644 index 0000000..c35f15c --- /dev/null +++ b/patterns/behavioral/command/tests/test_editor_undo.py @@ -0,0 +1,67 @@ +"""Behavioral tests for the editor-undo mini-project.""" + +from __future__ import annotations + +from patterns.behavioral.command.examples.editor_undo import ( + Document, + delete_span, + insert_text, + replace_span, +) +from patterns.behavioral.command.pattern import UndoStack + + +class TestEditingCommands: + def test_insert_then_undo_restores_exact_text(self) -> None: + doc = Document("hello world") + stack = UndoStack() + stack.push(insert_text(doc, 5, ",")) + assert doc.text == "hello, world" + stack.undo() + assert doc.text == "hello world" + + def test_delete_remembers_what_it_removed(self) -> None: + doc = Document("hello world") + stack = UndoStack() + stack.push(delete_span(doc, 0, 6)) + assert doc.text == "world" + stack.undo() + assert doc.text == "hello world" # the removed span came back verbatim + + def test_replace_round_trips(self) -> None: + doc = Document("hello world") + stack = UndoStack() + stack.push(replace_span(doc, 0, 5, "goodbye")) + assert doc.text == "goodbye world" + stack.undo() + assert doc.text == "hello world" + + def test_delete_undo_redo_cycle_reuses_captured_state(self) -> None: + doc = Document("abcdef") + stack = UndoStack() + stack.push(delete_span(doc, 1, 3)) + stack.undo() + stack.redo() + assert doc.text == "aef" + stack.undo() + assert doc.text == "abcdef" + + def test_session_log_reads_as_an_audit_trail(self) -> None: + doc = Document() + stack = UndoStack() + stack.push(insert_text(doc, 0, "hi")) + stack.push(delete_span(doc, 0, 1)) + assert stack.log() == ("insert 'hi'@0", "delete 1@0") + + def test_editing_session_end_to_end(self) -> None: + doc = Document() + stack = UndoStack() + stack.push(insert_text(doc, 0, "hello world")) + stack.push(replace_span(doc, 0, 5, "goodbye")) + stack.push(delete_span(doc, 7, 6)) + assert doc.text == "goodbye" + stack.undo() + stack.undo() + assert doc.text == "hello world" + stack.redo() + assert doc.text == "goodbye world" diff --git a/patterns/behavioral/interpreter/README.md b/patterns/behavioral/interpreter/README.md index 166e659..ef4d630 100644 --- a/patterns/behavioral/interpreter/README.md +++ b/patterns/behavioral/interpreter/README.md @@ -14,31 +14,17 @@ stdlib_sightings: [ast.literal_eval, ast.NodeVisitor, re] # Interpreter -## Problem - -Users need to supply small formulas — spreadsheet expressions, feature-flag -rules — that your program must evaluate, safely, without shipping them to -`eval()`. - -## Naive solution - -`naive.py` is the GoF class-per-grammar-rule form: `Number`, `Add`, `Mul` -nodes each carrying `interpret()`, composed into an expression tree. - -## Pythonic solution - -The tree doesn't need a class per rule: nested tuples plus one recursive -function interpret the same grammar in a screenful. Adding an operation to -the language is one dict entry, not a class. - -## In the wild - -The `re` module is a full Interpreter-pattern implementation you use daily -(pattern → compiled program → evaluated against strings). `ast.literal_eval` -safely interprets Python's own literal grammar, and `real_world.py` builds -the classic safe arithmetic evaluator from a restricted `ast` walk. - -## Verdict - -**Prefer an alternative.** Python's own parsers (`ast`, `re`) cover most -"little language" needs; write a grammar only when you truly have a language. +Represent a tiny language's grammar as data and evaluate sentences safely. +**Verdict: prefer an alternative** — Python's own parsers (`ast`, `re`) cover +most little-language needs; grammar-as-data covers the rest. + +| Where | What | +|---|---| +| [`pattern/`](pattern/) | The importable code: `Interpreter` (tuple-tree evaluator), hardened `safe_eval` | +| [`docs/`](docs/) | [Fundamentals](docs/fundamentals.md) · [Implementation guide](docs/implementation.md) · [External examples](docs/examples.md) | +| [`examples/flag_rules/`](examples/flag_rules/) | Mini-project: feature-flag rules engine built on `pattern/` | +| [`tests/`](tests/) | Behavioral tests for the pattern and the mini-project | + +```bash +uv run python -m patterns.behavioral.interpreter.examples.flag_rules +``` diff --git a/patterns/behavioral/interpreter/__init__.py b/patterns/behavioral/interpreter/__init__.py index 5362fe1..49952a8 100644 --- a/patterns/behavioral/interpreter/__init__.py +++ b/patterns/behavioral/interpreter/__init__.py @@ -1 +1,24 @@ -"""Interpreter: grammar as data. Verdict: use Python own parsers first.""" +"""Interpreter — public API. + +>>> from patterns.behavioral.interpreter import Interpreter, safe_eval +""" + +from patterns.behavioral.interpreter.pattern import ( + MAX_DEPTH, + Expr, + Interpreter, + Operation, + Resolver, + Value, + safe_eval, +) + +__all__ = [ + "MAX_DEPTH", + "Expr", + "Interpreter", + "Operation", + "Resolver", + "Value", + "safe_eval", +] diff --git a/patterns/behavioral/interpreter/docs/examples.md b/patterns/behavioral/interpreter/docs/examples.md new file mode 100644 index 0000000..4d0fb37 --- /dev/null +++ b/patterns/behavioral/interpreter/docs/examples.md @@ -0,0 +1,38 @@ +# Interpreter — where it lives outside this repo + +Cited, real implementations to study (or point an agent at) when designing or +reviewing little-language code. + +## Python standard library + +- **`ast.literal_eval` / `ast.NodeVisitor`** — the safe-evaluation floor: + Python parses, you walk only the nodes you allow. This module's + [`safe_eval`](../pattern/safe_eval.py) is the canonical restricted walk. + [docs.python.org/3/library/ast.html](https://docs.python.org/3/library/ast.html) +- **`re`** — a complete Interpreter implementation used daily: a pattern + language parsed and compiled into a program, evaluated against strings. + [docs.python.org/3/library/re.html](https://docs.python.org/3/library/re.html) + +## Major ecosystems + +- **Django `Q` objects** — query predicates built as composable expression + trees (`Q(age__gte=18) & Q(country="CA")`), interpreted into SQL by the ORM. + [docs.djangoproject.com/en/stable/topics/db/queries/#complex-lookups-with-q-objects](https://docs.djangoproject.com/en/stable/topics/db/queries/#complex-lookups-with-q-objects) +- **SQLAlchemy Core expression language** — column expressions form a tree + the compiler walks to emit dialect-specific SQL: grammar-as-objects at + production scale. + [docs.sqlalchemy.org/en/latest/core/expression_api.html](https://docs.sqlalchemy.org/en/latest/core/expression_api.html) +- **pytest `-k` expressions** — a real shipped mini-language (`and`/`or`/ + `not` over test names) with its own tiny parser and evaluator. + [docs.pytest.org/en/stable/how-to/usage.html#specifying-which-tests-to-run](https://docs.pytest.org/en/stable/how-to/usage.html#specifying-which-tests-to-run) *(unverified)* +- **json-logic** — rules-as-JSON evaluated by a small interpreter; the same + shape as this unit's flag engine, standardized across languages. + [jsonlogic.com](https://jsonlogic.com/) *(unverified)* + +## What to notice across all of them + +None of them expose a general-purpose evaluator to user input. Each fixes a +closed set of operations (Django's lookups, pytest's three combinators) and +validates sentences structurally before evaluating — the two guards +(`ValueError` on unknown operations, bounded depth) that this module treats +as part of the pattern, not optional hardening. diff --git a/patterns/behavioral/interpreter/docs/fundamentals.md b/patterns/behavioral/interpreter/docs/fundamentals.md new file mode 100644 index 0000000..62ad7ca --- /dev/null +++ b/patterns/behavioral/interpreter/docs/fundamentals.md @@ -0,0 +1,90 @@ +# Interpreter — fundamentals + +## Intent + +Given a small language, represent its grammar and evaluate sentences in it — +user-supplied formulas, filter rules, flag conditions — safely, without ever +handing user input to `eval()`. + +## Participants + +| Role | Classic (GoF) form | Python form | +|---|---|---| +| Grammar rules | One class per rule (`Number`, `Add`, `Mul`…) | Entries in an operation table — `OPERATIONS["and"] = …` | +| Sentence | A tree of rule instances | Nested tuples: `("*", ("+", 2, 3), 4)` — plain data | +| Evaluator | `interpret()` spread across every class | One recursive walk — [`Interpreter`](../pattern/rules.py) | +| Context | Passed to every `interpret()` | A `resolve` hook that turns leaves into values | + +## Mechanism + +1. A sentence arrives as data (config, request payload, rule store). +2. The evaluator walks it recursively: leaves resolve to values, tuples + dispatch on their head through the operation table. +3. Extending the language is one table entry; hostile input hits two guards — + unknown operations are `ValueError`, and nesting is depth-capped + (`MAX_DEPTH`) so a bomb fails cleanly instead of overflowing the stack. + +## The classic form, and what Python absorbs + +The textbook shape defines a class per grammar rule: + +```python +class Expression(ABC): + @abstractmethod + def interpret(self) -> int: ... + + +class Number(Expression): + def __init__(self, value: int) -> None: + self.value = value + + def interpret(self) -> int: + return self.value + + +class Add(Expression): # ...and Sub, and every rule you add + def __init__(self, left: Expression, right: Expression) -> None: + self.left, self.right = left, right + + def interpret(self) -> int: + return self.left.interpret() + self.right.interpret() + + +class Mul(Expression): # one class per grammar rule, forever + def __init__(self, left: Expression, right: Expression) -> None: + self.left, self.right = left, right + + def interpret(self) -> int: + return self.left.interpret() * self.right.interpret() + + +tree = Mul(Add(Number(2), Number(3)), Number(4)) +``` + +Python absorbs this twice over. The tree doesn't need classes — tuples and a +dict of operators interpret the same grammar in a screenful. And for many +"little languages" Python *is* the language: `ast.literal_eval` for data +literals, a vetted `ast` walk for arithmetic ([`safe_eval`](../pattern/safe_eval.py), +this module's hardened version), a real parser library beyond that. + +## When to use it + +- Rules must live in *data* — config files, databases, request payloads — + and be evaluated repeatedly against different contexts. +- The language is genuinely tiny: boolean combinators, comparisons, a dozen + operations. + +## When not to use it + +- The "language" is Python literals → `ast.literal_eval`. +- The language is arithmetic → a restricted `ast` walk (`safe_eval`). +- The language has precedence, bindings, or users who write it by hand → + a real parser library; hand-rolled grammar code grows without limit. +- **Never** `eval()` on user input — this pattern's safe forms exist + precisely to avoid that. + +## Verdict: prefer an alternative + +Check whether Python is already your language's parser before writing one. +When rules truly must be data, the tuple-tree + operation-table form here is +the whole pattern — no class hierarchy required. diff --git a/patterns/behavioral/interpreter/docs/implementation.md b/patterns/behavioral/interpreter/docs/implementation.md new file mode 100644 index 0000000..8f383ee --- /dev/null +++ b/patterns/behavioral/interpreter/docs/implementation.md @@ -0,0 +1,69 @@ +# Interpreter — putting it into a system + +## The smell it fixes + +Business rules hard-coded as Python conditionals that non-developers keep +asking to change ("enable this for Canadian pro users over 18"), or — +worse — a deployed `eval()` call "temporarily" evaluating user formulas. + +## Steps + +1. **Design the sentence shape first.** Nested tuples with a string head are + ideal: JSON-serializable, diffable, storable in config or a database: + + ```python + rule = ("and", (">=", "age", 18), ("==", "country", "CA")) + ``` + +2. **Write the operation table.** Each operation takes its already-evaluated + operands. Keep operations total: validate operand types and raise + `ValueError` on nonsense (comparing booleans, wrong arity). +3. **Decide leaf resolution.** The `resolve` hook is where `"age"` becomes + *this user's* age. Be explicit about the ambiguity it creates: a string + is a field when the context has it, a literal otherwise — write that rule + down and test it. +4. **Guard the edges.** Unknown operation → `ValueError` naming the options; + nesting beyond `MAX_DEPTH` → `ValueError`, not `RecursionError`. Both are + attacker-facing surfaces if rules come from users. +5. **Wrap it in a domain API.** Callers should see + `engine.is_enabled("beta", user)`, never the interpreter. + +```python +from patterns.behavioral.interpreter import Interpreter + +interpreter = Interpreter(OPERATIONS, resolve=lookup) +verdict = bool(interpreter.evaluate(rule)) +``` + +## Python idioms that keep it small + +- Operations are **dict entries, not classes** — `operator.add`, + lambdas, or named functions all slot in. +- For arithmetic-on-strings needs, **reuse [`safe_eval`](../pattern/safe_eval.py)** + instead of extending the grammar — Python's parser already did the work. +- Sentences being plain tuples means **tests are literals** — no builders. + +## Pitfalls + +- **`eval()` creep.** The moment someone proposes `eval` "because the rules + are trusted", the rules stop being trusted. The safe evaluator exists; + there is no acceptable shortcut. +- **Unbounded recursion.** Rules from outside are attacker input; the depth + cap is a security control, not a nicety (it was added in a security + review — keep it). +- **Boolean/int confusion.** `bool` subclasses `int`; ordered comparisons on + booleans and `True + 1` arithmetic should be rejected explicitly (both + `safe_eval` and the flag engine do). +- **Grammar sprawl.** Every operation added is language surface to document, + test, and secure. If the table keeps growing, you need a parser library, + not a bigger dict. + +## Worked example + +[`examples/flag_rules/`](../examples/flag_rules/) applies every step to a +feature-flag engine — rules as data, per-user evaluation, hostile input +rejected: + +```bash +uv run python -m patterns.behavioral.interpreter.examples.flag_rules +``` diff --git a/patterns/behavioral/interpreter/examples/__init__.py b/patterns/behavioral/interpreter/examples/__init__.py new file mode 100644 index 0000000..64c563c --- /dev/null +++ b/patterns/behavioral/interpreter/examples/__init__.py @@ -0,0 +1 @@ +"""Mini-projects demonstrating the Interpreter pattern in practice.""" diff --git a/patterns/behavioral/interpreter/examples/flag_rules/__init__.py b/patterns/behavioral/interpreter/examples/flag_rules/__init__.py new file mode 100644 index 0000000..beba8b7 --- /dev/null +++ b/patterns/behavioral/interpreter/examples/flag_rules/__init__.py @@ -0,0 +1,11 @@ +"""A feature-flag rules engine built on the Interpreter pattern. + +Run it: ``uv run python -m patterns.behavioral.interpreter.examples.flag_rules`` +""" + +from patterns.behavioral.interpreter.examples.flag_rules.engine import ( + OPERATIONS, + FlagEngine, +) + +__all__ = ["OPERATIONS", "FlagEngine"] diff --git a/patterns/behavioral/interpreter/examples/flag_rules/__main__.py b/patterns/behavioral/interpreter/examples/flag_rules/__main__.py new file mode 100644 index 0000000..bf2579b --- /dev/null +++ b/patterns/behavioral/interpreter/examples/flag_rules/__main__.py @@ -0,0 +1,30 @@ +"""Demo: three users against a small flag config.""" + +from __future__ import annotations + +from patterns.behavioral.interpreter.examples.flag_rules.engine import FlagEngine +from patterns.behavioral.interpreter.pattern import Expr, Value + +FLAGS: dict[str, Expr] = { + "new-dashboard": ("and", (">=", "age", 18), ("==", "country", "CA")), + "beta-exports": ("or", ("==", "plan", "pro"), ("==", "role", "staff")), + "legacy-ui": ("not", (">=", "signup_year", 2024)), +} + + +def main() -> None: + users: dict[str, dict[str, Value]] = { + "ada": {"age": 31, "country": "CA", "plan": "pro", "role": "user", "signup_year": 2021}, + "lin": {"age": 17, "country": "CA", "plan": "free", "role": "staff", "signup_year": 2025}, + "sam": {"age": 40, "country": "US", "plan": "free", "role": "user", "signup_year": 2024}, + } + engine = FlagEngine(FLAGS) + for name, user in users.items(): + verdicts = ", ".join( + f"{flag}={'on' if on else 'off'}" for flag, on in engine.rollout(user).items() + ) + print(f"{name}: {verdicts}") + + +if __name__ == "__main__": + main() diff --git a/patterns/behavioral/interpreter/examples/flag_rules/engine.py b/patterns/behavioral/interpreter/examples/flag_rules/engine.py new file mode 100644 index 0000000..3a9a0b0 --- /dev/null +++ b/patterns/behavioral/interpreter/examples/flag_rules/engine.py @@ -0,0 +1,77 @@ +"""Feature-flag rules stored as data, evaluated per user. + +A rule is a sentence in a tiny boolean language:: + + ("and", (">=", "age", 18), ("==", "country", "CA")) + +Rules live in config (they're just tuples — JSON-serializable shapes), and +extending the language is one entry in ``OPERATIONS``. Leaves that name a +context field resolve to the user's value; anything else is a literal. +""" + +from __future__ import annotations + +from collections.abc import Mapping + +from patterns.behavioral.interpreter.pattern import Expr, Interpreter, Operation, Value + + +def _cmp(pair: tuple[Value, ...]) -> tuple[float, float]: + left, right = pair + if isinstance(left, bool) or isinstance(right, bool): + raise ValueError("ordered comparison on booleans") + if not isinstance(left, int | float) or not isinstance(right, int | float): + raise ValueError(f"ordered comparison needs numbers, got {pair!r}") + return float(left), float(right) + + +def _all(args: tuple[Value, ...]) -> Value: + return all(bool(a) for a in args) + + +def _any(args: tuple[Value, ...]) -> Value: + return any(bool(a) for a in args) + + +def _not(args: tuple[Value, ...]) -> Value: + (only,) = args + return not bool(only) + + +OPERATIONS: dict[str, Operation] = { + "and": _all, + "or": _any, + "not": _not, + "==": lambda a: a[0] == a[1], + "!=": lambda a: a[0] != a[1], + ">=": lambda a: _cmp(a)[0] >= _cmp(a)[1], + "<=": lambda a: _cmp(a)[0] <= _cmp(a)[1], + ">": lambda a: _cmp(a)[0] > _cmp(a)[1], + "<": lambda a: _cmp(a)[0] < _cmp(a)[1], +} + + +class FlagEngine: + """Evaluate named feature flags against a user context.""" + + def __init__(self, flags: Mapping[str, Expr]) -> None: + self._flags = dict(flags) + + def is_enabled(self, flag: str, user: Mapping[str, Value]) -> bool: + """True if ``flag``'s rule accepts this user; KeyError on unknown flag.""" + if flag not in self._flags: + raise KeyError(f"unknown flag {flag!r} (has: {sorted(self._flags)})") + + def resolve(leaf: Value) -> Value: + # A string leaf names a context field when the user has one; + # otherwise it is a literal ("CA" in a country comparison). + if isinstance(leaf, str) and leaf in user: + return user[leaf] + return leaf + + interpreter = Interpreter(OPERATIONS, resolve=resolve) + return bool(interpreter.evaluate(self._flags[flag])) + + def rollout(self, user: Mapping[str, Value]) -> dict[str, bool]: + """Every flag's verdict for one user.""" + return {flag: self.is_enabled(flag, user) for flag in sorted(self._flags)} diff --git a/patterns/behavioral/interpreter/naive.py b/patterns/behavioral/interpreter/naive.py deleted file mode 100644 index 29809e3..0000000 --- a/patterns/behavioral/interpreter/naive.py +++ /dev/null @@ -1,44 +0,0 @@ -"""The Gang of Four Interpreter: one class per grammar rule.""" - -from __future__ import annotations - -from abc import ABC, abstractmethod - - -class Expression(ABC): - @abstractmethod - def interpret(self) -> int: ... - - -class Number(Expression): - def __init__(self, value: int) -> None: - self.value = value - - def interpret(self) -> int: - return self.value - - -class Add(Expression): - def __init__(self, left: Expression, right: Expression) -> None: - self.left, self.right = left, right - - def interpret(self) -> int: - return self.left.interpret() + self.right.interpret() - - -class Mul(Expression): - def __init__(self, left: Expression, right: Expression) -> None: - self.left, self.right = left, right - - def interpret(self) -> int: - return self.left.interpret() * self.right.interpret() - - -def main() -> None: - # (2 + 3) * 4 - tree = Mul(Add(Number(2), Number(3)), Number(4)) - print(tree.interpret()) - - -if __name__ == "__main__": - main() diff --git a/patterns/behavioral/interpreter/pattern/__init__.py b/patterns/behavioral/interpreter/pattern/__init__.py new file mode 100644 index 0000000..b221163 --- /dev/null +++ b/patterns/behavioral/interpreter/pattern/__init__.py @@ -0,0 +1,21 @@ +"""The Interpreter pattern, importable as library code.""" + +from patterns.behavioral.interpreter.pattern.rules import ( + MAX_DEPTH, + Expr, + Interpreter, + Operation, + Resolver, + Value, +) +from patterns.behavioral.interpreter.pattern.safe_eval import safe_eval + +__all__ = [ + "MAX_DEPTH", + "Expr", + "Interpreter", + "Operation", + "Resolver", + "Value", + "safe_eval", +] diff --git a/patterns/behavioral/interpreter/pattern/rules.py b/patterns/behavioral/interpreter/pattern/rules.py new file mode 100644 index 0000000..66b3a00 --- /dev/null +++ b/patterns/behavioral/interpreter/pattern/rules.py @@ -0,0 +1,65 @@ +"""Grammar-as-data: nested tuples, one recursive evaluator. + +The tree needs no class per rule. A sentence is a value or a tuple whose +head names an operation: ``("*", ("+", 2, 3), 4)``. Extending the language +is a dict entry, not a class — and the evaluator is depth-capped so a +hostile, deeply nested input fails with ``ValueError`` instead of blowing +the recursion limit. +""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping + +#: Deeper than any human-written sentence; shallower than the recursion +#: limit, so hostile nesting gets a clean ValueError, not a RecursionError. +MAX_DEPTH = 50 + +Value = int | float | str | bool +Expr = Value | tuple[object, ...] + +#: An operation receives its already-evaluated operands. +Operation = Callable[[tuple[Value, ...]], Value] + +#: Resolves a leaf — the hook where "age" becomes the user's age. Default: +#: leaves are literals. +Resolver = Callable[[Value], Value] + + +class Interpreter: + """Evaluate tuple-tree sentences against an operation table.""" + + def __init__( + self, + operations: Mapping[str, Operation], + *, + resolve: Resolver | None = None, + max_depth: int = MAX_DEPTH, + ) -> None: + self._operations = dict(operations) + self._resolve: Resolver = resolve if resolve is not None else lambda leaf: leaf + self._max_depth = max_depth + + def evaluate(self, expr: Expr) -> Value: + """Interpret one sentence; reject unknown operations and deep nesting.""" + return self._walk(expr, depth=0) + + def _walk(self, expr: Expr, depth: int) -> Value: + if depth > self._max_depth: + raise ValueError("expression too deeply nested") + if not isinstance(expr, tuple): + return self._resolve(expr) + if not expr or not isinstance(expr[0], str): + raise ValueError(f"malformed expression: {expr!r}") + head = expr[0] + if head not in self._operations: + raise ValueError(f"unknown operation: {head!r}") + operands = tuple(self._walk(_as_expr(arg), depth + 1) for arg in expr[1:]) + return self._operations[head](operands) + + +def _as_expr(node: object) -> Expr: + """Narrow a tuple element back to Expr, rejecting foreign objects.""" + if isinstance(node, int | float | str | bool | tuple): + return node + raise ValueError(f"unsupported node: {node!r}") diff --git a/patterns/behavioral/interpreter/real_world.py b/patterns/behavioral/interpreter/pattern/safe_eval.py similarity index 56% rename from patterns/behavioral/interpreter/real_world.py rename to patterns/behavioral/interpreter/pattern/safe_eval.py index 21b9490..8235796 100644 --- a/patterns/behavioral/interpreter/real_world.py +++ b/patterns/behavioral/interpreter/pattern/safe_eval.py @@ -1,7 +1,9 @@ """Interpreting with Python's own parser: a safe arithmetic evaluator. -``ast.parse`` builds the tree; a restricted walk evaluates only the node -types we allow. User input never reaches eval(). +The preferred alternative when the "little language" is arithmetic: +``ast.parse`` builds the tree and a restricted walk evaluates only the node +types we allow. User input never reaches eval(). Security-reviewed: rejects +bool constants (``True + 1``) and depth-limits nesting. """ from __future__ import annotations @@ -10,6 +12,10 @@ import operator from collections.abc import Callable +# The depth limit is the unit's ONE security knob: read from ``rules`` at +# call time so hardening the exported constant tightens this evaluator too. +from patterns.behavioral.interpreter.pattern import rules + _BINOPS: dict[type[ast.operator], Callable[[float, float], float]] = { ast.Add: operator.add, ast.Sub: operator.sub, @@ -18,18 +24,20 @@ } -#: Deeper than any human formula; shallower than the recursion limit, so a -#: hostile input gets a clean ValueError instead of a RecursionError crash. -MAX_DEPTH = 50 - - def safe_eval(formula: str) -> float: - """Evaluate arithmetic like '2 * (3 + 4)'; reject everything else.""" - return _walk(ast.parse(formula, mode="eval").body, depth=0) + """Evaluate arithmetic like '2 * (3 + 4)'; anything else is ValueError. + + That includes division by zero: every rejection this evaluator makes is + a ValueError, so callers wrap untrusted input in exactly one except. + """ + try: + return _walk(ast.parse(formula, mode="eval").body, depth=0) + except ZeroDivisionError: + raise ValueError("division by zero") from None def _walk(node: ast.expr, depth: int) -> float: - if depth > MAX_DEPTH: + if depth > rules.MAX_DEPTH: raise ValueError("expression too deeply nested") if ( isinstance(node, ast.Constant) @@ -44,15 +52,3 @@ def _walk(node: ast.expr, depth: int) -> float: if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.USub): return -_walk(node.operand, depth + 1) raise ValueError(f"disallowed syntax: {ast.dump(node)[:40]}") - - -def main() -> None: - print(safe_eval("2 * (3 + 4)")) - try: - safe_eval("__import__('os')") - except ValueError as exc: - print(f"rejected: {exc}") - - -if __name__ == "__main__": - main() diff --git a/patterns/behavioral/interpreter/pythonic.py b/patterns/behavioral/interpreter/pythonic.py deleted file mode 100644 index 356c9fd..0000000 --- a/patterns/behavioral/interpreter/pythonic.py +++ /dev/null @@ -1,33 +0,0 @@ -"""The same grammar as data: nested tuples, one recursive evaluator. - -Extending the language is a dict entry, not a class. -""" - -from __future__ import annotations - -import operator -from collections.abc import Callable - -Expr = int | tuple[str, "Expr", "Expr"] - -OPS: dict[str, Callable[[int, int], int]] = { - "+": operator.add, - "*": operator.mul, - "-": operator.sub, -} - - -def interpret(expr: Expr) -> int: - if isinstance(expr, int): - return expr - op, left, right = expr - return OPS[op](interpret(left), interpret(right)) - - -def main() -> None: - tree: Expr = ("*", ("+", 2, 3), 4) - print(interpret(tree)) - - -if __name__ == "__main__": - main() diff --git a/patterns/behavioral/interpreter/tests/test_flag_rules.py b/patterns/behavioral/interpreter/tests/test_flag_rules.py new file mode 100644 index 0000000..97491f5 --- /dev/null +++ b/patterns/behavioral/interpreter/tests/test_flag_rules.py @@ -0,0 +1,109 @@ +"""Behavioral tests for the flag-rules mini-project.""" + +from __future__ import annotations + +import pytest + +from patterns.behavioral.interpreter.examples.flag_rules import FlagEngine +from patterns.behavioral.interpreter.pattern import Expr, Value + +FLAGS: dict[str, Expr] = { + "new-dashboard": ("and", (">=", "age", 18), ("==", "country", "CA")), + "beta-exports": ("or", ("==", "plan", "pro"), ("==", "role", "staff")), + "legacy-ui": ("not", (">=", "signup_year", 2024)), +} + + +def _flag(rule: Expr, user: dict[str, Value]) -> bool: + return FlagEngine({"probe": rule}).is_enabled("probe", user) + + +class TestComparisonOperators: + """Every operator, at its boundary — off-by-one is this engine's real risk.""" + + def test_ge_boundary(self) -> None: + assert _flag((">=", "age", 18), {"age": 18}) + assert not _flag((">=", "age", 18), {"age": 17}) + + def test_gt_boundary(self) -> None: + assert not _flag((">", "age", 18), {"age": 18}) + assert _flag((">", "age", 18), {"age": 19}) + + def test_le_boundary(self) -> None: + assert _flag(("<=", "age", 18), {"age": 18}) + assert not _flag(("<=", "age", 18), {"age": 19}) + + def test_lt_boundary(self) -> None: + assert not _flag(("<", "age", 18), {"age": 18}) + assert _flag(("<", "age", 18), {"age": 17}) + + def test_ne(self) -> None: + assert _flag(("!=", "plan", "pro"), {"plan": "free"}) + assert not _flag(("!=", "plan", "pro"), {"plan": "pro"}) + + def test_ordered_comparison_refuses_booleans(self) -> None: + with pytest.raises(ValueError, match="ordered comparison on booleans"): + _flag((">=", "flagged", 1), {"flagged": True}) + + def test_ordered_comparison_refuses_non_numbers(self) -> None: + with pytest.raises(ValueError, match="needs numbers"): + _flag((">=", "plan", 18), {"plan": "pro"}) + + +class TestFlagEngine: + def test_conjunction_requires_both_sides(self) -> None: + engine = FlagEngine(FLAGS) + adult_canadian: dict[str, Value] = {"age": 31, "country": "CA"} + minor_canadian: dict[str, Value] = {"age": 17, "country": "CA"} + adult_american: dict[str, Value] = {"age": 31, "country": "US"} + assert engine.is_enabled("new-dashboard", adult_canadian) + assert not engine.is_enabled("new-dashboard", minor_canadian) + assert not engine.is_enabled("new-dashboard", adult_american) + + def test_disjunction_takes_either_side(self) -> None: + engine = FlagEngine(FLAGS) + assert engine.is_enabled("beta-exports", {"plan": "pro", "role": "user"}) + assert engine.is_enabled("beta-exports", {"plan": "free", "role": "staff"}) + assert not engine.is_enabled("beta-exports", {"plan": "free", "role": "user"}) + + def test_negation(self) -> None: + engine = FlagEngine(FLAGS) + assert engine.is_enabled("legacy-ui", {"signup_year": 2021}) + assert not engine.is_enabled("legacy-ui", {"signup_year": 2025}) + + def test_string_leaf_is_field_when_context_has_it_else_literal(self) -> None: + engine = FlagEngine({"self-country": ("==", "country", "country")}) + # Both leaves resolve to the user's country -> always equal. + assert engine.is_enabled("self-country", {"country": "CA"}) + engine2 = FlagEngine({"is-ca": ("==", "country", "CA")}) + # "CA" is not a context field, so it stays a literal. + assert engine2.is_enabled("is-ca", {"country": "CA"}) + assert not engine2.is_enabled("is-ca", {"country": "US"}) + + def test_unknown_flag_names_the_known_ones(self) -> None: + engine = FlagEngine(FLAGS) + with pytest.raises(KeyError, match="beta-exports"): + engine.is_enabled("nope", {}) + + def test_hostile_rule_depth_is_rejected(self) -> None: + bomb: Expr = ("==", "x", 1) + for _ in range(200): + bomb = ("and", bomb, True) + engine = FlagEngine({"bomb": bomb}) + with pytest.raises(ValueError, match="too deeply nested"): + engine.is_enabled("bomb", {"x": 1}) + + def test_rollout_reports_every_flag(self) -> None: + engine = FlagEngine(FLAGS) + user: dict[str, Value] = { + "age": 31, + "country": "CA", + "plan": "pro", + "role": "user", + "signup_year": 2021, + } + assert engine.rollout(user) == { + "beta-exports": True, + "legacy-ui": True, + "new-dashboard": True, + } diff --git a/patterns/behavioral/interpreter/tests/test_interpreter.py b/patterns/behavioral/interpreter/tests/test_interpreter.py deleted file mode 100644 index 762f951..0000000 --- a/patterns/behavioral/interpreter/tests/test_interpreter.py +++ /dev/null @@ -1,46 +0,0 @@ -"""Behavioral tests for all three interpreter variants.""" - -import pytest - -from patterns.behavioral.interpreter import naive, pythonic, real_world - - -class TestNaive: - def test_tree_interprets(self) -> None: - tree = naive.Mul(naive.Add(naive.Number(2), naive.Number(3)), naive.Number(4)) - assert tree.interpret() == 20 - - -class TestPythonic: - def test_tuple_tree_interprets(self) -> None: - assert pythonic.interpret(("*", ("+", 2, 3), 4)) == 20 - - def test_bare_number(self) -> None: - assert pythonic.interpret(7) == 7 - - def test_language_extends_by_dict_entry(self) -> None: - assert pythonic.interpret(("-", 10, 4)) == 6 - - -class TestRealWorld: - def test_safe_arithmetic(self) -> None: - assert real_world.safe_eval("2 * (3 + 4)") == 14.0 - assert real_world.safe_eval("-5 + 1") == -4.0 - - def test_attack_is_rejected_not_executed(self) -> None: - with pytest.raises(ValueError, match="disallowed"): - real_world.safe_eval("__import__('os').system('true')") - - def test_names_are_rejected(self) -> None: - with pytest.raises(ValueError): - real_world.safe_eval("x + 1") - - def test_bool_constants_are_rejected(self) -> None: - # bool subclasses int; a safe evaluator must not compute True + 1. - with pytest.raises(ValueError, match="disallowed"): - real_world.safe_eval("True + 1") - - def test_hostile_nesting_gets_a_clean_error_not_a_crash(self) -> None: - bomb = "1" + " + 1" * 200 # deeper than MAX_DEPTH - with pytest.raises(ValueError, match="deeply nested"): - real_world.safe_eval(bomb) diff --git a/patterns/behavioral/interpreter/tests/test_rules.py b/patterns/behavioral/interpreter/tests/test_rules.py new file mode 100644 index 0000000..c8b0bfc --- /dev/null +++ b/patterns/behavioral/interpreter/tests/test_rules.py @@ -0,0 +1,112 @@ +"""Behavioral tests for the Interpreter pattern's library code.""" + +from __future__ import annotations + +import operator + +import pytest + +from patterns.behavioral.interpreter.pattern import ( + Expr, + Interpreter, + Operation, + Value, + safe_eval, +) + + +def _binop(fn: object) -> Operation: + def apply(args: tuple[Value, ...]) -> Value: + left, right = args + assert callable(fn) + result: Value = fn(left, right) + return result + + return apply + + +ARITHMETIC: dict[str, Operation] = { + "+": _binop(operator.add), + "*": _binop(operator.mul), + "-": _binop(operator.sub), +} + + +class TestInterpreter: + def test_evaluates_nested_sentences(self) -> None: + interpreter = Interpreter(ARITHMETIC) + tree: Expr = ("*", ("+", 2, 3), 4) + assert interpreter.evaluate(tree) == 20 + + def test_leaves_pass_through_the_resolver(self) -> None: + context = {"age": 31} + interpreter = Interpreter( + ARITHMETIC, + resolve=lambda leaf: context.get(leaf, leaf) if isinstance(leaf, str) else leaf, + ) + assert interpreter.evaluate(("+", "age", 1)) == 32 + + def test_unknown_operation_is_a_value_error(self) -> None: + interpreter = Interpreter(ARITHMETIC) + with pytest.raises(ValueError, match="unknown operation"): + interpreter.evaluate(("/", 1, 2)) + + def test_depth_bomb_fails_cleanly(self) -> None: + interpreter = Interpreter(ARITHMETIC) + bomb: Expr = 1 + for _ in range(200): + bomb = ("+", bomb, 1) + with pytest.raises(ValueError, match="too deeply nested"): + interpreter.evaluate(bomb) + + def test_malformed_tuple_head_rejected(self) -> None: + interpreter = Interpreter(ARITHMETIC) + with pytest.raises(ValueError, match="malformed"): + interpreter.evaluate((1, 2, 3)) + + +class TestSafeEval: + """The hardened arithmetic evaluator keeps its security-review contract.""" + + def test_evaluates_arithmetic(self) -> None: + assert safe_eval("2 * (3 + 4)") == 14.0 + + def test_every_operator_is_pinned(self) -> None: + # The operator table is a security surface: each entry asserted + # individually so a mis-mapped operator cannot survive review. + assert safe_eval("7 + 2") == 9.0 + assert safe_eval("7 - 2") == 5.0 + assert safe_eval("7 * 2") == 14.0 + assert safe_eval("7 / 2") == 3.5 + assert safe_eval("-7") == -7.0 + assert safe_eval("-(3 - 5)") == 2.0 + + def test_division_by_zero_is_a_value_error(self) -> None: + # The documented contract: every rejection is ValueError. + with pytest.raises(ValueError, match="division by zero"): + safe_eval("1/0") + + def test_the_exported_depth_constant_is_the_one_enforced( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + # One MAX_DEPTH knob for the whole unit — tightening the exported + # constant must actually tighten this evaluator. + from patterns.behavioral.interpreter.pattern import rules + + monkeypatch.setattr(rules, "MAX_DEPTH", 3) + with pytest.raises(ValueError, match="too deeply nested"): + safe_eval("1 + 1 + 1 + 1 + 1 + 1") + assert safe_eval("1 + 1") == 2.0 + + def test_rejects_imports_and_names(self) -> None: + with pytest.raises(ValueError, match="disallowed"): + safe_eval("__import__('os')") + + def test_rejects_bool_constants(self) -> None: + with pytest.raises(ValueError, match="disallowed"): + safe_eval("True + 1") + + def test_depth_limit_is_a_value_error_not_recursion(self) -> None: + deep_formula = "1" + " + 1" * 60 # left-deep BinOp tree past MAX_DEPTH + with pytest.raises(ValueError, match="too deeply nested"): + safe_eval(deep_formula) diff --git a/patterns/behavioral/iterator/README.md b/patterns/behavioral/iterator/README.md index be3dc97..98e5917 100644 --- a/patterns/behavioral/iterator/README.md +++ b/patterns/behavioral/iterator/README.md @@ -14,34 +14,16 @@ stdlib_sightings: [iter, next, generators, itertools] # Iterator -## Problem - -Callers want to walk a collection's elements — possibly lazily, possibly in a -custom order — without coupling to its storage. The GoF answer is a separate -cursor object with a "give me the next one" method. - -## Naive solution - -`naive.py` implements the protocol by hand, the way the guide teaches it: -an iterable whose `__iter__` returns a fresh iterator object, and an iterator -with `__next__` (raising `StopIteration`) plus `__iter__` returning itself so -it can be used directly in a `for` loop. - -## Pythonic solution - -Python absorbed this pattern deeper than any other — `for`, unpacking, and -comprehensions all speak the protocol natively, and **generators** write the -iterator for you: a function with `yield` returns an object implementing -`__iter__` and `__next__` correctly, with all cursor state kept in the frame. -`pythonic.py` re-does `naive.py` in a fraction of the code. - -## In the wild - -`itertools` is an entire stdlib module of composable iterators; files iterate -by line; `dict` yields keys. `real_world.py` composes `itertools.islice` and -`itertools.count` into a lazy, infinite-but-bounded pipeline. - -## Verdict - -**Pythonic.** Know the manual protocol (it's the machinery underneath), write -generators in practice. +Traverse elements without exposing storage — lazily when it matters. +**Verdict: pythonic** — the pattern is the language; write generators. + +| Where | What | +|---|---| +| [`pattern/`](pattern/) | The importable code: `iterate_pages` — chunked traversal behind one generator | +| [`docs/`](docs/) | [Fundamentals](docs/fundamentals.md) · [Implementation guide](docs/implementation.md) · [External examples](docs/examples.md) | +| [`examples/paginated_client/`](examples/paginated_client/) | Mini-project: observably lazy article API client built on `pattern/` | +| [`tests/`](tests/) | Behavioral tests for the pattern and the mini-project | + +```bash +uv run python -m patterns.behavioral.iterator.examples.paginated_client +``` diff --git a/patterns/behavioral/iterator/__init__.py b/patterns/behavioral/iterator/__init__.py index c910a88..146d335 100644 --- a/patterns/behavioral/iterator/__init__.py +++ b/patterns/behavioral/iterator/__init__.py @@ -1 +1,8 @@ -"""Iterator: traverse a container without exposing its storage.""" +"""Iterator — public API. + +>>> from patterns.behavioral.iterator import iterate_pages +""" + +from patterns.behavioral.iterator.pattern import PageFetcher, iterate_pages + +__all__ = ["PageFetcher", "iterate_pages"] diff --git a/patterns/behavioral/iterator/docs/examples.md b/patterns/behavioral/iterator/docs/examples.md new file mode 100644 index 0000000..0ecd371 --- /dev/null +++ b/patterns/behavioral/iterator/docs/examples.md @@ -0,0 +1,36 @@ +# Iterator — where it lives outside this repo + +Cited, real implementations to study (or point an agent at) when designing or +reviewing traversal code. + +## Python standard library + +- **Generators and `itertools`** — the pattern as language feature plus a + toolbox of composable iterators (`count`, `islice`, `chain`, `tee`). + [docs.python.org/3/library/itertools.html](https://docs.python.org/3/library/itertools.html) +- **`os.walk` / `pathlib.Path.iterdir`** — lazy filesystem traversal: a + directory tree of any size, constant memory. + [docs.python.org/3/library/os.html#os.walk](https://docs.python.org/3/library/os.html#os.walk) +- **`csv.reader`** — file rows as an iterator; the file object underneath is + itself an iterator of lines. + [docs.python.org/3/library/csv.html](https://docs.python.org/3/library/csv.html) + +## Major ecosystems + +- **Django `QuerySet`** — lazily evaluated; `.iterator()` streams rows over + a server-side cursor instead of caching the whole result: the + page-hiding move at ORM scale. + [docs.djangoproject.com/en/stable/ref/models/querysets/#iterator](https://docs.djangoproject.com/en/stable/ref/models/querysets/#iterator) +- **boto3 paginators** — AWS list APIs return truncated pages; a paginator + wraps the continuation-token dance into one iterable, exactly this unit's + `iterate_pages` shape. + [boto3.amazonaws.com/v1/documentation/api/latest/guide/paginators.html](https://boto3.amazonaws.com/v1/documentation/api/latest/guide/paginators.html) *(unverified)* + +## What to notice across all of them + +Every one hides a *cursor protocol* (continuation tokens, DB cursors, file +offsets) behind the one protocol Python already speaks. And every one +documents its laziness as a feature with consequences — Django warns that +`.iterator()` skips caching; file iterators exhaust. When reviewing, ask: +does the signature promise `Iterator`, and does anything downstream silently +materialize it? diff --git a/patterns/behavioral/iterator/docs/fundamentals.md b/patterns/behavioral/iterator/docs/fundamentals.md new file mode 100644 index 0000000..ec77626 --- /dev/null +++ b/patterns/behavioral/iterator/docs/fundamentals.md @@ -0,0 +1,94 @@ +# Iterator — fundamentals + +## Intent + +Traverse a collection's elements — possibly lazily, possibly remote — +without exposing how the collection stores them. Callers say "next"; +the cursor's bookkeeping is someone else's problem. + +## Participants + +| Role | Classic (GoF) form | Python form | +|---|---|---| +| Iterator | An object with `next()`/`done()` | Anything with `__next__` — in practice, a generator's frame | +| Concrete iterator | A class holding cursor state | The paused generator frame holds it for free | +| Aggregate | `createIterator()` factory method | `__iter__`, usually written *as* a generator | +| Client | Calls `next()` in a loop | `for`, comprehensions, unpacking — the protocol is the language | + +## Mechanism + +1. The iterable's `__iter__` returns a fresh iterator (so two loops don't + share a cursor). +2. The iterator's `__next__` returns items and raises `StopIteration` when + done; its own `__iter__` returns itself. +3. A generator function implements all of it: each `yield` suspends the + frame, and the frame *is* the cursor state. + +## The classic form, and what Python absorbs + +The protocol implemented by hand, the way the guide teaches it: + +```python +from __future__ import annotations # OddIterator is named before it exists + + +class OddNumbers: # the aggregate + def __init__(self, maximum: int) -> None: + self.maximum = maximum + + def __iter__(self) -> OddIterator: + return OddIterator(self) # fresh cursor per loop + + +class OddIterator: # the cursor object + def __init__(self, container: OddNumbers) -> None: + self.container = container + self.n = -1 # cursor state, managed by hand + + def __next__(self) -> int: + self.n += 2 + if self.n > self.container.maximum: + raise StopIteration + return self.n + + def __iter__(self) -> OddIterator: + return self +``` + +Python absorbed this pattern deeper than any other. The same behavior as a +generator is four lines — the cursor class vanishes into the paused frame: + +```python +from collections.abc import Iterator + + +def odd_numbers(maximum: int) -> Iterator[int]: + n = 1 + while n <= maximum: + yield n + n += 2 +``` + +What survives as a *design* move is hiding a non-trivial traversal (pages, +cursors, chunked reads) behind one generator — this module's +[`iterate_pages`](../pattern/paging.py). + +## When to use it + +- Custom or lazy traversal over your own types: write `__iter__` as a + generator. +- Chunked/remote sources (paginated APIs, cursored queries): expose one + generator; keep pages out of caller code. + +## When not to use it + +- Hand-writing `__next__` — a generator implements the protocol correctly + for you; the manual form is for understanding, not production. +- Materializing everything into a list "to be safe" — you just deleted the + laziness that justified the pattern. + +## Verdict: pythonic + +The pattern is the language. Know the manual protocol (it is the machinery +underneath); write generators in practice. Guide chapter: +[python-patterns.guide/gang-of-four/iterator/](https://python-patterns.guide/gang-of-four/iterator/) diff --git a/patterns/behavioral/iterator/docs/implementation.md b/patterns/behavioral/iterator/docs/implementation.md new file mode 100644 index 0000000..3e11ca1 --- /dev/null +++ b/patterns/behavioral/iterator/docs/implementation.md @@ -0,0 +1,65 @@ +# Iterator — putting it into a system + +## The smell it fixes + +Pagination leaking everywhere: every caller of your API client repeats the +same `while page: fetch, extend, page += 1` dance — or worse, someone +"simplifies" it to `fetch_all()` and the service melts when a tenant has a +million records. + +## Steps + +1. **Find the traversal that callers keep re-implementing** (pages, DB + cursors, chunked file reads, retry-and-continue scans). +2. **Write it once as a generator.** The generator owns the cursor, + the stop condition, and nothing else: + + ```python + from patterns.behavioral.iterator import iterate_pages + + + def articles(self) -> Iterator[str]: + return iterate_pages(self._backend.fetch) + ``` + +3. **Return `Iterator[T]`, not `list[T]`.** The signature is the promise of + laziness; a list return silently repeals it. +4. **Let callers bound the work** with `itertools.islice` / early `break` — + that's the payoff; don't add a `limit=` parameter that re-implements it. +5. **Test the laziness, not just the items.** Log fetches in the fake + backend and assert consuming 7 items touched 2 pages. If laziness is the + contract, an eager regression must fail a test. + +## Python idioms that keep it small + +- `__iter__` **written as a generator** makes any class iterable in one + line — no iterator class. +- **Compose, don't accumulate**: `islice(count(), …)`, `chain`, and + generator expressions build pipelines where nothing runs until iteration. +- A generator that must clean up (close a cursor) should be consumed with + `contextlib.closing` or wrapped in a context manager — say which in its + docstring. + +## Pitfalls + +- **Iterators exhaust.** A generator iterates once; a second `for` gets + nothing. Return a *fresh* iterator per call (as `articles()` does), and + never stash a half-consumed one in shared state. +- **The container/iterator confusion**: the container's `__iter__` returns a + fresh iterator; the iterator's `__iter__` returns itself. Swap them and + nested loops break mysteriously. +- **Side effects in generators run late** (or never, if the caller stops + early). Don't hide commits or releases inside a traversal. +- **`StopIteration` escaping a generator body** — say, from an unguarded + `next()` call inside it — would silently end the generator; PEP 479 + converts that escape into a `RuntimeError` so the bug is loud. Guard + inner `next()` calls with a default or `except StopIteration`. + +## Worked example + +[`examples/paginated_client/`](../examples/paginated_client/) applies every +step to an article API client with an observably lazy fetch log: + +```bash +uv run python -m patterns.behavioral.iterator.examples.paginated_client +``` diff --git a/patterns/behavioral/iterator/examples/__init__.py b/patterns/behavioral/iterator/examples/__init__.py new file mode 100644 index 0000000..e50b2ee --- /dev/null +++ b/patterns/behavioral/iterator/examples/__init__.py @@ -0,0 +1 @@ +"""Mini-projects demonstrating the Iterator pattern in practice.""" diff --git a/patterns/behavioral/iterator/examples/paginated_client/__init__.py b/patterns/behavioral/iterator/examples/paginated_client/__init__.py new file mode 100644 index 0000000..fdf8cc6 --- /dev/null +++ b/patterns/behavioral/iterator/examples/paginated_client/__init__.py @@ -0,0 +1,9 @@ +"""A paginated API client built on the Iterator pattern. + +Run it: ``uv run python -m patterns.behavioral.iterator.examples.paginated_client`` +""" + +from patterns.behavioral.iterator.examples.paginated_client.backend import FakeBackend +from patterns.behavioral.iterator.examples.paginated_client.client import ArticleClient + +__all__ = ["ArticleClient", "FakeBackend"] diff --git a/patterns/behavioral/iterator/examples/paginated_client/__main__.py b/patterns/behavioral/iterator/examples/paginated_client/__main__.py new file mode 100644 index 0000000..42cbe5a --- /dev/null +++ b/patterns/behavioral/iterator/examples/paginated_client/__main__.py @@ -0,0 +1,24 @@ +"""Demo: consume a few articles; observe how few pages were fetched.""" + +from __future__ import annotations + +import itertools + +from patterns.behavioral.iterator.examples.paginated_client.backend import FakeBackend +from patterns.behavioral.iterator.examples.paginated_client.client import ArticleClient + + +def main() -> None: + backend = FakeBackend([f"article-{n:02d}" for n in range(30)], page_size=5) + client = ArticleClient(backend) + + first_seven = list(itertools.islice(client.articles(), 7)) + print(f"read {len(first_seven)} articles: {first_seven[0]} .. {first_seven[-1]}") + print(f"pages fetched: {backend.fetch_log} (30 articles = 6 pages exist)") + + total = sum(1 for _ in client.articles()) + print(f"full scan: {total} articles, pages fetched now: {backend.fetch_log}") + + +if __name__ == "__main__": + main() diff --git a/patterns/behavioral/iterator/examples/paginated_client/backend.py b/patterns/behavioral/iterator/examples/paginated_client/backend.py new file mode 100644 index 0000000..0f7ac5e --- /dev/null +++ b/patterns/behavioral/iterator/examples/paginated_client/backend.py @@ -0,0 +1,22 @@ +"""A fake HTTP-ish backend that serves articles in pages and logs each fetch. + +The fetch log is the point: tests (and the demo) read it to *prove* the +client fetched only the pages iteration actually consumed. +""" + +from __future__ import annotations + + +class FakeBackend: + """Serves ``articles`` in pages of ``page_size``; records every request.""" + + def __init__(self, articles: list[str], page_size: int = 10) -> None: + self._articles = list(articles) + self._page_size = page_size + self.fetch_log: list[int] = [] + + def fetch(self, page_number: int) -> list[str]: + """One page of articles; empty past the end. Every call is logged.""" + self.fetch_log.append(page_number) + start = page_number * self._page_size + return self._articles[start : start + self._page_size] diff --git a/patterns/behavioral/iterator/examples/paginated_client/client.py b/patterns/behavioral/iterator/examples/paginated_client/client.py new file mode 100644 index 0000000..096aa57 --- /dev/null +++ b/patterns/behavioral/iterator/examples/paginated_client/client.py @@ -0,0 +1,19 @@ +"""The client: one generator hides pages, cursors, and fetch calls.""" + +from __future__ import annotations + +from collections.abc import Iterator + +from patterns.behavioral.iterator.examples.paginated_client.backend import FakeBackend +from patterns.behavioral.iterator.pattern import iterate_pages + + +class ArticleClient: + """Callers iterate articles; pagination never leaks into their code.""" + + def __init__(self, backend: FakeBackend) -> None: + self._backend = backend + + def articles(self) -> Iterator[str]: + """All articles, fetched lazily page by page as iteration demands.""" + return iterate_pages(self._backend.fetch) diff --git a/patterns/behavioral/iterator/naive.py b/patterns/behavioral/iterator/naive.py deleted file mode 100644 index b0e4a69..0000000 --- a/patterns/behavioral/iterator/naive.py +++ /dev/null @@ -1,46 +0,0 @@ -"""The iterator protocol implemented by hand. - -The guide's three rules: -1. the iterable's ``__iter__`` returns a new iterator; -2. the iterator's ``__next__`` returns items and raises ``StopIteration``; -3. the iterator's ``__iter__`` returns itself. -""" - -from __future__ import annotations - - -class OddNumbers: - """An iterable: knows its contents, delegates traversal.""" - - def __init__(self, maximum: int) -> None: - self.maximum = maximum - - def __iter__(self) -> OddIterator: - return OddIterator(self) - - -class OddIterator: - """An iterator: owns the cursor state.""" - - def __init__(self, container: OddNumbers) -> None: - self.container = container - self.n = -1 - - def __next__(self) -> int: - self.n += 2 - if self.n > self.container.maximum: - raise StopIteration - return self.n - - def __iter__(self) -> OddIterator: - return self - - -def main() -> None: - numbers = OddNumbers(7) - print(list(numbers)) - print(list(numbers)) # a fresh iterator each time -- iteration restarts - - -if __name__ == "__main__": - main() diff --git a/patterns/behavioral/iterator/pattern/__init__.py b/patterns/behavioral/iterator/pattern/__init__.py new file mode 100644 index 0000000..40d9240 --- /dev/null +++ b/patterns/behavioral/iterator/pattern/__init__.py @@ -0,0 +1,5 @@ +"""The Iterator pattern, importable as library code.""" + +from patterns.behavioral.iterator.pattern.paging import PageFetcher, iterate_pages + +__all__ = ["PageFetcher", "iterate_pages"] diff --git a/patterns/behavioral/iterator/pattern/paging.py b/patterns/behavioral/iterator/pattern/paging.py new file mode 100644 index 0000000..5bcecde --- /dev/null +++ b/patterns/behavioral/iterator/pattern/paging.py @@ -0,0 +1,31 @@ +"""Iterator as an importable building block: traversal behind one generator. + +Python absorbed this pattern into the language — ``for``, comprehensions, +and generators all speak the protocol. What remains worth packaging is the +*shape*: hide a chunked or remote traversal behind a single generator so +callers iterate items and never see pages, cursors, or fetch calls. +""" + +from __future__ import annotations + +import itertools +from collections.abc import Callable, Iterator, Sequence +from typing import TypeVar + +T = TypeVar("T") + +#: Fetches one zero-indexed page; an empty page means the sequence is over. +PageFetcher = Callable[[int], Sequence[T]] + + +def iterate_pages(fetch_page: PageFetcher[T]) -> Iterator[T]: + """Yield items lazily, page by page, stopping at the first empty page. + + Nothing is fetched until iteration demands it, and only the pages + actually consumed are ever requested — the laziness is the contract. + """ + for page_number in itertools.count(): + page = fetch_page(page_number) + if not page: + return + yield from page diff --git a/patterns/behavioral/iterator/pythonic.py b/patterns/behavioral/iterator/pythonic.py deleted file mode 100644 index 2590125..0000000 --- a/patterns/behavioral/iterator/pythonic.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Generators: the iterator pattern as a language feature. - -A function with ``yield`` returns an object that already implements -``__iter__`` and ``__next__``; the cursor state lives in the paused frame. -An ``__iter__`` written as a generator makes a class iterable in one line. -""" - -from __future__ import annotations - -from collections.abc import Iterator - - -def odd_numbers(maximum: int) -> Iterator[int]: - """The whole of naive.py, as a generator.""" - n = 1 - while n <= maximum: - yield n - n += 2 - - -class OddNumbers: - """An iterable class whose __iter__ is itself a generator.""" - - def __init__(self, maximum: int) -> None: - self.maximum = maximum - - def __iter__(self) -> Iterator[int]: - n = 1 - while n <= self.maximum: - yield n - n += 2 - - -def main() -> None: - print(list(odd_numbers(7))) - print(list(OddNumbers(7))) - print([n * n for n in OddNumbers(9)]) # comprehensions speak the protocol - - -if __name__ == "__main__": - main() diff --git a/patterns/behavioral/iterator/real_world.py b/patterns/behavioral/iterator/real_world.py deleted file mode 100644 index 650a16d..0000000 --- a/patterns/behavioral/iterator/real_world.py +++ /dev/null @@ -1,26 +0,0 @@ -"""``itertools``: the stdlib's iterator toolbox. - -Iterators compose: ``count`` is infinite, ``islice`` bounds it, and nothing -is computed until iteration demands it. -""" - -from __future__ import annotations - -import itertools -from collections.abc import Iterator - - -def first_n_odd_squares(n: int) -> Iterator[int]: - """A lazy pipeline over an infinite source.""" - odds = itertools.count(start=1, step=2) # 1, 3, 5, ... forever - return itertools.islice((x * x for x in odds), n) - - -def main() -> None: - print(list(first_n_odd_squares(5))) - evens_then_odds = itertools.chain([0, 2, 4], [1, 3, 5]) - print(list(evens_then_odds)) - - -if __name__ == "__main__": - main() diff --git a/patterns/behavioral/iterator/tests/test_iterator.py b/patterns/behavioral/iterator/tests/test_iterator.py deleted file mode 100644 index e2c9b4a..0000000 --- a/patterns/behavioral/iterator/tests/test_iterator.py +++ /dev/null @@ -1,40 +0,0 @@ -"""Behavioral tests for all three iterator variants.""" - -import pytest - -from patterns.behavioral.iterator import naive, pythonic, real_world - - -class TestNaive: - def test_yields_odds_up_to_maximum(self) -> None: - assert list(naive.OddNumbers(7)) == [1, 3, 5, 7] - - def test_iterable_restarts_iterator_does_not(self) -> None: - numbers = naive.OddNumbers(5) - assert list(numbers) == list(numbers) == [1, 3, 5] - it = iter(numbers) - assert list(it) == [1, 3, 5] - assert list(it) == [] # the iterator itself is exhausted - - def test_next_raises_stop_iteration_when_done(self) -> None: - it = iter(naive.OddNumbers(1)) - assert next(it) == 1 - with pytest.raises(StopIteration): - next(it) - - -class TestPythonic: - def test_generator_function_matches_naive(self) -> None: - assert list(pythonic.odd_numbers(7)) == [1, 3, 5, 7] - - def test_generator_dunder_iter_makes_class_iterable(self) -> None: - assert list(pythonic.OddNumbers(9)) == [1, 3, 5, 7, 9] - - def test_generators_are_lazy(self) -> None: - gen = pythonic.odd_numbers(10**12) # instant: nothing computed yet - assert next(gen) == 1 - - -class TestRealWorld: - def test_bounded_pipeline_over_infinite_source(self) -> None: - assert list(real_world.first_n_odd_squares(4)) == [1, 9, 25, 49] diff --git a/patterns/behavioral/iterator/tests/test_paginated_client.py b/patterns/behavioral/iterator/tests/test_paginated_client.py new file mode 100644 index 0000000..025a045 --- /dev/null +++ b/patterns/behavioral/iterator/tests/test_paginated_client.py @@ -0,0 +1,41 @@ +"""Behavioral tests for the paginated-client mini-project.""" + +from __future__ import annotations + +import itertools + +from patterns.behavioral.iterator.examples.paginated_client import ( + ArticleClient, + FakeBackend, +) + + +def _backend(count: int = 30, page_size: int = 5) -> FakeBackend: + return FakeBackend([f"article-{n:02d}" for n in range(count)], page_size) + + +class TestArticleClient: + def test_full_iteration_sees_every_article_in_order(self) -> None: + backend = _backend(12, page_size=5) + client = ArticleClient(backend) + articles = list(client.articles()) + assert len(articles) == 12 + assert articles[0] == "article-00" + assert articles[-1] == "article-11" + + def test_consuming_seven_articles_fetches_two_pages(self) -> None: + backend = _backend(30, page_size=5) + client = ArticleClient(backend) + list(itertools.islice(client.articles(), 7)) + assert backend.fetch_log == [0, 1] # pages 2..5 were never requested + + def test_each_call_returns_a_fresh_iterator(self) -> None: + backend = _backend(4, page_size=2) + client = ArticleClient(backend) + assert list(client.articles()) == list(client.articles()) + + def test_empty_backend(self) -> None: + backend = _backend(0) + client = ArticleClient(backend) + assert list(client.articles()) == [] + assert backend.fetch_log == [0] diff --git a/patterns/behavioral/iterator/tests/test_paging.py b/patterns/behavioral/iterator/tests/test_paging.py new file mode 100644 index 0000000..7b06fb9 --- /dev/null +++ b/patterns/behavioral/iterator/tests/test_paging.py @@ -0,0 +1,46 @@ +"""Behavioral tests for the Iterator pattern's library code.""" + +from __future__ import annotations + +import itertools + +from patterns.behavioral.iterator.pattern import iterate_pages + + +class TestIteratePages: + def test_yields_all_items_across_pages(self) -> None: + pages = [[1, 2], [3, 4], [5]] + fetched: list[int] = [] + + def fetch(n: int) -> list[int]: + fetched.append(n) + return pages[n] if n < len(pages) else [] + + assert list(iterate_pages(fetch)) == [1, 2, 3, 4, 5] + assert fetched == [0, 1, 2, 3] # one probe past the end, no more + + def test_is_lazy_until_iterated(self) -> None: + fetched: list[int] = [] + + def fetch(n: int) -> list[int]: + fetched.append(n) + return [n] if n < 5 else [] + + iterator = iterate_pages(fetch) + assert fetched == [] # creating the iterator fetched nothing + next(iterator) + assert fetched == [0] + + def test_partial_consumption_fetches_only_needed_pages(self) -> None: + fetched: list[int] = [] + + def fetch(n: int) -> list[int]: + fetched.append(n) + return list(range(n * 3, n * 3 + 3)) if n < 10 else [] + + first_four = list(itertools.islice(iterate_pages(fetch), 4)) + assert first_four == [0, 1, 2, 3] + assert fetched == [0, 1] # 10 pages exist; 2 were touched + + def test_empty_source_yields_nothing(self) -> None: + assert list(iterate_pages(lambda n: [])) == [] diff --git a/patterns/behavioral/mediator/README.md b/patterns/behavioral/mediator/README.md index 27a99d4..003100c 100644 --- a/patterns/behavioral/mediator/README.md +++ b/patterns/behavioral/mediator/README.md @@ -14,33 +14,17 @@ stdlib_sightings: [queue.Queue, asyncio.Queue] # Mediator -## Problem - -A signup form: the submit button enables only when username and password -fields validate, the password strength meter watches the password field… -Let the widgets reference each other and you get N² couplings that no one -can safely change. - -## Naive solution - -`naive.py` is the GoF dialog: colleagues report every change to the mediator -and *only* the mediator decides who reacts. - -## Pythonic solution - -The mediator doesn't need a Colleague base class — widgets accept a -`notify` callable and hold zero rules. `pythonic.py` scales the idea to a -checkout form whose rules genuinely tangle (country restricts shipping, -shipping gates payment and changes the total): one `_recheck` method holds -every rule, and a country change cascades through the dependent fields. - -## In the wild - -`queue.Queue` mediates producers and consumers: neither side knows the -other exists, and the coupling that used to be pairwise lives in one -thread-safe object. +Route component interactions through one coordinator that owns every rule. +**Verdict: use with care** — excellent for genuinely tangled rules; watch +for god-object drift. -## Verdict +| Where | What | +|---|---| +| [`pattern/`](pattern/) | The importable code: `Field` (dumb value holder) + `Form` (the mediator base owning `recheck`) | +| [`docs/`](docs/) | [Fundamentals](docs/fundamentals.md) · [Implementation guide](docs/implementation.md) · [External examples](docs/examples.md) | +| [`examples/checkout_form/`](examples/checkout_form/) | Mini-project: cascading checkout rules built on `pattern/` | +| [`tests/`](tests/) | Behavioral tests for the pattern and the mini-project | -**Use with care.** Excellent for genuinely tangled interaction rules; watch -for god-object drift. +```bash +uv run python -m patterns.behavioral.mediator.examples.checkout_form +``` diff --git a/patterns/behavioral/mediator/__init__.py b/patterns/behavioral/mediator/__init__.py index 42a9583..4982c15 100644 --- a/patterns/behavioral/mediator/__init__.py +++ b/patterns/behavioral/mediator/__init__.py @@ -1 +1,8 @@ -"""Mediator: interactions routed through one coordinator.""" +"""Mediator — public API. + +>>> from patterns.behavioral.mediator import Field, Form +""" + +from patterns.behavioral.mediator.pattern import Field, Form + +__all__ = ["Field", "Form"] diff --git a/patterns/behavioral/mediator/docs/examples.md b/patterns/behavioral/mediator/docs/examples.md new file mode 100644 index 0000000..d7382c9 --- /dev/null +++ b/patterns/behavioral/mediator/docs/examples.md @@ -0,0 +1,36 @@ +# Mediator — where it lives outside this repo + +Cited, real implementations to study (or point an agent at) when designing or +reviewing coordinator-shaped code. + +## Python standard library + +- **`queue.Queue` / `asyncio.Queue`** — the degenerate mediator: producers + and consumers know the queue and never each other; the pairwise coupling + that would exist lives in one thread-safe object. + [docs.python.org/3/library/queue.html](https://docs.python.org/3/library/queue.html) +- **Tk variable tracing** — `tkinter` widgets coordinate through shared + `Variable` objects with trace callbacks rather than direct references. + [docs.python.org/3/library/tkinter.html](https://docs.python.org/3/library/tkinter.html) *(unverified)* + +## Major ecosystems + +- **Django `Form.clean()`** — cross-field validation in one method: fields + that depend on each other never reference each other; the form owns the + rule ("if shipping is express, phone is required"). + [docs.djangoproject.com/en/stable/ref/forms/validation/#cleaning-and-validating-fields-that-depend-on-each-other](https://docs.djangoproject.com/en/stable/ref/forms/validation/#cleaning-and-validating-fields-that-depend-on-each-other) +- **python-telegram-bot's `Application`** — handlers register with one + dispatcher; updates route through it; handlers never call each other. + [docs.python-telegram-bot.org](https://docs.python-telegram-bot.org/) *(unverified)* +- **Message brokers (RabbitMQ, Kafka)** — the mediator at architecture + scale: every producer and consumer couples to the broker's topology, none + to each other. The god-object risk scales up too — topic sprawl is + `recheck` sprawl. *(concept citation)* + +## What to notice across all of them + +Each one is defined by the references it *removes* — Django fields don't +import each other, queue consumers can't name their producers. And each +bounds the mediator's scope: `clean()` owns validation only, a queue owns +transport only. When reviewing mediator code, ask what pairwise references +died, and what stops the hub from absorbing rules that belong to the domain. diff --git a/patterns/behavioral/mediator/docs/fundamentals.md b/patterns/behavioral/mediator/docs/fundamentals.md new file mode 100644 index 0000000..fc00691 --- /dev/null +++ b/patterns/behavioral/mediator/docs/fundamentals.md @@ -0,0 +1,77 @@ +# Mediator — fundamentals + +## Intent + +Stop a web of objects from referencing each other by routing all their +interaction through one coordinator. N components with pairwise rules is N² +couplings; a mediator makes it N spokes and one hub that owns every rule. + +## Participants + +| Role | Classic (GoF) form | Python form | +|---|---|---| +| Mediator | An interface, then a ConcreteMediator | A [`Form`](../pattern/form.py) subclass whose single `recheck` method holds every rule | +| Colleagues | A `Widget` base class holding a mediator reference | Plain value holders wired with a `notify` callable — [`Field`](../pattern/form.py), created via `Form.add_field` | +| Interaction protocol | `mediator.widget_changed(widget)` | Any change calls `notify()`; the mediator re-derives the whole state | + +## Mechanism + +1. Components hold values and report changes; they contain zero rules. +2. On any change, the mediator recomputes every derived fact and cascades: + invalidated selections reset, dependent options update, gating re-checks. +3. Rules are readable in one place — and testable without any UI. + +## The classic form, and what Python absorbs + +The textbook dialog threads a Colleague hierarchy through a mediator +interface: + +```python +from __future__ import annotations # SignupDialog is named before it exists + + +class Widget: + def __init__(self, mediator: SignupDialog, name: str) -> None: + self.mediator = mediator # every widget carries the wiring + self.name = name + + def changed(self) -> None: + self.mediator.widget_changed(self) + + +class TextField(Widget): ... # subclasses per widget kind + + +class Button(Widget): ... + + +class SignupDialog: # the mediator + def widget_changed(self, _widget: Widget) -> None: + self.submit.enabled = bool(self.username.text) and len(self.password.text) >= 8 +``` + +Python needs none of the hierarchy: a widget is a value holder plus a +`notify` callable, and the mediator is whoever handed out that callable. +What survives is the *discipline*, not the class diagram: *widgets dumb, +rules in one place*. For pipeline-shaped decoupling, the language absorbs +the pattern further still — `queue.Queue` is a degenerate mediator where +the only rule is "hand items across". + +## When to use it + +- Interaction rules genuinely tangle: field A restricts B, B gates C, C + changes a total — and the set must stay coherent after every change. +- You are deleting pairwise references: each component should know the hub, + never a sibling. + +## When not to use it + +- Two components, one rule → a direct callback is honest and shorter. +- Broadcast with no cross-rules ("tell everyone it changed") → Observer. +- Producer/consumer decoupling → a queue *is* the mediator; don't wrap one. + +## Verdict: use with care + +The mediator earns its keep by the references it deletes. If it grows into a +god object that knows every domain rule in the app, you traded a web for a +blob — split it by interaction cluster. diff --git a/patterns/behavioral/mediator/docs/implementation.md b/patterns/behavioral/mediator/docs/implementation.md new file mode 100644 index 0000000..1335a74 --- /dev/null +++ b/patterns/behavioral/mediator/docs/implementation.md @@ -0,0 +1,66 @@ +# Mediator — putting it into a system + +## The smell it fixes + +Widgets (or services) updating each other directly: the country dropdown +pokes the shipping selector, which pokes payment, which pokes the total — +and adding one field means auditing every other field's handlers. + +## Steps + +1. **Inventory the cross-component rules** — write each as a sentence + ("cash-on-delivery is only offered on express"). These sentences become + one method's body, so their number tells you the mediator's size. +2. **Dumb the components down** to value + change notification. + [`Field`](../pattern/form.py) is that reduced form; subclass + [`Form`](../pattern/form.py) and create each one with `add_field`, so + every change notifies the one mediator. +3. **Write one `recheck` that re-derives everything** from current values: + recompute options, reset invalidated selections, update totals, gate + submission. Deriving the *whole* state each time is what makes cascades + (country → shipping → payment) fall out for free. +4. **Keep the rules as data where they are data.** Tables like + `SHIPPING_BY_COUNTRY` stay dicts the mediator consults — don't encode + them as conditionals. +5. **Test the mediator headlessly.** The rules never needed a UI: set + values, assert derived state — including the cascade paths. + +```python +form = CheckoutForm(cart_cents=5000) +form.country.set("CA") +form.shipping.set("express") +assert form.payment_options == ("card", "cod") +``` + +## Python idioms that keep it small + +- The notify wire is **just a bound method** (`add_field` wires each + `Field` to `self.recheck`) — no observer framework, no signals library. +- Recompute-everything beats surgical updates until profiling says + otherwise: correctness first, the rules stay declarative. +- Components that are values-with-validation can be **dataclasses**; the + mediator subclass composes its fields in `__init__` via `add_field` and + ends with one initial `recheck()` so derived state starts coherent. + +## Pitfalls + +- **God-object drift** — the mediator's budget is *interaction* rules; the + moment domain logic (pricing, tax) moves in, split it: mediator + coordinates, domain objects compute. +- **Notification loops.** `recheck` writing `field.value` directly (not via + `set`) is deliberate here — calling `set` from inside the mediator would + re-enter it. Keep one direction: components notify in, mediator writes out. +- **Hidden ordering dependencies** between rules in `recheck` — derive + facts in dependency order (options before validity before gating) and + test the cascade explicitly. +- **A queue would do.** If your "rules" are only "pass work along", + `queue.Queue` is the whole mediator. + +## Worked example + +[`examples/checkout_form/`](../examples/checkout_form/) applies every step — +country/shipping/payment with cascading resets and submit gating: + +```bash +uv run python -m patterns.behavioral.mediator.examples.checkout_form +``` diff --git a/patterns/behavioral/mediator/examples/__init__.py b/patterns/behavioral/mediator/examples/__init__.py new file mode 100644 index 0000000..c8350a9 --- /dev/null +++ b/patterns/behavioral/mediator/examples/__init__.py @@ -0,0 +1 @@ +"""Mini-projects demonstrating the Mediator pattern in practice.""" diff --git a/patterns/behavioral/mediator/examples/checkout_form/__init__.py b/patterns/behavioral/mediator/examples/checkout_form/__init__.py new file mode 100644 index 0000000..fcf8726 --- /dev/null +++ b/patterns/behavioral/mediator/examples/checkout_form/__init__.py @@ -0,0 +1,12 @@ +"""A checkout form built on the Mediator pattern. + +Run it: ``uv run python -m patterns.behavioral.mediator.examples.checkout_form`` +""" + +from patterns.behavioral.mediator.examples.checkout_form.form import CheckoutForm +from patterns.behavioral.mediator.examples.checkout_form.rules import ( + PAYMENTS_BY_SHIPPING, + SHIPPING_BY_COUNTRY, +) + +__all__ = ["PAYMENTS_BY_SHIPPING", "SHIPPING_BY_COUNTRY", "CheckoutForm"] diff --git a/patterns/behavioral/mediator/examples/checkout_form/__main__.py b/patterns/behavioral/mediator/examples/checkout_form/__main__.py new file mode 100644 index 0000000..d0d6354 --- /dev/null +++ b/patterns/behavioral/mediator/examples/checkout_form/__main__.py @@ -0,0 +1,29 @@ +"""Demo: a scripted checkout interaction, including the cascade.""" + +from __future__ import annotations + +from patterns.behavioral.mediator.examples.checkout_form.form import CheckoutForm + + +def main() -> None: + form = CheckoutForm(cart_cents=5000) + print(f"start: options={form.shipping_options}, submit={form.submit_enabled}") + + form.country.set("CA") + form.shipping.set("express") + form.payment.set("cod") + print(f"CA/express/cod: total={form.total_cents}, submit={form.submit_enabled}") + + form.country.set("DE") # express vanishes; dependent fields reset + print( + f"switch to DE: shipping={form.shipping.value!r}, " + f"payment={form.payment.value!r}, submit={form.submit_enabled}" + ) + + form.shipping.set("standard") + form.payment.set("card") + print(f"DE/standard/card: total={form.total_cents}, submit={form.submit_enabled}") + + +if __name__ == "__main__": + main() diff --git a/patterns/behavioral/mediator/examples/checkout_form/form.py b/patterns/behavioral/mediator/examples/checkout_form/form.py new file mode 100644 index 0000000..c3608c9 --- /dev/null +++ b/patterns/behavioral/mediator/examples/checkout_form/form.py @@ -0,0 +1,44 @@ +"""The mediator: a checkout form whose rules genuinely tangle. + +Country restricts shipping methods; shipping gates payment options and +changes the total; submit enables only when the whole set is coherent. +Fields know none of it — every rule lives in ``recheck``, one readable +place, and a country change cascades through the dependent fields. +""" + +from __future__ import annotations + +from patterns.behavioral.mediator.examples.checkout_form.rules import ( + PAYMENTS_BY_SHIPPING, + SHIPPING_BY_COUNTRY, +) +from patterns.behavioral.mediator.pattern import Form + + +class CheckoutForm(Form): + """Every cross-field rule, in one place — the ``recheck`` the base calls.""" + + def __init__(self, cart_cents: int) -> None: + super().__init__() + self.cart_cents = cart_cents + self.shipping_options: tuple[str, ...] = () + self.payment_options: tuple[str, ...] = () + self.total_cents = 0 + self.submit_enabled = False + self.country = self.add_field("country") + self.shipping = self.add_field("shipping") + self.payment = self.add_field("payment") + self.recheck() + + def recheck(self) -> None: + lanes = SHIPPING_BY_COUNTRY.get(self.country.value, {}) + self.shipping_options = tuple(lanes) + if self.shipping.value not in lanes: + self.shipping.value = "" # country change invalidated the lane + self.payment_options = PAYMENTS_BY_SHIPPING.get(self.shipping.value, ()) + if self.payment.value not in self.payment_options: + self.payment.value = "" + self.total_cents = self.cart_cents + lanes.get(self.shipping.value, 0) + self.submit_enabled = bool( + self.country.value and self.shipping.value and self.payment.value + ) diff --git a/patterns/behavioral/mediator/examples/checkout_form/rules.py b/patterns/behavioral/mediator/examples/checkout_form/rules.py new file mode 100644 index 0000000..b9725f2 --- /dev/null +++ b/patterns/behavioral/mediator/examples/checkout_form/rules.py @@ -0,0 +1,15 @@ +"""The business tables the checkout mediator coordinates over.""" + +from __future__ import annotations + +SHIPPING_BY_COUNTRY: dict[str, dict[str, int]] = { + "CA": {"standard": 900, "express": 2400}, + "US": {"standard": 700, "express": 1900}, + "DE": {"standard": 1100}, # no express lane +} + +#: cash-on-delivery is only offered on express shipments +PAYMENTS_BY_SHIPPING: dict[str, tuple[str, ...]] = { + "standard": ("card",), + "express": ("card", "cod"), +} diff --git a/patterns/behavioral/mediator/naive.py b/patterns/behavioral/mediator/naive.py deleted file mode 100644 index 308f830..0000000 --- a/patterns/behavioral/mediator/naive.py +++ /dev/null @@ -1,52 +0,0 @@ -"""The Gang of Four Mediator: colleagues talk only to the dialog.""" - -from __future__ import annotations - - -class Widget: - def __init__(self, mediator: SignupDialog, name: str) -> None: - self.mediator = mediator - self.name = name - - def changed(self) -> None: - self.mediator.widget_changed(self) - - -class TextField(Widget): - def __init__(self, mediator: SignupDialog, name: str) -> None: - super().__init__(mediator, name) - self.text = "" - - def type_text(self, text: str) -> None: - self.text = text - self.changed() - - -class Button(Widget): - def __init__(self, mediator: SignupDialog, name: str) -> None: - super().__init__(mediator, name) - self.enabled = False - - -class SignupDialog: - """All interaction rules live here; widgets know none of them.""" - - def __init__(self) -> None: - self.username = TextField(self, "username") - self.password = TextField(self, "password") - self.submit = Button(self, "submit") - - def widget_changed(self, _widget: Widget) -> None: - self.submit.enabled = bool(self.username.text) and len(self.password.text) >= 8 - - -def main() -> None: - dialog = SignupDialog() - dialog.username.type_text("ada") - print(f"after username: submit enabled = {dialog.submit.enabled}") - dialog.password.type_text("correcthorse") - print(f"after password: submit enabled = {dialog.submit.enabled}") - - -if __name__ == "__main__": - main() diff --git a/patterns/behavioral/mediator/pattern/__init__.py b/patterns/behavioral/mediator/pattern/__init__.py new file mode 100644 index 0000000..c274ca8 --- /dev/null +++ b/patterns/behavioral/mediator/pattern/__init__.py @@ -0,0 +1,5 @@ +"""The Mediator pattern, importable as library code.""" + +from patterns.behavioral.mediator.pattern.form import Field, Form + +__all__ = ["Field", "Form"] diff --git a/patterns/behavioral/mediator/pattern/form.py b/patterns/behavioral/mediator/pattern/form.py new file mode 100644 index 0000000..ba9cdba --- /dev/null +++ b/patterns/behavioral/mediator/pattern/form.py @@ -0,0 +1,53 @@ +"""Mediator as an importable building block: dumb fields, one rule owner. + +The pattern's Python lesson is a division of labor: widgets hold a value +and report changes; *every* cross-widget rule lives in one mediator method. +``Field`` is the colleague half — a value holder with no rules. ``Form`` is +the mediator half: it creates the fields wired back to itself and owns the +single ``recheck`` hook where all coordination lives. +""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + + +@dataclass +class Field: + """A dumb widget: holds a value, reports changes. No rules, ever.""" + + notify: Callable[[], None] + value: str = "" + + def set(self, value: str) -> None: + self.value = value + self.notify() + + +class Form: + """The mediator base: owns its fields and every cross-field rule. + + Subclasses implement ``recheck`` — the one place rules live. Fields are + created through ``add_field`` so each one notifies this mediator and + none can be wired to two coordinators by accident. + """ + + def __init__(self) -> None: + self._fields: dict[str, Field] = {} + + def add_field(self, name: str) -> Field: + """Create and register a field wired to this mediator's recheck.""" + if name in self._fields: + raise ValueError(f"field {name!r} already registered (pass a fresh name)") + created = Field(self.recheck) + self._fields[name] = created + return created + + def field_names(self) -> list[str]: + """Registration order — the mediator knows its colleagues.""" + return list(self._fields) + + def recheck(self) -> None: + """Re-derive every dependent value; subclasses own the rules.""" + raise NotImplementedError diff --git a/patterns/behavioral/mediator/pythonic.py b/patterns/behavioral/mediator/pythonic.py deleted file mode 100644 index 44f5c26..0000000 --- a/patterns/behavioral/mediator/pythonic.py +++ /dev/null @@ -1,82 +0,0 @@ -"""The mediator without a Colleague hierarchy. - -A checkout form with enough interdependent rules to *justify* a mediator: -country restricts shipping methods, shipping method gates payment options -and recomputes the total, and submit is enabled only when the whole set is -coherent. Widgets know none of it -- every rule lives in one method. -""" - -from __future__ import annotations - -from collections.abc import Callable -from dataclasses import dataclass, field - -SHIPPING_BY_COUNTRY = { - "CA": {"standard": 900, "express": 2400}, - "US": {"standard": 700, "express": 1900}, - "DE": {"standard": 1100}, # no express lane -} -#: cash-on-delivery is only offered on express shipments -PAYMENTS_BY_SHIPPING: dict[str, tuple[str, ...]] = { - "standard": ("card",), - "express": ("card", "cod"), -} - - -@dataclass -class Field: - """A dumb widget: holds a value, reports changes. No rules.""" - - notify: Callable[[], None] - value: str = "" - - def set(self, value: str) -> None: - self.value = value - self.notify() - - -@dataclass -class CheckoutForm: - """The mediator: every cross-field rule, in one readable place.""" - - cart_cents: int - country: Field = field(init=False) - shipping: Field = field(init=False) - payment: Field = field(init=False) - shipping_options: tuple[str, ...] = () - payment_options: tuple[str, ...] = () - total_cents: int = 0 - submit_enabled: bool = False - - def __post_init__(self) -> None: - self.country = Field(self._recheck) - self.shipping = Field(self._recheck) - self.payment = Field(self._recheck) - self._recheck() - - def _recheck(self) -> None: - lanes = SHIPPING_BY_COUNTRY.get(self.country.value, {}) - self.shipping_options = tuple(lanes) - if self.shipping.value not in lanes: - self.shipping.value = "" # country change invalidated the lane - self.payment_options = PAYMENTS_BY_SHIPPING.get(self.shipping.value, ()) - if self.payment.value not in self.payment_options: - self.payment.value = "" - self.total_cents = self.cart_cents + lanes.get(self.shipping.value, 0) - self.submit_enabled = bool( - self.country.value and self.shipping.value and self.payment.value - ) - - -def main() -> None: - form = CheckoutForm(cart_cents=5000) - form.country.set("CA") - form.shipping.set("express") - form.payment.set("cod") - print(f"total {form.total_cents}, submit={form.submit_enabled}") - form.country.set("DE") # express vanishes; dependent fields reset - print(f"after DE: shipping={form.shipping.value!r}, submit={form.submit_enabled}") - - -if __name__ == "__main__": - main() diff --git a/patterns/behavioral/mediator/real_world.py b/patterns/behavioral/mediator/real_world.py deleted file mode 100644 index 96d6fbf..0000000 --- a/patterns/behavioral/mediator/real_world.py +++ /dev/null @@ -1,40 +0,0 @@ -"""``queue.Queue``: the mediator between threads. - -Producer and consumer never reference each other; the queue owns all the -coordination (ordering, blocking, thread safety). -""" - -from __future__ import annotations - -import queue -import threading - - -def pipeline(items: list[str]) -> list[str]: - """Producer and consumer meet only at the queue.""" - channel: queue.Queue[str | None] = queue.Queue() - results: list[str] = [] - - def producer() -> None: - for item in items: - channel.put(item) - channel.put(None) # sentinel: end of stream - - def consumer() -> None: - while (item := channel.get()) is not None: - results.append(item.upper()) - - threads = [threading.Thread(target=producer), threading.Thread(target=consumer)] - for t in threads: - t.start() - for t in threads: - t.join() - return results - - -def main() -> None: - print(pipeline(["a", "b", "c"])) - - -if __name__ == "__main__": - main() diff --git a/patterns/behavioral/mediator/tests/test_checkout_form.py b/patterns/behavioral/mediator/tests/test_checkout_form.py new file mode 100644 index 0000000..a6c8f82 --- /dev/null +++ b/patterns/behavioral/mediator/tests/test_checkout_form.py @@ -0,0 +1,63 @@ +"""Behavioral tests for the checkout-form mini-project.""" + +from __future__ import annotations + +from patterns.behavioral.mediator.examples.checkout_form import CheckoutForm + + +class TestCheckoutForm: + def test_starts_incoherent_and_disabled(self) -> None: + form = CheckoutForm(cart_cents=5000) + assert form.shipping_options == () + assert form.payment_options == () + assert not form.submit_enabled + + def test_country_choice_reveals_its_lanes(self) -> None: + form = CheckoutForm(cart_cents=5000) + form.country.set("CA") + assert list(form.shipping_options) == ["standard", "express"] + form.country.set("DE") + assert list(form.shipping_options) == ["standard"] + + def test_shipping_gates_payment_options(self) -> None: + form = CheckoutForm(cart_cents=5000) + form.country.set("CA") + form.shipping.set("standard") + assert list(form.payment_options) == ["card"] + form.shipping.set("express") + assert list(form.payment_options) == ["card", "cod"] + + def test_total_includes_the_chosen_lane(self) -> None: + form = CheckoutForm(cart_cents=5000) + form.country.set("US") + form.shipping.set("express") + assert form.total_cents == 5000 + 1900 + + def test_full_selection_enables_submit(self) -> None: + form = CheckoutForm(cart_cents=5000) + form.country.set("CA") + form.shipping.set("express") + form.payment.set("cod") + assert form.submit_enabled + + def test_country_change_cascades_and_resets_dependents(self) -> None: + form = CheckoutForm(cart_cents=5000) + form.country.set("CA") + form.shipping.set("express") + form.payment.set("cod") + form.country.set("DE") # DE has no express lane + assert form.shipping.value == "" + assert form.payment.value == "" + assert not form.submit_enabled + assert form.total_cents == 5000 # no lane selected, no shipping cost + + def test_recovery_after_cascade(self) -> None: + form = CheckoutForm(cart_cents=5000) + form.country.set("CA") + form.shipping.set("express") + form.payment.set("cod") + form.country.set("DE") + form.shipping.set("standard") + form.payment.set("card") + assert form.submit_enabled + assert form.total_cents == 5000 + 1100 diff --git a/patterns/behavioral/mediator/tests/test_form.py b/patterns/behavioral/mediator/tests/test_form.py new file mode 100644 index 0000000..59f74ea --- /dev/null +++ b/patterns/behavioral/mediator/tests/test_form.py @@ -0,0 +1,77 @@ +"""Behavioral tests for the Mediator pattern's library code.""" + +from __future__ import annotations + +import pytest + +from patterns.behavioral.mediator.pattern import Field, Form + + +class TestForm: + class _Doubler(Form): + """Minimal mediator: derived state re-computed on every change.""" + + def __init__(self) -> None: + super().__init__() + self.rechecks = 0 + self.left = self.add_field("left") + self.right = self.add_field("right") + self.combined = "" + self.recheck() + + def recheck(self) -> None: + self.rechecks += 1 + self.combined = f"{self.left.value}+{self.right.value}" + + def test_fields_notify_their_mediator(self) -> None: + form = self._Doubler() + form.left.set("a") + form.right.set("b") + assert form.combined == "a+b" + assert form.rechecks == 3 # construction + two sets + + def test_add_field_refuses_duplicate_names(self) -> None: + form = self._Doubler() + with pytest.raises(ValueError, match="already registered"): + form.add_field("left") + + def test_field_names_keep_registration_order(self) -> None: + form = self._Doubler() + assert form.field_names() == ["left", "right"] + + def test_recheck_is_the_subclass_contract(self) -> None: + with pytest.raises(NotImplementedError): + Form().recheck() + + +class TestField: + def test_set_updates_value_then_notifies(self) -> None: + seen: list[str] = [] + field = Field(notify=lambda: seen.append(field.value)) + field.set("hello") + assert field.value == "hello" + assert seen == ["hello"] # notify observed the *new* value + + def test_every_set_notifies(self) -> None: + count = 0 + + def bump() -> None: + nonlocal count + count += 1 + + field = Field(notify=bump) + field.set("a") + field.set("a") # even an unchanged value reports; dedup is the mediator's call + assert count == 2 + + def test_direct_write_does_not_notify(self) -> None: + """Mediators write .value directly to avoid re-entering themselves.""" + count = 0 + + def bump() -> None: + nonlocal count + count += 1 + + field = Field(notify=bump) + field.value = "silent" + assert count == 0 diff --git a/patterns/behavioral/mediator/tests/test_mediator.py b/patterns/behavioral/mediator/tests/test_mediator.py deleted file mode 100644 index 009520f..0000000 --- a/patterns/behavioral/mediator/tests/test_mediator.py +++ /dev/null @@ -1,62 +0,0 @@ -"""Behavioral tests for all three mediator variants.""" - -from patterns.behavioral.mediator import naive, pythonic, real_world - - -class TestNaive: - def test_rules_live_in_the_mediator(self) -> None: - dialog = naive.SignupDialog() - dialog.username.type_text("ada") - assert not dialog.submit.enabled - dialog.password.type_text("correcthorse") - assert dialog.submit.enabled - - def test_weak_password_keeps_submit_disabled(self) -> None: - dialog = naive.SignupDialog() - dialog.username.type_text("ada") - dialog.password.type_text("short") - assert not dialog.submit.enabled - - -class TestPythonic: - def test_happy_path_enables_submit_and_totals(self) -> None: - form = pythonic.CheckoutForm(cart_cents=5000) - form.country.set("CA") - form.shipping.set("express") - form.payment.set("cod") - assert form.submit_enabled - assert form.total_cents == 5000 + 2400 - - def test_country_change_cascades_through_dependent_fields(self) -> None: - form = pythonic.CheckoutForm(cart_cents=5000) - form.country.set("US") - form.shipping.set("express") - form.payment.set("cod") - form.country.set("DE") # DE has no express -> shipping and payment reset - assert form.shipping.value == "" and form.payment.value == "" - assert not form.submit_enabled - assert form.shipping_options == ("standard",) - - def test_payment_options_follow_shipping_method(self) -> None: - form = pythonic.CheckoutForm(cart_cents=1000) - form.country.set("CA") - form.shipping.set("standard") - standard_options: tuple[str, ...] = form.payment_options - assert standard_options == ("card",) - form.shipping.set("express") - express_options: tuple[str, ...] = form.payment_options - assert express_options == ("card", "cod") - - def test_widgets_hold_no_rules(self) -> None: - pings: list[str] = [] - widget = pythonic.Field(notify=lambda: pings.append("changed")) - widget.set("anything") - assert pings == ["changed"] # reusable with any coordinator - - -class TestRealWorld: - def test_queue_mediates_producer_and_consumer(self) -> None: - assert real_world.pipeline(["a", "b", "c"]) == ["A", "B", "C"] - - def test_empty_stream(self) -> None: - assert real_world.pipeline([]) == [] diff --git a/patterns/behavioral/memento/README.md b/patterns/behavioral/memento/README.md index 423cf61..92d79f9 100644 --- a/patterns/behavioral/memento/README.md +++ b/patterns/behavioral/memento/README.md @@ -9,36 +9,23 @@ verdict: use-with-care caveats: - "Immutable state makes the pattern nearly free: a snapshot is just keeping the old object. Design the state to be frozen and mementos fall out." - "pickle.loads executes code while deserializing — only unpickle snapshots your own process produced; use JSON for anything crossing a trust boundary." - - "Deep-copying big mutable graphs per keystroke is the naive cost; snapshot the smallest state that matters." + - "Deep-copying big mutable graphs per keystroke is the obvious-first-attempt cost; snapshot the smallest state that matters." stdlib_sightings: [copy.deepcopy, pickle.dumps, dataclasses.replace] --- # Memento -## Problem +Keep "how it was" so you can go back — undo, checkpoints, rollback — without +letting the keeper read what it keeps. **Verdict: use with care** — freeze the +state and the pattern is nearly free. -An editor needs undo; a migration needs rollback. Something outside the -object must hold "how it was" without being allowed to poke around inside. +| Where | What | +|---|---| +| [`pattern/`](pattern/) | The importable code: `History`, `NoSnapshotError` | +| [`docs/`](docs/) | [Fundamentals](docs/fundamentals.md) · [Implementation guide](docs/implementation.md) · [External examples](docs/examples.md) | +| [`examples/config_checkpoints/`](examples/config_checkpoints/) | Mini-project: validate-or-rollback config editing built on `pattern/` | +| [`tests/`](tests/) | Behavioral tests for the pattern and the mini-project | -## Naive solution - -`naive.py` is the GoF trio: Originator produces opaque mementos, a -Caretaker stacks them, restore hands one back. The memento's fields are -private by convention — Python has no way to truly seal them. - -## Pythonic solution - -Make the state an immutable dataclass and the whole pattern collapses: -a snapshot *is* the current state object, history is a list of them, undo is -popping. `dataclasses.replace` produces each next state. - -## In the wild - -`pickle.dumps` is a memento serializer: the bytes are an opaque snapshot -restorable with `loads`, even in another process. `copy.deepcopy` is the -in-memory equivalent for mutable state you can't freeze. - -## Verdict - -**Use with care** — and tilt the design toward immutable state, where the -pattern costs nothing. +```bash +uv run python -m patterns.behavioral.memento.examples.config_checkpoints +``` diff --git a/patterns/behavioral/memento/__init__.py b/patterns/behavioral/memento/__init__.py index b957db8..cc92040 100644 --- a/patterns/behavioral/memento/__init__.py +++ b/patterns/behavioral/memento/__init__.py @@ -1 +1,8 @@ -"""Memento: capture state for later restore.""" +"""Memento — public API. + +>>> from patterns.behavioral.memento import History +""" + +from patterns.behavioral.memento.pattern import History, NoSnapshotError + +__all__ = ["History", "NoSnapshotError"] diff --git a/patterns/behavioral/memento/docs/examples.md b/patterns/behavioral/memento/docs/examples.md new file mode 100644 index 0000000..2f88396 --- /dev/null +++ b/patterns/behavioral/memento/docs/examples.md @@ -0,0 +1,41 @@ +# Memento — where it lives outside this repo + +Cited, real implementations to study (or point an agent at) when designing or +reviewing snapshot/rollback code. + +## Python standard library + +- **`dataclasses.replace` + frozen dataclasses.** Immutability makes + snapshots free: `replace` builds the next state, the previous object *is* + the memento. The foundation this module's `History` assumes. + [docs.python.org/3/library/dataclasses.html#dataclasses.replace](https://docs.python.org/3/library/dataclasses.html#dataclasses.replace) +- **`pickle` / `copy.deepcopy`.** `pickle.dumps` produces an opaque snapshot + restorable with `loads`, even in another process; `deepcopy` is the + in-memory equivalent for state you can't freeze. **Security (CWE-502):** + `pickle.loads` executes code while deserializing — only unpickle snapshots + your own process produced and stored where untrusted input cannot reach; + use JSON for anything crossing a trust boundary. + [docs.python.org/3/library/pickle.html#module-pickle](https://docs.python.org/3/library/pickle.html#module-pickle) + +## Databases + +- **SQLAlchemy `Session.begin_nested()`** — a SAVEPOINT as a memento: + checkpoint mid-transaction, roll back to it on failure while the outer + transaction survives. The validate-or-rollback flow of the mini-project, + at database scale. + [docs.sqlalchemy.org/en/latest/orm/session_transaction.html#using-savepoint](https://docs.sqlalchemy.org/en/latest/orm/session_transaction.html#using-savepoint) +- **SQLite `SAVEPOINT`** — the same idea in the database the stdlib ships. + [sqlite.org/lang_savepoint.html](https://sqlite.org/lang_savepoint.html) + +## Everyday tools + +- **Editor undo persistence** — Vim's undo files (`:help undo-persistence`) + are mementos written to disk: state snapshots that outlive the process. + *(unverified)* + +## What to notice across all of them + +Each one keeps the caretaker ignorant: the SAVEPOINT name, the pickle bytes, +the undo file are all opaque handles. The moment restoring requires +*interpreting* the snapshot, you are maintaining two copies of the object's +logic — the pattern's encapsulation promise is the part worth defending. diff --git a/patterns/behavioral/memento/docs/fundamentals.md b/patterns/behavioral/memento/docs/fundamentals.md new file mode 100644 index 0000000..9a59dc1 --- /dev/null +++ b/patterns/behavioral/memento/docs/fundamentals.md @@ -0,0 +1,79 @@ +# Memento — fundamentals + +## Intent + +Capture an object's internal state so it can be restored later, without +violating encapsulation: whoever stores the snapshot must not be able to read +or edit it. + +## Participants + +| Role | Classic (GoF) form | Python form | +|---|---|---| +| Originator | Creates mementos from its state, restores from them | The object whose state is a frozen value (dataclass) | +| Memento | An opaque snapshot class, fields private by convention | The old state object itself — immutability *makes* it opaque-safe | +| Caretaker | Stores mementos, never inspects them | `History` in [`pattern/history.py`](../pattern/history.py) — generic, so it *cannot* peek | + +## Mechanism + +1. The originator's mutable identity holds an **immutable state value**. +2. Before a change, the current state object is handed to the caretaker + (`history.save(state)` or `history.checkpoint("name", state)`). +3. A change builds a *new* state value (`dataclasses.replace`) — the old one + is untouched, which is why saving it cost nothing. +4. Undo/rollback is the caretaker handing a snapshot back and the originator + adopting it wholesale. + +## The classic form, and what Python absorbs + +The textbook version writes three classes — the snapshot is its own class, +opaque only by underscore convention: + +```python +class Memento: + """Opaque by convention: only the originator reads its fields.""" + + def __init__(self, text: str, cursor: int) -> None: + self._text = text # nothing stops a caretaker from peeking + self._cursor = cursor + + +class Editor: # the originator + def save(self) -> Memento: + return Memento(self.text, self.cursor) + + def restore(self, memento: Memento) -> None: + self.text = memento._text # privileged access, unenforced + self.cursor = memento._cursor + + +class History: # the caretaker + def push(self, memento: Memento) -> None: ... + def pop(self) -> Memento: ... +``` + +Python has no way to truly seal `Memento`'s fields — the design's central +promise is unenforceable here. Freezing the state solves it from the other +side: when state is a frozen dataclass, **the snapshot is the old state +object**. No copy, no dedicated Memento class, and the caretaker can hold it +safely because nobody can mutate it. What survives of the pattern is the +caretaker discipline: history stores snapshots *it never interprets*. + +## When to use it + +- Undo/redo, checkpoint-and-rollback, save slots — any "return to how it was". +- Speculative edits: try a batch, validate, restore on failure. + +## When not to use it + +- State is huge and mutable and cannot be frozen — deep-copying per edit is + the cost the caveats warn about; snapshot the smallest state that matters. +- The "restore" is really replaying inputs → that is Command with an undo + log, not a snapshot. +- Snapshots must cross a process or trust boundary → that is serialization, + and the pickle warning in [examples](examples.md) applies. + +## Verdict: use with care + +Tilt the design toward immutable state, where the pattern costs nothing — +`History` plus a frozen dataclass is the whole implementation. diff --git a/patterns/behavioral/memento/docs/implementation.md b/patterns/behavioral/memento/docs/implementation.md new file mode 100644 index 0000000..a44fa2b --- /dev/null +++ b/patterns/behavioral/memento/docs/implementation.md @@ -0,0 +1,86 @@ +# Memento — putting it into a system + +## The smell it fixes + +Ad-hoc "remember the old values" code smeared through an object: + +```python +def risky_update(self, changes): + old_workers = self.workers # hand-rolled, per-field, + old_timeout = self.timeout # and always one field short + try: + ... + except Exception: + self.workers = old_workers # partial restore, subtle drift + self.timeout = old_timeout +``` + +Every new field must remember to join the backup ritual. A memento replaces +the ritual with one move: keep the whole old state. + +## Steps + +1. **Freeze the state.** Move the object's data into a `@dataclass(frozen=True)`. + The identity (the editor, the service) stays mutable; its *state* doesn't. +2. **Give the originator a `History`.** `History[YourState]()` — the type + parameter is the whole caretaker contract: it stores and returns, nothing else. +3. **Snapshot before commit.** Each mutation builds a candidate with + `dataclasses.replace`, validates it, then `history.save(self.state)` and + adopt the candidate. Order matters: save only what was *valid and live*. +4. **Choose your restore vocabulary.** LIFO `undo()` for editing flows; named + `checkpoint(name)` / `rollback_to(name)` for operational flows + ("before-upgrade"). Decide whether a rollback is itself undoable. +5. **Bound the history** if edits are unbounded — a deque with `maxlen`, or + checkpoint-only retention. Unbounded undo is a slow memory leak. + +```python +from patterns.behavioral.memento import History + + +class ConfigEditor: + def __init__(self) -> None: + self.config = ServiceConfig() + self._history: History[ServiceConfig] = History() + + def apply(self, changes: Mapping[str, Any]) -> ServiceConfig: + candidate = replace(self.config, **changes) + validate(candidate) # reject BEFORE touching history + self._history.save(self.config) + self.config = candidate + return self.config +``` + +## Python idioms that keep it small + +- `dataclasses.replace` is the snapshot-friendly mutation: it forces + "new value, old value intact" as the default motion. +- Frozen dataclasses with `frozenset`/`tuple` fields keep immutability + *deep* — a frozen shell over a mutable `list` is a snapshot that lies. +- For state you genuinely cannot freeze, `copy.deepcopy` at the snapshot + point is the honest fallback; pay the cost visibly, at one call site. + +## Pitfalls + +- **The shallow snapshot.** Freezing the top object while a field is a + mutable list shares that list across "snapshots" — undo silently undoes + nothing. Freeze all the way down. +- **Saving the invalid candidate.** Snapshot the last *good* state, then + validate the candidate — the demo's rejected batch leaves both the live + config and the history untouched. +- **A caretaker that peeks.** The moment history code reads snapshot fields, + restore semantics couple to state internals. `History` is generic + precisely so it can't. +- **Unpickling as restore.** Restoring from bytes means `pickle.loads`, and + that executes code during deserialization (CWE-502): only unpickle + snapshots your own process produced; use JSON for anything that crosses a + trust boundary. + +## Worked example + +[`examples/config_checkpoints/`](../examples/config_checkpoints/) applies +every step: atomic validate-or-reject batches, LIFO undo, and a named +"before-upgrade" checkpoint. Run it with: + +```bash +uv run python -m patterns.behavioral.memento.examples.config_checkpoints +``` diff --git a/patterns/behavioral/memento/examples/__init__.py b/patterns/behavioral/memento/examples/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/patterns/behavioral/memento/examples/config_checkpoints/__init__.py b/patterns/behavioral/memento/examples/config_checkpoints/__init__.py new file mode 100644 index 0000000..5e8bcac --- /dev/null +++ b/patterns/behavioral/memento/examples/config_checkpoints/__init__.py @@ -0,0 +1,13 @@ +"""Config editing with validate-or-rollback, built on the Memento pattern. + +Run it: ``uv run python -m patterns.behavioral.memento.examples.config_checkpoints`` +""" + +from patterns.behavioral.memento.examples.config_checkpoints.editor import ConfigEditor +from patterns.behavioral.memento.examples.config_checkpoints.models import ( + InvalidConfigError, + ServiceConfig, + validate, +) + +__all__ = ["ConfigEditor", "InvalidConfigError", "ServiceConfig", "validate"] diff --git a/patterns/behavioral/memento/examples/config_checkpoints/__main__.py b/patterns/behavioral/memento/examples/config_checkpoints/__main__.py new file mode 100644 index 0000000..a57962d --- /dev/null +++ b/patterns/behavioral/memento/examples/config_checkpoints/__main__.py @@ -0,0 +1,29 @@ +"""Demo: an upgrade day saved by checkpoints.""" + +from __future__ import annotations + +from patterns.behavioral.memento.examples.config_checkpoints.editor import ConfigEditor +from patterns.behavioral.memento.examples.config_checkpoints.models import InvalidConfigError + + +def main() -> None: + editor = ConfigEditor() + editor.apply({"workers": 8, "log_level": "WARNING"}) + editor.checkpoint("before-upgrade") + print(f"checkpointed: {editor.config}") + + try: + editor.apply({"workers": 0, "timeout_s": -1.0}) + except InvalidConfigError as err: + print(f"batch rejected: {err}") + print(f"still intact: {editor.config}") + + editor.apply({"feature_flags": frozenset({"new-renderer"})}) + print(f"upgraded: {editor.config}") + + editor.rollback_to("before-upgrade") + print(f"rolled back: {editor.config}") + + +if __name__ == "__main__": + main() diff --git a/patterns/behavioral/memento/examples/config_checkpoints/editor.py b/patterns/behavioral/memento/examples/config_checkpoints/editor.py new file mode 100644 index 0000000..b347b86 --- /dev/null +++ b/patterns/behavioral/memento/examples/config_checkpoints/editor.py @@ -0,0 +1,55 @@ +"""The originator: a config editor with validate-or-rollback and checkpoints. + +Because ``ServiceConfig`` is frozen, a snapshot is just the current object — +the editor hands it to ``History`` (the caretaker), which stores it without +ever reading a field. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import replace +from typing import Any + +from patterns.behavioral.memento.examples.config_checkpoints.models import ( + ServiceConfig, + validate, +) +from patterns.behavioral.memento.pattern import History + + +class ConfigEditor: + """Edits a ``ServiceConfig``; every committed edit is undoable.""" + + def __init__(self, config: ServiceConfig | None = None) -> None: + self.config = config if config is not None else ServiceConfig() + self._history: History[ServiceConfig] = History() + + def apply(self, changes: Mapping[str, Any]) -> ServiceConfig: + """Apply a batch atomically: validate the result, commit or reject. + + On success the pre-batch snapshot goes onto the undo stack. On + failure ``InvalidConfigError`` propagates and the live config is + untouched — the caller never sees a half-applied batch. + """ + candidate = replace(self.config, **changes) + validate(candidate) + self._history.save(self.config) + self.config = candidate + return self.config + + def undo(self) -> ServiceConfig: + """Restore the config as it was before the last committed batch.""" + self.config = self._history.undo() + return self.config + + def checkpoint(self, name: str) -> None: + """Name the current config so it can be restored much later.""" + self._history.checkpoint(name, self.config) + + def rollback_to(self, name: str) -> ServiceConfig: + """Jump back to a named checkpoint (the jump itself is undoable).""" + restored = self._history.rollback_to(name) + self._history.save(self.config) + self.config = restored + return self.config diff --git a/patterns/behavioral/memento/examples/config_checkpoints/models.py b/patterns/behavioral/memento/examples/config_checkpoints/models.py new file mode 100644 index 0000000..cca2c01 --- /dev/null +++ b/patterns/behavioral/memento/examples/config_checkpoints/models.py @@ -0,0 +1,34 @@ +"""Domain types for the config-checkpoints mini-project.""" + +from __future__ import annotations + +from dataclasses import dataclass + +LOG_LEVELS = frozenset({"DEBUG", "INFO", "WARNING", "ERROR"}) + + +class InvalidConfigError(ValueError): + """The proposed configuration violates at least one rule.""" + + +@dataclass(frozen=True) +class ServiceConfig: + """A service's settings. Frozen: every edit produces a new snapshot.""" + + workers: int = 2 + timeout_s: float = 30.0 + log_level: str = "INFO" + feature_flags: frozenset[str] = frozenset() + + +def validate(config: ServiceConfig) -> None: + """Raise ``InvalidConfigError`` naming every rule the config breaks.""" + problems: list[str] = [] + if config.workers < 1: + problems.append(f"workers must be >= 1, got {config.workers}") + if config.timeout_s <= 0: + problems.append(f"timeout_s must be positive, got {config.timeout_s}") + if config.log_level not in LOG_LEVELS: + problems.append(f"log_level must be one of {sorted(LOG_LEVELS)}, got {config.log_level!r}") + if problems: + raise InvalidConfigError("; ".join(problems)) diff --git a/patterns/behavioral/memento/naive.py b/patterns/behavioral/memento/naive.py deleted file mode 100644 index 5a7c1a6..0000000 --- a/patterns/behavioral/memento/naive.py +++ /dev/null @@ -1,57 +0,0 @@ -"""The Gang of Four Memento: originator, opaque memento, caretaker.""" - -from __future__ import annotations - - -class Memento: - """Opaque by convention: only the originator reads its fields.""" - - def __init__(self, text: str, cursor: int) -> None: - self._text = text - self._cursor = cursor - - -class Editor: - """The originator.""" - - def __init__(self) -> None: - self.text = "" - self.cursor = 0 - - def type_text(self, text: str) -> None: - self.text += text - self.cursor = len(self.text) - - def save(self) -> Memento: - return Memento(self.text, self.cursor) - - def restore(self, memento: Memento) -> None: - self.text = memento._text - self.cursor = memento._cursor - - -class History: - """The caretaker: stores mementos, never looks inside.""" - - def __init__(self) -> None: - self._stack: list[Memento] = [] - - def push(self, memento: Memento) -> None: - self._stack.append(memento) - - def pop(self) -> Memento: - return self._stack.pop() - - -def main() -> None: - editor, history = Editor(), History() - editor.type_text("hello") - history.push(editor.save()) - editor.type_text(" world") - print(f"before undo: {editor.text!r}") - editor.restore(history.pop()) - print(f"after undo: {editor.text!r}") - - -if __name__ == "__main__": - main() diff --git a/patterns/behavioral/memento/pattern/__init__.py b/patterns/behavioral/memento/pattern/__init__.py new file mode 100644 index 0000000..594ca1d --- /dev/null +++ b/patterns/behavioral/memento/pattern/__init__.py @@ -0,0 +1,5 @@ +"""The Memento pattern, importable as library code.""" + +from patterns.behavioral.memento.pattern.history import History, NoSnapshotError + +__all__ = ["History", "NoSnapshotError"] diff --git a/patterns/behavioral/memento/pattern/history.py b/patterns/behavioral/memento/pattern/history.py new file mode 100644 index 0000000..965f743 --- /dev/null +++ b/patterns/behavioral/memento/pattern/history.py @@ -0,0 +1,62 @@ +"""Memento as an importable, typed building block. + +The originator's state is any value — ideally immutable, so a snapshot *is* +the old state object. ``History`` is the caretaker: it stores snapshots and +hands them back, but never looks inside. Undo is LIFO; named checkpoints +("before-upgrade") are random-access. +""" + +from __future__ import annotations + +from typing import Generic, TypeVar + +Snapshot = TypeVar("Snapshot") + + +class NoSnapshotError(LookupError): + """The history has nothing to restore.""" + + +class History(Generic[Snapshot]): + """A caretaker for opaque snapshots: an undo stack plus named checkpoints.""" + + def __init__(self) -> None: + self._stack: list[Snapshot] = [] + self._checkpoints: dict[str, Snapshot] = {} + + def save(self, snapshot: Snapshot) -> Snapshot: + """Push a snapshot onto the undo stack and return it unchanged.""" + self._stack.append(snapshot) + return snapshot + + def undo(self) -> Snapshot: + """Pop and return the most recent snapshot; raise if there is none.""" + if not self._stack: + raise NoSnapshotError("history is empty") + return self._stack.pop() + + def checkpoint(self, name: str, snapshot: Snapshot, *, replace: bool = False) -> Snapshot: + """Store a snapshot under a name; the name must be free. + + A duplicate name is an error unless ``replace=True`` — a rollback API + that silently swaps what "before-migration" points at is untrustworthy + exactly where it must not be. + """ + if name in self._checkpoints and not replace: + raise ValueError(f"checkpoint {name!r} already exists (pass replace=True)") + self._checkpoints[name] = snapshot + return snapshot + + def rollback_to(self, name: str) -> Snapshot: + """Return the named checkpoint; raise if the name is unknown.""" + try: + return self._checkpoints[name] + except KeyError: + known = sorted(self._checkpoints) or "none" + raise NoSnapshotError(f"no checkpoint {name!r} (known: {known})") from None + + def __len__(self) -> int: + return len(self._stack) + + def __bool__(self) -> bool: + return bool(self._stack) or bool(self._checkpoints) diff --git a/patterns/behavioral/memento/pythonic.py b/patterns/behavioral/memento/pythonic.py deleted file mode 100644 index 30fd51f..0000000 --- a/patterns/behavioral/memento/pythonic.py +++ /dev/null @@ -1,43 +0,0 @@ -"""Immutable state makes mementos free. - -The state is a frozen dataclass; a snapshot IS the state object, history is -a list of them, and undo is pop. No Memento class, no copying. -""" - -from __future__ import annotations - -from dataclasses import dataclass, replace - - -@dataclass(frozen=True) -class EditorState: - text: str = "" - cursor: int = 0 - - -class Editor: - def __init__(self) -> None: - self.state = EditorState() - self._history: list[EditorState] = [] - - def type_text(self, text: str) -> None: - self._history.append(self.state) # the old state object is the memento - new_text = self.state.text + text - self.state = replace(self.state, text=new_text, cursor=len(new_text)) - - def undo(self) -> None: - if self._history: - self.state = self._history.pop() - - -def main() -> None: - editor = Editor() - editor.type_text("hello") - editor.type_text(" world") - print(f"before undo: {editor.state.text!r}") - editor.undo() - print(f"after undo: {editor.state.text!r}") - - -if __name__ == "__main__": - main() diff --git a/patterns/behavioral/memento/real_world.py b/patterns/behavioral/memento/real_world.py deleted file mode 100644 index 740780a..0000000 --- a/patterns/behavioral/memento/real_world.py +++ /dev/null @@ -1,45 +0,0 @@ -"""``pickle``: mementos that survive the process. - -dumps() produces an opaque snapshot; loads() restores an equivalent object --- checkpoint/rollback for anything picklable. - -SECURITY: ``pickle.loads`` executes code during deserialization. Only ever -unpickle snapshots your own process produced and stored somewhere untrusted -input cannot reach (CWE-502). For snapshots that cross a trust boundary, -serialize explicit state as JSON instead. -""" - -from __future__ import annotations - -import pickle -from dataclasses import dataclass, field - - -@dataclass -class Game: - level: int = 1 - inventory: list[str] = field(default_factory=list) - - -def checkpoint(game: Game) -> bytes: - return pickle.dumps(game) - - -def rollback(snapshot: bytes) -> Game: - # Safe ONLY because `snapshot` came from checkpoint() in this process. - restored = pickle.loads(snapshot) - assert isinstance(restored, Game) - return restored - - -def main() -> None: - game = Game() - game.inventory.append("sword") - save = checkpoint(game) - game.level, game.inventory = 9, [] - print(f"after disaster: {game}") - print(f"rolled back: {rollback(save)}") - - -if __name__ == "__main__": - main() diff --git a/patterns/behavioral/memento/tests/test_config_checkpoints.py b/patterns/behavioral/memento/tests/test_config_checkpoints.py new file mode 100644 index 0000000..6fcc6f3 --- /dev/null +++ b/patterns/behavioral/memento/tests/test_config_checkpoints.py @@ -0,0 +1,80 @@ +"""Behavioral tests for the config-checkpoints mini-project.""" + +from __future__ import annotations + +import pytest + +from patterns.behavioral.memento.examples.config_checkpoints import ( + ConfigEditor, + InvalidConfigError, + ServiceConfig, +) +from patterns.behavioral.memento.examples.config_checkpoints.__main__ import main + + +class TestValidateOrRollback: + def test_a_valid_batch_commits_atomically(self) -> None: + editor = ConfigEditor() + editor.apply({"workers": 8, "log_level": "ERROR"}) + assert editor.config.workers == 8 + assert editor.config.log_level == "ERROR" + + def test_an_invalid_batch_is_rejected_whole(self) -> None: + editor = ConfigEditor() + before = editor.config + with pytest.raises(InvalidConfigError, match="workers"): + editor.apply({"workers": 0, "log_level": "ERROR"}) + assert editor.config is before # not even the valid half applied + + def test_a_rejected_batch_does_not_pollute_undo(self) -> None: + editor = ConfigEditor() + editor.apply({"workers": 4}) + with pytest.raises(InvalidConfigError): + editor.apply({"timeout_s": -1.0}) + assert editor.undo() == ServiceConfig() # straight back to the start + + def test_error_message_names_every_broken_rule(self) -> None: + editor = ConfigEditor() + with pytest.raises(InvalidConfigError, match=r"workers.*timeout_s"): + editor.apply({"workers": -1, "timeout_s": 0.0}) + + def test_an_unknown_log_level_is_rejected(self) -> None: + editor = ConfigEditor() + with pytest.raises(InvalidConfigError, match="log_level"): + editor.apply({"log_level": "LOUD"}) + + +class TestUndoAndCheckpoints: + def test_undo_steps_back_one_committed_batch(self) -> None: + editor = ConfigEditor() + editor.apply({"workers": 4}) + editor.apply({"workers": 16}) + assert editor.undo().workers == 4 + assert editor.undo().workers == 2 + + def test_rollback_to_a_named_checkpoint_after_later_edits(self) -> None: + editor = ConfigEditor() + editor.apply({"log_level": "WARNING"}) + editor.checkpoint("before-upgrade") + editor.apply({"feature_flags": frozenset({"risky"})}) + restored = editor.rollback_to("before-upgrade") + assert restored.log_level == "WARNING" + assert restored.feature_flags == frozenset() + + def test_a_rollback_is_itself_undoable(self) -> None: + editor = ConfigEditor() + editor.checkpoint("start") + editor.apply({"workers": 9}) + editor.rollback_to("start") + assert editor.undo().workers == 9 + + +class TestDemo: + def test_main_shows_reject_upgrade_and_rollback( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + main() + out = capsys.readouterr().out + assert "batch rejected" in out + assert "new-renderer" in out + assert "rolled back" in out diff --git a/patterns/behavioral/memento/tests/test_history.py b/patterns/behavioral/memento/tests/test_history.py new file mode 100644 index 0000000..b21a3cf --- /dev/null +++ b/patterns/behavioral/memento/tests/test_history.py @@ -0,0 +1,76 @@ +"""Behavioral tests for the Memento pattern's History caretaker.""" + +from __future__ import annotations + +import pytest + +from patterns.behavioral.memento import History, NoSnapshotError + + +class TestUndoStack: + def test_undo_returns_snapshots_last_in_first_out(self) -> None: + history: History[str] = History() + history.save("first") + history.save("second") + assert history.undo() == "second" + assert history.undo() == "first" + + def test_undo_on_empty_history_raises(self) -> None: + history: History[str] = History() + with pytest.raises(NoSnapshotError): + history.undo() + + def test_save_returns_the_snapshot_unchanged(self) -> None: + history: History[tuple[int, ...]] = History() + snapshot = (1, 2, 3) + assert history.save(snapshot) is snapshot + + def test_len_counts_only_the_undo_stack(self) -> None: + history: History[int] = History() + history.save(1) + history.checkpoint("named", 2) + assert len(history) == 1 + + +class TestCheckpoints: + def test_rollback_to_returns_the_named_snapshot(self) -> None: + history: History[int] = History() + history.checkpoint("before-upgrade", 41) + history.save(42) + assert history.rollback_to("before-upgrade") == 41 + + def test_unknown_checkpoint_raises_and_names_the_known_ones(self) -> None: + history: History[int] = History() + history.checkpoint("alpha", 1) + with pytest.raises(NoSnapshotError, match="alpha"): + history.rollback_to("beta") + + def test_duplicate_checkpoint_name_is_refused(self) -> None: + history: History[int] = History() + history.checkpoint("mark", 1) + with pytest.raises(ValueError, match="already exists"): + history.checkpoint("mark", 2) + assert history.rollback_to("mark") == 1 # the original survives + + def test_replace_overwrites_intentionally(self) -> None: + history: History[int] = History() + history.checkpoint("mark", 1) + history.checkpoint("mark", 2, replace=True) + assert history.rollback_to("mark") == 2 + + def test_bool_reflects_any_stored_snapshot(self) -> None: + history: History[int] = History() + assert not history + history.checkpoint("only-named", 1) + assert history + + def test_bool_is_true_for_a_stack_only_history(self) -> None: + history: History[int] = History() + history.save(1) + assert history # the undo-stack half of __bool__ on its own + + def test_rollback_to_is_non_destructive(self) -> None: + history: History[int] = History() + history.checkpoint("mark", 7) + assert history.rollback_to("mark") == 7 + assert history.rollback_to("mark") == 7 # a checkpoint is reusable diff --git a/patterns/behavioral/memento/tests/test_memento.py b/patterns/behavioral/memento/tests/test_memento.py deleted file mode 100644 index 99a7406..0000000 --- a/patterns/behavioral/memento/tests/test_memento.py +++ /dev/null @@ -1,53 +0,0 @@ -"""Behavioral tests for all three memento variants.""" - -from patterns.behavioral.memento import naive, pythonic, real_world - - -class TestNaive: - def test_save_and_restore(self) -> None: - editor, history = naive.Editor(), naive.History() - editor.type_text("hello") - history.push(editor.save()) - editor.type_text(" world") - editor.restore(history.pop()) - assert (editor.text, editor.cursor) == ("hello", 5) - - -class TestPythonic: - def test_undo_restores_previous_state(self) -> None: - editor = pythonic.Editor() - editor.type_text("hello") - editor.type_text(" world") - editor.undo() - assert editor.state == pythonic.EditorState("hello", 5) - - def test_undo_to_the_beginning_then_noop(self) -> None: - editor = pythonic.Editor() - editor.type_text("x") - editor.undo() - editor.undo() # empty history: must not raise - assert editor.state == pythonic.EditorState() - - def test_snapshots_are_immutable(self) -> None: - import dataclasses - - import pytest - - with pytest.raises(dataclasses.FrozenInstanceError): - pythonic.EditorState().text = "nope" # type: ignore[misc] - - -class TestRealWorld: - def test_pickle_round_trip_restores_state(self) -> None: - game = real_world.Game() - game.inventory.append("sword") - save = real_world.checkpoint(game) - game.level, game.inventory = 9, [] - restored = real_world.rollback(save) - assert (restored.level, restored.inventory) == (1, ["sword"]) - - def test_snapshot_is_independent_of_later_mutation(self) -> None: - game = real_world.Game(inventory=["map"]) - save = real_world.checkpoint(game) - game.inventory.clear() - assert real_world.rollback(save).inventory == ["map"] diff --git a/patterns/behavioral/observer/README.md b/patterns/behavioral/observer/README.md index 90e3e26..f21d718 100644 --- a/patterns/behavioral/observer/README.md +++ b/patterns/behavioral/observer/README.md @@ -14,30 +14,17 @@ stdlib_sightings: [concurrent.futures.Future.add_done_callback, asyncio.Future] # Observer -## Problem - -A model changes and three views must repaint; a download finishes and -logging, metrics, and the UI all care. The subject must broadcast without -compiling a list of friends into itself. - -## Naive solution - -`naive.py` is the GoF form: Subject with attach/detach/notify, an Observer -ABC, concrete observers implementing `update()`. - -## Pythonic solution - -Observers are callables in a list; subscribing is appending. `pythonic.py` -also shows the property-setter variant — assignment to `.temperature` -triggers the callbacks — which is how observation usually hides inside -Python APIs. - -## In the wild - -`concurrent.futures.Future.add_done_callback` is the stdlib observer: -register any callable, it fires when the future resolves — even if it -already has. - -## Verdict - -**Pythonic.** Lists of callables, everywhere, deliberately. +Broadcast a change to whoever subscribed, in order, without the subject +knowing its audience. **Verdict: pythonic** — observers are callables in a +list; the only real design decisions are order and failure policy. + +| Where | What | +|---|---| +| [`pattern/`](pattern/) | The importable code: `Signal`, `Subscriber`, `ErrorPolicy` | +| [`docs/`](docs/) | [Fundamentals](docs/fundamentals.md) · [Implementation guide](docs/implementation.md) · [External examples](docs/examples.md) | +| [`examples/order_events/`](examples/order_events/) | Mini-project: order pipeline with independent subscribers built on `pattern/` | +| [`tests/`](tests/) | Behavioral tests for the pattern and the mini-project | + +```bash +uv run python -m patterns.behavioral.observer.examples.order_events +``` diff --git a/patterns/behavioral/observer/__init__.py b/patterns/behavioral/observer/__init__.py index 942e3df..770e6dc 100644 --- a/patterns/behavioral/observer/__init__.py +++ b/patterns/behavioral/observer/__init__.py @@ -1 +1,8 @@ -"""Observer: broadcast changes to subscribed callables.""" +"""Observer — public API. + +>>> from patterns.behavioral.observer import Signal +""" + +from patterns.behavioral.observer.pattern import ErrorPolicy, Signal, Subscriber + +__all__ = ["ErrorPolicy", "Signal", "Subscriber"] diff --git a/patterns/behavioral/observer/docs/examples.md b/patterns/behavioral/observer/docs/examples.md new file mode 100644 index 0000000..5a9cecb --- /dev/null +++ b/patterns/behavioral/observer/docs/examples.md @@ -0,0 +1,44 @@ +# Observer — where it lives outside this repo + +Cited, real implementations to study (or point an agent at) when designing or +reviewing event/notification code. + +## Python standard library + +- **`concurrent.futures.Future.add_done_callback`.** Register any callable + on a future; it fires on completion — and fires *immediately* if the + future already resolved, a late-subscriber decision worth copying. + [docs.python.org/3/library/concurrent.futures.html#concurrent.futures.Future.add_done_callback](https://docs.python.org/3/library/concurrent.futures.html#concurrent.futures.Future.add_done_callback) +- **`asyncio` callbacks.** The event loop's core currency: + `loop.call_soon`, future/task done-callbacks — completion observers + driving the whole async machine. + [docs.python.org/3/library/asyncio-eventloop.html](https://docs.python.org/3/library/asyncio-eventloop.html) · + [docs.python.org/3/library/asyncio-future.html](https://docs.python.org/3/library/asyncio-future.html) + +## Major ecosystems + +- **Django signals.** `post_save`, `request_finished`, custom signals — the + canonical Python pub/sub, with `@receiver` as decorator subscription. Its + docs' warning that signals make flow "harder to follow" is the pattern's + main cost, stated by its biggest user. + [docs.djangoproject.com/en/stable/topics/signals/](https://docs.djangoproject.com/en/stable/topics/signals/) +- **blinker** — the standalone signals library Flask builds on; named + signals, weak references to subscribers (an answer to the lapsed-listener + leak). [blinker.readthedocs.io](https://blinker.readthedocs.io/) *(unverified)* +- **traitlets** — observable attributes (`observe`/`@observe`) powering + Jupyter's configuration system; the property-setter idiom grown into a + framework. [traitlets.readthedocs.io](https://traitlets.readthedocs.io/) *(unverified)* + +## Outside Python, for contrast + +- **DOM `addEventListener`** — the same shape every web developer already + knows: subscribe callables to a subject's named events; `removeEventListener` + is the lapsed-listener chore made visible. + +## What to notice across all of them + +Each one had to answer the two questions the classic diagram skips: *what +order* (Django: registration order; DOM: registration order per phase) and +*what happens when a listener throws* (Django propagates unless you use +`send_robust`; the DOM isolates). This module's `Signal` makes exactly those +two decisions explicit parameters. diff --git a/patterns/behavioral/observer/docs/fundamentals.md b/patterns/behavioral/observer/docs/fundamentals.md new file mode 100644 index 0000000..53c662f --- /dev/null +++ b/patterns/behavioral/observer/docs/fundamentals.md @@ -0,0 +1,81 @@ +# Observer — fundamentals + +## Intent + +Define a one-to-many dependency so that when one object changes state, all +its dependents are notified automatically — without the subject compiling a +list of friends into itself. + +## Participants + +| Role | Classic (GoF) form | Python form | +|---|---|---| +| Subject | `attach` / `detach` / `notify` methods | `Signal` in [`pattern/signal.py`](../pattern/signal.py) — or any list of callables | +| Observer contract | An ABC with one `update()` method | Any callable `(event) -> None` | +| Concrete observers | Subclasses implementing `update()` | Plain functions, bound methods, callable objects | + +## Mechanism + +1. Interested parties subscribe — in Python, appending a callable. +2. The subject changes and emits: each subscriber is called, in order, with + the event. +3. The subject knows *that* it has subscribers, never *who* they are — + adding a fourth listener touches zero subject code. +4. A failure policy governs what a raising subscriber does to the rest — + the decision GoF never mentions and production code lives or dies by. + +## The classic form, and what Python absorbs + +The textbook version builds an inheritance seam for what is, in Python, an +argument slot: + +```python +class Observer(ABC): + @abstractmethod + def update(self, temperature: float) -> None: ... + + +class Display(Observer): + def update(self, temperature: float) -> None: ... + + +class WeatherStation: # the subject + def attach(self, observer: Observer) -> None: + self._observers.append(observer) + + def set_temperature(self, value: float) -> None: + self._temperature = value + for observer in self._observers: + observer.update(value) # one-method interface = a function +``` + +An ABC with a single `update` method *is* a function with extra steps: the +callable protocol already expresses "something invokable with an event." +Subscribing collapses to `list.append`; the subject's whole machinery is a +loop. What survives is the dependency direction — the subject broadcasts to +strangers — and the two decisions the class diagram hides: **notification +order** and **failure policy**. + +A second Python absorption: observation often hides behind a `@property` +setter, so plain attribute assignment (`station.temperature = 35.0`) +triggers the broadcast. That is how observing APIs usually *feel* in Python +even when a `Signal` sits underneath. + +## When to use it + +- Several independent reactions to one change (email + metrics + audit), and + the emitter must not know them. +- Plug-in points: subscribers registered from modules the subject never imports. + +## When not to use it + +- Exactly one, known receiver → call it. Indirection without fan-out is noise. +- The reaction must happen *before* the change commits → that is validation, + not observation; observers can't veto. +- Cross-process or durable events → a message queue; in-process observers + silently die with the process. + +## Verdict: pythonic + +Lists of callables, everywhere, deliberately — `Signal` only adds the two +policies (order, failure) that a bare list leaves implicit. diff --git a/patterns/behavioral/observer/docs/implementation.md b/patterns/behavioral/observer/docs/implementation.md new file mode 100644 index 0000000..61a14bf --- /dev/null +++ b/patterns/behavioral/observer/docs/implementation.md @@ -0,0 +1,84 @@ +# Observer — putting it into a system + +## The smell it fixes + +The subject hard-codes its audience: + +```python +def mark_shipped(self, order): + order.status = "shipped" + email.send_shipped_notice(order) # the pipeline now imports email, + metrics.incr("orders.shipped") # metrics, audit ... and grows a + audit.record(order, "shipped") # new import per interested party +``` + +Every new reaction edits the pipeline. Inverted, the pipeline emits one +event and reactions subscribe from their own modules. + +## Steps + +1. **Make the event a value.** A small frozen dataclass carrying what + subscribers need — not the subject itself (that re-couples them). +2. **Give the subject a `Signal`.** One per event kind beats one bus with + string topics; the type parameter documents the payload. +3. **Choose the failure policy at construction.** The default propagates — + right for tests and for subscribers that are truly part of the operation. + Pass `on_error` to isolate: log to a dead-letter list, keep notifying. + Never decide this by accident. +4. **Subscribe at the edges.** Wiring (`signal.subscribe(...)`) belongs in + composition code — the app's startup, a fixture — not inside the subject. +5. **Pin order only if it means something.** Subscribers run in subscription + order; if a test doesn't assert an ordering requirement, you don't have one. + +```python +from patterns.behavioral.observer import Signal, Subscriber + + +class OrderPipeline: + def __init__(self) -> None: + self.dead_letters: list[str] = [] + self.events: Signal[OrderEvent] = Signal(on_error=self._quarantine) + + def _quarantine(self, err: Exception, subscriber: Subscriber[OrderEvent]) -> None: + name = getattr(subscriber, "__name__", type(subscriber).__name__) + self.dead_letters.append(f"{name}: {err}") + + def advance(self, order_id: str, status: str, total: float) -> None: + self.events.emit(OrderEvent(order_id, status, total)) +``` + +## Python idioms that keep it small + +- Subscribers are **plain callables**: `seen.append` subscribes a list's own + method; a lambda subscribes a filter; a class with `__call__` subscribes + stateful behavior. +- `signal.subscribe` as a **decorator** registers a handler at definition + site — the shape Django's `@receiver` and Flask's hooks made familiar. +- Hide the emit behind a **property setter** when the "event" is really an + attribute change — callers write plain assignment. + +## Pitfalls + +- **One raising subscriber silencing the rest** — the load-bearing caveat. + `Signal`'s default is honest (it propagates loudly); switch to `on_error` + isolation the moment subscribers belong to different owners. +- **Mutating the subscriber list mid-broadcast.** `emit` iterates a copy so + self-unsubscribing handlers are safe — preserve that if you hand-roll. +- **Fat events.** Passing the mutable subject as the event invites + subscribers to write to it; broadcast immutable facts. +- **Hidden ordering contracts.** If metrics must run before email, that is + pipeline logic, not observation — make it one subscriber or one explicit + sequence. +- **Expecting delivery guarantees.** In-process observers give none: no + retry, no persistence, gone on crash. Needing those means a queue, not + this pattern. + +## Worked example + +[`examples/order_events/`](../examples/order_events/) applies every step: +one pipeline, four independent subscribers, a down webhook quarantined to a +dead-letter list while the rest keep working. Run it with: + +```bash +uv run python -m patterns.behavioral.observer.examples.order_events +``` diff --git a/patterns/behavioral/observer/examples/__init__.py b/patterns/behavioral/observer/examples/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/patterns/behavioral/observer/examples/order_events/__init__.py b/patterns/behavioral/observer/examples/order_events/__init__.py new file mode 100644 index 0000000..1f65881 --- /dev/null +++ b/patterns/behavioral/observer/examples/order_events/__init__.py @@ -0,0 +1,22 @@ +"""Order events with independent subscribers, built on the Observer pattern. + +Run it: ``uv run python -m patterns.behavioral.observer.examples.order_events`` +""" + +from patterns.behavioral.observer.examples.order_events.models import OrderEvent +from patterns.behavioral.observer.examples.order_events.subscribers import ( + AuditLog, + EmailNotifier, + MetricsCounter, + OrderPipeline, + flaky_webhook, +) + +__all__ = [ + "AuditLog", + "EmailNotifier", + "MetricsCounter", + "OrderEvent", + "OrderPipeline", + "flaky_webhook", +] diff --git a/patterns/behavioral/observer/examples/order_events/__main__.py b/patterns/behavioral/observer/examples/order_events/__main__.py new file mode 100644 index 0000000..a035a17 --- /dev/null +++ b/patterns/behavioral/observer/examples/order_events/__main__.py @@ -0,0 +1,31 @@ +"""Demo: one order's life, four independent listeners, one of them down.""" + +from __future__ import annotations + +from patterns.behavioral.observer.examples.order_events.subscribers import ( + AuditLog, + EmailNotifier, + MetricsCounter, + OrderPipeline, + flaky_webhook, +) + + +def main() -> None: + pipeline = OrderPipeline() + email, metrics, audit = EmailNotifier(), MetricsCounter(), AuditLog() + for subscriber in (email, metrics, audit, flaky_webhook): + pipeline.events.subscribe(subscriber) + + pipeline.advance("A-100", "placed", 42.50) + pipeline.advance("A-100", "paid", 42.50) + pipeline.advance("A-100", "shipped", 42.50) + + print(f"email outbox: {email.outbox}") + print(f"metrics: {dict(metrics.counts)}") + print(f"audit trail: {len(audit.entries)} entries") + print(f"dead letters: {len(pipeline.dead_letters)} (webhook was down, nobody else noticed)") + + +if __name__ == "__main__": + main() diff --git a/patterns/behavioral/observer/examples/order_events/models.py b/patterns/behavioral/observer/examples/order_events/models.py new file mode 100644 index 0000000..78de267 --- /dev/null +++ b/patterns/behavioral/observer/examples/order_events/models.py @@ -0,0 +1,14 @@ +"""Domain types for the order-events mini-project.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class OrderEvent: + """One change in an order's life, broadcast to whoever cares.""" + + order_id: str + status: str # "placed" | "paid" | "shipped" + total: float diff --git a/patterns/behavioral/observer/examples/order_events/subscribers.py b/patterns/behavioral/observer/examples/order_events/subscribers.py new file mode 100644 index 0000000..e1cc75a --- /dev/null +++ b/patterns/behavioral/observer/examples/order_events/subscribers.py @@ -0,0 +1,65 @@ +"""The subscribers, and the wired-up signal they listen to. + +Each subscriber is independent: the order pipeline emits events without +knowing that email, metrics, and audit exist. The signal's error policy is +the deliberate decision here — a failing subscriber is quarantined and +logged, never allowed to silence the others. +""" + +from __future__ import annotations + +from collections import Counter + +from patterns.behavioral.observer.examples.order_events.models import OrderEvent +from patterns.behavioral.observer.pattern import Signal, Subscriber + + +class EmailNotifier: + """Pretends to send mail; records what it would have sent.""" + + def __init__(self) -> None: + self.outbox: list[str] = [] + + def __call__(self, event: OrderEvent) -> None: + if event.status == "shipped": + self.outbox.append(f"to customer of {event.order_id}: your order shipped!") + + +class MetricsCounter: + """Counts events by status, the way a stats client would.""" + + def __init__(self) -> None: + self.counts: Counter[str] = Counter() + + def __call__(self, event: OrderEvent) -> None: + self.counts[event.status] += 1 + + +class AuditLog: + """Append-only trail of everything that happened.""" + + def __init__(self) -> None: + self.entries: list[str] = [] + + def __call__(self, event: OrderEvent) -> None: + self.entries.append(f"{event.order_id} -> {event.status} (${event.total:.2f})") + + +def flaky_webhook(event: OrderEvent) -> None: + """A partner integration that is down today.""" + raise ConnectionError("partner endpoint 503") + + +class OrderPipeline: + """The subject: emits an event per status change, knows no subscriber.""" + + def __init__(self) -> None: + self.dead_letters: list[str] = [] + self.events: Signal[OrderEvent] = Signal(on_error=self._quarantine) + + def _quarantine(self, err: Exception, subscriber: Subscriber[OrderEvent]) -> None: + name = getattr(subscriber, "__name__", type(subscriber).__name__) + self.dead_letters.append(f"{name}: {err}") + + def advance(self, order_id: str, status: str, total: float) -> None: + self.events.emit(OrderEvent(order_id, status, total)) diff --git a/patterns/behavioral/observer/naive.py b/patterns/behavioral/observer/naive.py deleted file mode 100644 index d5b28eb..0000000 --- a/patterns/behavioral/observer/naive.py +++ /dev/null @@ -1,60 +0,0 @@ -"""The Gang of Four Observer: Subject, Observer ABC, update().""" - -from __future__ import annotations - -from abc import ABC, abstractmethod - - -class Observer(ABC): - @abstractmethod - def update(self, temperature: float) -> None: ... - - -class Display(Observer): - def __init__(self) -> None: - self.shown: float | None = None - - def update(self, temperature: float) -> None: - self.shown = temperature - - -class AlarmLog(Observer): - def __init__(self, threshold: float) -> None: - self.threshold = threshold - self.alerts: list[float] = [] - - def update(self, temperature: float) -> None: - if temperature > self.threshold: - self.alerts.append(temperature) - - -class WeatherStation: - """The subject.""" - - def __init__(self) -> None: - self._observers: list[Observer] = [] - self._temperature = 0.0 - - def attach(self, observer: Observer) -> None: - self._observers.append(observer) - - def detach(self, observer: Observer) -> None: - self._observers.remove(observer) - - def set_temperature(self, value: float) -> None: - self._temperature = value - for observer in self._observers: - observer.update(value) - - -def main() -> None: - station, display, alarm = WeatherStation(), Display(), AlarmLog(30.0) - station.attach(display) - station.attach(alarm) - station.set_temperature(21.5) - station.set_temperature(35.0) - print(f"display shows {display.shown}, alarms: {alarm.alerts}") - - -if __name__ == "__main__": - main() diff --git a/patterns/behavioral/observer/pattern/__init__.py b/patterns/behavioral/observer/pattern/__init__.py new file mode 100644 index 0000000..c0d2a0d --- /dev/null +++ b/patterns/behavioral/observer/pattern/__init__.py @@ -0,0 +1,5 @@ +"""The Observer pattern, importable as library code.""" + +from patterns.behavioral.observer.pattern.signal import ErrorPolicy, Signal, Subscriber + +__all__ = ["ErrorPolicy", "Signal", "Subscriber"] diff --git a/patterns/behavioral/observer/pattern/signal.py b/patterns/behavioral/observer/pattern/signal.py new file mode 100644 index 0000000..79fc29a --- /dev/null +++ b/patterns/behavioral/observer/pattern/signal.py @@ -0,0 +1,56 @@ +"""Observer as an importable, typed building block. + +A subscriber is any callable taking the event. ``Signal`` broadcasts to its +subscribers in subscription order, with the failure policy stated up front: +by default a raising subscriber propagates (fail fast); pass ``on_error`` to +isolate subscribers from each other instead. +""" + +from __future__ import annotations + +from collections.abc import Callable, Iterator +from typing import Generic, TypeVar + +Event = TypeVar("Event") + +Subscriber = Callable[[Event], None] +ErrorPolicy = Callable[[Exception, "Subscriber[Event]"], None] + + +class Signal(Generic[Event]): + """A broadcast list of callables with an explicit failure policy.""" + + def __init__(self, on_error: ErrorPolicy[Event] | None = None) -> None: + self._subscribers: list[Subscriber[Event]] = [] + self._on_error = on_error + + def subscribe(self, subscriber: Subscriber[Event]) -> Subscriber[Event]: + """Add a subscriber (appending = subscribing); usable as a decorator.""" + self._subscribers.append(subscriber) + return subscriber + + def unsubscribe(self, subscriber: Subscriber[Event]) -> None: + """Remove a subscriber; ``ValueError`` if it never subscribed.""" + self._subscribers.remove(subscriber) + + def emit(self, event: Event) -> None: + """Notify every subscriber in order. + + Iterates over a copy, so subscribers may unsubscribe (even + themselves) mid-broadcast. A subscriber's exception propagates unless + an ``on_error`` policy was given, in which case the policy is called + and the remaining subscribers still run. + """ + for subscriber in list(self._subscribers): + try: + subscriber(event) + except Exception as err: + if self._on_error is None: + raise + self._on_error(err, subscriber) + + def __iter__(self) -> Iterator[Subscriber[Event]]: + return iter(self._subscribers) + + def __len__(self) -> int: + return len(self._subscribers) diff --git a/patterns/behavioral/observer/pythonic.py b/patterns/behavioral/observer/pythonic.py deleted file mode 100644 index 94e7dea..0000000 --- a/patterns/behavioral/observer/pythonic.py +++ /dev/null @@ -1,43 +0,0 @@ -"""Observers as callables; observation hidden behind a property. - -Subscribing is appending a function. The property setter shows the idiom -most Python APIs actually use: plain assignment triggers the broadcast. -""" - -from __future__ import annotations - -from collections.abc import Callable - -Listener = Callable[[float], None] - - -class WeatherStation: - def __init__(self) -> None: - self.listeners: list[Listener] = [] - self._temperature = 0.0 - - @property - def temperature(self) -> float: - return self._temperature - - @temperature.setter - def temperature(self, value: float) -> None: - self._temperature = value - for listen in list(self.listeners): # copy: observers may unsubscribe - listen(value) - - -def main() -> None: - station = WeatherStation() - seen: list[float] = [] - alerts: list[float] = [] - station.listeners.append(seen.append) - station.listeners.append(lambda t: alerts.append(t) if t > 30 else None) - - station.temperature = 21.5 # plain assignment broadcasts - station.temperature = 35.0 - print(f"seen: {seen}, alerts: {alerts}") - - -if __name__ == "__main__": - main() diff --git a/patterns/behavioral/observer/real_world.py b/patterns/behavioral/observer/real_world.py deleted file mode 100644 index f1e4b75..0000000 --- a/patterns/behavioral/observer/real_world.py +++ /dev/null @@ -1,35 +0,0 @@ -"""``Future.add_done_callback``: the stdlib observer. - -Any callable can subscribe to a future's completion; late subscribers to an -already-resolved future fire immediately. -""" - -from __future__ import annotations - -from concurrent.futures import Future - - -def observe_completion() -> list[str]: - events: list[str] = [] - future: Future[int] = Future() - future.add_done_callback(lambda f: events.append(f"log: {f.result()}")) - future.add_done_callback(lambda f: events.append(f"metrics: {f.result()}")) - future.set_result(42) - return events - - -def late_subscription_fires_immediately() -> bool: - future: Future[str] = Future() - future.set_result("done") - fired: list[str] = [] - future.add_done_callback(lambda f: fired.append(f.result())) - return fired == ["done"] - - -def main() -> None: - print(observe_completion()) - print(f"late subscriber still notified: {late_subscription_fires_immediately()}") - - -if __name__ == "__main__": - main() diff --git a/patterns/behavioral/observer/tests/test_observer.py b/patterns/behavioral/observer/tests/test_observer.py deleted file mode 100644 index cfceeb5..0000000 --- a/patterns/behavioral/observer/tests/test_observer.py +++ /dev/null @@ -1,50 +0,0 @@ -"""Behavioral tests for all three observer variants.""" - -from patterns.behavioral.observer import naive, pythonic, real_world - - -class TestNaive: - def test_all_attached_observers_are_notified(self) -> None: - station, display, alarm = naive.WeatherStation(), naive.Display(), naive.AlarmLog(30.0) - station.attach(display) - station.attach(alarm) - station.set_temperature(35.0) - assert display.shown == 35.0 - assert alarm.alerts == [35.0] - - def test_detached_observer_stops_receiving(self) -> None: - station, display = naive.WeatherStation(), naive.Display() - station.attach(display) - station.set_temperature(10.0) - station.detach(display) - station.set_temperature(99.0) - assert display.shown == 10.0 - - -class TestPythonic: - def test_assignment_broadcasts_to_callables(self) -> None: - station = pythonic.WeatherStation() - seen: list[float] = [] - station.listeners.append(seen.append) - station.temperature = 21.5 - assert seen == [21.5] - assert station.temperature == 21.5 - - def test_observer_may_unsubscribe_during_notification(self) -> None: - station = pythonic.WeatherStation() - - def once(value: float) -> None: - station.listeners.remove(once) - - station.listeners.append(once) - station.temperature = 1.0 # must not blow up mid-iteration - station.temperature = 2.0 - assert station.listeners == [] - - -class TestRealWorld: - def test_done_callbacks_fire_in_order(self) -> None: - assert real_world.observe_completion() == ["log: 42", "metrics: 42"] - - def test_late_subscription(self) -> None: - assert real_world.late_subscription_fires_immediately() diff --git a/patterns/behavioral/observer/tests/test_order_events.py b/patterns/behavioral/observer/tests/test_order_events.py new file mode 100644 index 0000000..6f9bee1 --- /dev/null +++ b/patterns/behavioral/observer/tests/test_order_events.py @@ -0,0 +1,80 @@ +"""Behavioral tests for the order-events mini-project.""" + +from __future__ import annotations + +import pytest + +from patterns.behavioral.observer.examples.order_events import ( + AuditLog, + EmailNotifier, + MetricsCounter, + OrderPipeline, + flaky_webhook, +) +from patterns.behavioral.observer.examples.order_events.__main__ import main + + +def wired_pipeline() -> tuple[OrderPipeline, EmailNotifier, MetricsCounter, AuditLog]: + pipeline = OrderPipeline() + email, metrics, audit = EmailNotifier(), MetricsCounter(), AuditLog() + for subscriber in (email, metrics, audit): + pipeline.events.subscribe(subscriber) + return pipeline, email, metrics, audit + + +class TestIndependentSubscribers: + def test_every_subscriber_sees_every_event(self) -> None: + pipeline, _, metrics, audit = wired_pipeline() + pipeline.advance("A-1", "placed", 10.0) + pipeline.advance("A-1", "paid", 10.0) + assert metrics.counts == {"placed": 1, "paid": 1} + assert len(audit.entries) == 2 + + def test_email_reacts_only_to_shipping(self) -> None: + pipeline, email, _, _ = wired_pipeline() + pipeline.advance("A-2", "placed", 5.0) + assert email.outbox == [] + pipeline.advance("A-2", "shipped", 5.0) + assert email.outbox == ["to customer of A-2: your order shipped!"] + + def test_the_pipeline_needs_no_subscribers_at_all(self) -> None: + pipeline = OrderPipeline() + pipeline.advance("A-3", "placed", 1.0) # nobody listening, no error + assert pipeline.dead_letters == [] + + +class TestFailureIsolation: + def test_a_down_webhook_does_not_silence_the_others(self) -> None: + pipeline, _, metrics, audit = wired_pipeline() + pipeline.events.subscribe(flaky_webhook) + pipeline.advance("A-4", "paid", 99.0) + assert metrics.counts["paid"] == 1 + assert len(audit.entries) == 1 + + def test_failures_land_in_the_dead_letter_list_with_a_name(self) -> None: + pipeline, *_ = wired_pipeline() + pipeline.events.subscribe(flaky_webhook) + pipeline.advance("A-5", "shipped", 3.0) + assert pipeline.dead_letters == ["flaky_webhook: partner endpoint 503"] + + def test_a_failing_class_based_subscriber_is_named_by_its_type(self) -> None: + # Instances have no __name__ — the quarantine falls back to the type. + class BrokenAuditSink: + def __call__(self, event: object) -> None: + raise RuntimeError("disk full") + + pipeline, *_ = wired_pipeline() + pipeline.events.subscribe(BrokenAuditSink()) + pipeline.advance("A-6", "shipped", 3.0) + assert pipeline.dead_letters == ["BrokenAuditSink: disk full"] + + +class TestDemo: + def test_main_reports_deliveries_and_the_dead_webhook( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + main() + out = capsys.readouterr().out + assert "your order shipped!" in out + assert "'placed': 1" in out and "'shipped': 1" in out + assert "dead letters: 3" in out diff --git a/patterns/behavioral/observer/tests/test_signal.py b/patterns/behavioral/observer/tests/test_signal.py new file mode 100644 index 0000000..1ea43ac --- /dev/null +++ b/patterns/behavioral/observer/tests/test_signal.py @@ -0,0 +1,106 @@ +"""Behavioral tests for the Observer pattern's Signal.""" + +from __future__ import annotations + +import pytest + +from patterns.behavioral.observer import Signal, Subscriber + + +class TestBroadcast: + def test_subscribers_are_notified_in_subscription_order(self) -> None: + signal: Signal[int] = Signal() + calls: list[str] = [] + signal.subscribe(lambda e: calls.append(f"first:{e}")) + signal.subscribe(lambda e: calls.append(f"second:{e}")) + signal.emit(7) + assert calls == ["first:7", "second:7"] + + def test_subscribe_works_as_a_decorator(self) -> None: + signal: Signal[str] = Signal() + seen: list[str] = [] + + @signal.subscribe + def listener(event: str) -> None: + seen.append(event) + + signal.emit("hello") + assert seen == ["hello"] + + def test_unsubscribed_callables_stop_receiving(self) -> None: + signal: Signal[int] = Signal() + seen: list[int] = [] + subscriber: Subscriber[int] = seen.append + signal.subscribe(subscriber) + signal.emit(1) + signal.unsubscribe(subscriber) + signal.emit(2) + assert seen == [1] + + def test_iteration_and_len_expose_the_subscribers(self) -> None: + signal: Signal[str] = Signal() + assert len(signal) == 0 and list(signal) == [] + + def first(event: str) -> None: ... + + def second(event: str) -> None: ... + + signal.subscribe(first) + signal.subscribe(second) + assert len(signal) == 2 + assert list(signal) == [first, second] + + def test_unsubscribing_a_stranger_raises(self) -> None: + signal: Signal[int] = Signal() + with pytest.raises(ValueError): + signal.unsubscribe(print) + + def test_a_subscriber_may_unsubscribe_itself_mid_broadcast(self) -> None: + signal: Signal[int] = Signal() + seen: list[int] = [] + + def once(event: int) -> None: + seen.append(event) + signal.unsubscribe(once) + + signal.subscribe(once) + signal.subscribe(seen.append) # must still run in the same emit + signal.emit(1) + signal.emit(2) + assert seen == [1, 1, 2] + + +class TestFailurePolicy: + def test_default_policy_propagates_and_stops_the_broadcast(self) -> None: + signal: Signal[int] = Signal() + reached: list[int] = [] + signal.subscribe(lambda e: (_ for _ in ()).throw(RuntimeError("boom"))) + signal.subscribe(reached.append) + with pytest.raises(RuntimeError, match="boom"): + signal.emit(1) + assert reached == [] # fail fast means fail visibly + + def test_on_error_policy_isolates_and_keeps_notifying(self) -> None: + quarantined: list[str] = [] + signal: Signal[int] = Signal(on_error=lambda err, sub: quarantined.append(str(err))) + reached: list[int] = [] + + def failing(event: int) -> None: + raise ConnectionError("down") + + signal.subscribe(failing) + signal.subscribe(reached.append) + signal.emit(5) + assert reached == [5] + assert quarantined == ["down"] + + def test_error_policy_receives_the_offending_subscriber(self) -> None: + offenders: list[Subscriber[int]] = [] + signal: Signal[int] = Signal(on_error=lambda err, sub: offenders.append(sub)) + + def failing(event: int) -> None: + raise ValueError + + signal.subscribe(failing) + signal.emit(0) + assert offenders == [failing] diff --git a/patterns/behavioral/state/README.md b/patterns/behavioral/state/README.md index 4433f2e..09a45b2 100644 --- a/patterns/behavioral/state/README.md +++ b/patterns/behavioral/state/README.md @@ -14,30 +14,18 @@ stdlib_sightings: [enum.Enum, generators] # State -## Problem - -A turnstile behaves differently locked vs unlocked; an order moves through a -lifecycle. Branching on a mode flag in every method scatters the machine -across the class. - -## Naive solution - -`naive.py` is the GoF form: a class per state, the context delegating to the -current state object, transitions swapping the object. - -## Pythonic solution - -Two idioms in `pythonic.py`: an `Enum` + transition-table machine (data, not -classes — the whole machine visible in one dict), and a **generator** machine -where the paused frame *is* the state. - -## In the wild - -Generators are the language's own state machines — every coroutine and every -`itertools`-style pipeline stage relies on frame suspension keeping state. -`real_world.py` shows a protocol scanner built on exactly that. - -## Verdict - -**Use with care.** Class-per-state pays off only for large machines with -state-specific data; tables and generators cover the rest. +Behavior that depends on "where we are" — gathered into one readable +transition table instead of mode-flag branches in every method. +**Verdict: use with care** — tables and generators cover most machines; +class-per-state only pays at real size. + +| Where | What | +|---|---| +| [`pattern/`](pattern/) | The importable code: `StateMachine`, `Step`, `IllegalTransitionError` | +| [`docs/`](docs/) | [Fundamentals](docs/fundamentals.md) · [Implementation guide](docs/implementation.md) · [External examples](docs/examples.md) | +| [`examples/order_lifecycle/`](examples/order_lifecycle/) | Mini-project: an order FSM with guards and an audit log built on `pattern/` | +| [`tests/`](tests/) | Behavioral tests for the pattern and the mini-project | + +```bash +uv run python -m patterns.behavioral.state.examples.order_lifecycle +``` diff --git a/patterns/behavioral/state/__init__.py b/patterns/behavioral/state/__init__.py index 74e5afe..cdcb916 100644 --- a/patterns/behavioral/state/__init__.py +++ b/patterns/behavioral/state/__init__.py @@ -1 +1,8 @@ -"""State: behavior that changes with internal state.""" +"""State — public API. + +>>> from patterns.behavioral.state import StateMachine +""" + +from patterns.behavioral.state.pattern import Guard, IllegalTransitionError, StateMachine, Step + +__all__ = ["Guard", "IllegalTransitionError", "StateMachine", "Step"] diff --git a/patterns/behavioral/state/docs/examples.md b/patterns/behavioral/state/docs/examples.md new file mode 100644 index 0000000..8ae481c --- /dev/null +++ b/patterns/behavioral/state/docs/examples.md @@ -0,0 +1,43 @@ +# State — where it lives outside this repo + +Cited, real implementations to study (or point an agent at) when designing or +reviewing state-machine code. + +## Python standard library + +- **`enum.Enum`** — states as first-class, finite, typo-proof values; the + foundation the table form assumes. + [docs.python.org/3/library/enum.html](https://docs.python.org/3/library/enum.html) +- **Generators** — the interpreter-maintained state machine: the suspension + point is the state. Every `yield`-based parser and pipeline stage is this + pattern with zero state fields. + [docs.python.org/3/reference/expressions.html#yield-expressions](https://docs.python.org/3/reference/expressions.html#yield-expressions) +- **`asyncio.Task` lifecycle** — pending → running → done/cancelled, with + rules about which motions exist (`cancel()` on a done task is a no-op that + returns False): a transition table in prose. + [docs.python.org/3/library/asyncio-task.html](https://docs.python.org/3/library/asyncio-task.html) + +## Libraries built on the pattern + +- **transitions (pytransitions)** — the most-used Python FSM library: + declarative tables, guards ("conditions"), callbacks, hierarchical + machines — this module's `StateMachine` grown to production size. + [github.com/pytransitions/transitions](https://github.com/pytransitions/transitions) *(unverified)* +- **django-fsm / viewflow.fsm** — lifecycle guards on Django model fields: + `@transition(source, target)` decorators putting the table next to the + model it rules. [github.com/viewflow/django-fsm](https://github.com/viewflow/django-fsm) *(unverified)* + +## The classic specification + +- **TCP's connection diagram (RFC 9293 §3.3.2)** — LISTEN, SYN-SENT, + ESTABLISHED, TIME-WAIT... the state machine every networked program rides + on, specified as exactly a transition table. + [rfc-editor.org/rfc/rfc9293](https://www.rfc-editor.org/rfc/rfc9293) *(unverified)* + +## What to notice across all of them + +The serious ones publish their table (TCP's diagram, pytransitions' +declaration) rather than burying motion rules in methods — the machine you +can *read whole* is the feature. And each distinguishes state from data: +`asyncio` keeps a task's result out of its state set the same way a guard +keeps `amount_paid` out of an order's. diff --git a/patterns/behavioral/state/docs/fundamentals.md b/patterns/behavioral/state/docs/fundamentals.md new file mode 100644 index 0000000..3ec941a --- /dev/null +++ b/patterns/behavioral/state/docs/fundamentals.md @@ -0,0 +1,95 @@ +# State — fundamentals + +## Intent + +Let an object alter its behavior when its internal state changes — the object +appears to change class — instead of branching on a mode flag in every method. + +## Participants + +| Role | Classic (GoF) form | Python form | +|---|---|---| +| Context | Holds a state object, delegates every operation to it | The domain object holding a `StateMachine` (or just an `Enum` field) | +| State interface | An ABC, one method per operation | An `Enum` of states + a table of `(state, event)` pairs | +| Concrete states | A class per state, each owning its transitions | Rows in the table — data, not classes ([`pattern/machine.py`](../pattern/machine.py)) | + +## Mechanism + +1. Enumerate the states and the events that move between them. +2. Write the machine as a **table**: `(current, event) -> next`. What is + absent is illegal — the table is a whitelist. +3. Fire events through one choke point (`trigger`), which either moves the + machine or raises `IllegalTransitionError`; there is no half-move. +4. **Guards** veto listed transitions using data the table can't see + ("refund only if money was taken"); the **log** records every step. + +## The classic form, and what Python absorbs + +The textbook version spends a class per state and swaps objects to transition: + +```python +class TurnstileState(ABC): + @abstractmethod + def coin(self, turnstile: Turnstile) -> str: ... + @abstractmethod + def push(self, turnstile: Turnstile) -> str: ... + + +class Locked(TurnstileState): + def coin(self, turnstile: Turnstile) -> str: + turnstile.state = Unlocked() # transition = object swap + return "unlocked" + + +class Unlocked(TurnstileState): ... + + +class Turnstile: # the context + def coin(self) -> str: + return self.state.coin(self) # every call delegates +``` + +Four classes to say four facts. As a table, the same machine is one dict — +whole, on one screen, diffable in review: + +```python +TRANSITIONS = { + (State.LOCKED, "coin"): (State.UNLOCKED, "unlocked"), + (State.LOCKED, "push"): (State.LOCKED, "locked: push refused"), + (State.UNLOCKED, "coin"): (State.UNLOCKED, "already unlocked"), + (State.UNLOCKED, "push"): (State.LOCKED, "pushed through, locking"), +} +``` + +Python has a second, deeper absorption: a **generator** is a state machine +maintained by the interpreter — the suspension point *is* the state: + +```python +def turnstile() -> Generator[str, str, None]: + while True: + event = yield "ready" + if event == "coin": + event = yield "unlocked" # the UNLOCKED state lives HERE, + ... # in where the frame is paused +``` + +Every coroutine and parsing loop in the stdlib runs on this: no state field +exists because the position in the code carries it. + +## When to use it + +- A lifecycle with rules: orders, documents, connections, jobs — anywhere + "what may happen next" depends on "where we are". +- The moment a second `if self.mode == ...` appears in a second method. + +## When not to use it + +- Two states, one branch → keep the `if`; a machine is ceremony. +- The "states" are just data values with no transition rules → a plain field. +- The flow is linear consumption of a stream → write the generator directly. + +## Verdict: use with care + +The table form covers most machines and stays reviewable. A class per state +pays only when each state carries its own data *and* behavior bundle; +generators win when the machine is really a paused program. diff --git a/patterns/behavioral/state/docs/implementation.md b/patterns/behavioral/state/docs/implementation.md new file mode 100644 index 0000000..2315f98 --- /dev/null +++ b/patterns/behavioral/state/docs/implementation.md @@ -0,0 +1,85 @@ +# State — putting it into a system + +## The smell it fixes + +A mode flag branching in every method — the machine exists, but smeared: + +```python +class Order: + def cancel(self): + if self.status in ("placed", "paid"): # rule fragment here + self.status = "cancelled" + else: + raise ValueError("too late") + + def refund(self): + if self.status == "paid" and self.amount_paid > 0: # fragment there + ... +``` + +Nobody can read the whole lifecycle, and a new status means auditing every +method. The pattern gathers the machine into one visible table. + +## Steps + +1. **Name states and events as Enums.** Strings work but typos become + runtime surprises; Enum members make the table exhaustive to the reader + and checkable by mypy. +2. **Write the transition table** `(state, event) -> state`. Review it like + policy, because it is policy: the absent pairs are the business rules + ("no cancel after shipment" is a row that *does not exist*). +3. **Add guards only for data rules.** Shape rules belong in the table; + guards (`lambda: order.amount_paid > 0`) are for decisions the current + data must make. A guard that ignores data belongs in the table instead. +4. **Route every change through `trigger`.** The domain object keeps its + fields; its *status* moves only via the machine, so illegal motion is an + exception, not a silent field write. +5. **Use the log.** The machine already records `source --event--> target` + for each step — that is the audit trail ops asks for later, free. + +```python +from patterns.behavioral.state import StateMachine + + +def build_lifecycle(order: Order) -> StateMachine[OrderStatus, OrderAction]: + return StateMachine( + initial=OrderStatus.CART, + table=LIFECYCLE, + guards={(OrderStatus.PAID, OrderAction.REFUND): lambda: order.amount_paid > 0}, + ) +``` + +## Python idioms that keep it small + +- The table as a **module-level dict** makes the machine importable, + testable, and rendered whole in one diff hunk. +- Guards are **closures over the domain object** — no context parameter + threading, no subclassing. +- `machine.can(event)` drives UIs ("which buttons to show") from the same + table that enforces the rules — one source of truth. +- When the machine is a linear consumption loop, skip the class: **write a + generator** and let the paused frame hold the state. + +## Pitfalls + +- **Bypassing the machine.** One `order.status = X` assignment elsewhere and + the table lies. Make status transitions go through `trigger` only. +- **Guards with side effects.** `can()` calls guards too — a guard that + charges a card on inspection charges it twice. Guards decide; actions act. +- **Stringly-typed states** drift ("Paid" vs "paid") and silently add + unreachable rows. Enums close the set. +- **The god-machine.** If the table needs sub-states of sub-states, you have + several machines (payment, fulfillment) sharing an object — split them. +- **Machine state vs. domain data confusion.** `amount_paid` is data; + `PAID` is state. Guards exist precisely so data can stay out of the state + space instead of exploding it (`PAID_IN_FULL`, `PAID_PARTIALLY`, ...). + +## Worked example + +[`examples/order_lifecycle/`](../examples/order_lifecycle/) applies every +step: an eight-row table, three data guards, refusal of a too-late cancel, and +the audit log printed at the end. Run it with: + +```bash +uv run python -m patterns.behavioral.state.examples.order_lifecycle +``` diff --git a/patterns/behavioral/state/examples/__init__.py b/patterns/behavioral/state/examples/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/patterns/behavioral/state/examples/order_lifecycle/__init__.py b/patterns/behavioral/state/examples/order_lifecycle/__init__.py new file mode 100644 index 0000000..17524a9 --- /dev/null +++ b/patterns/behavioral/state/examples/order_lifecycle/__init__.py @@ -0,0 +1,16 @@ +"""An order lifecycle FSM, built on the State pattern. + +Run it: ``uv run python -m patterns.behavioral.state.examples.order_lifecycle`` +""" + +from patterns.behavioral.state.examples.order_lifecycle.lifecycle import ( + LIFECYCLE, + build_lifecycle, +) +from patterns.behavioral.state.examples.order_lifecycle.models import ( + Order, + OrderAction, + OrderStatus, +) + +__all__ = ["LIFECYCLE", "Order", "OrderAction", "OrderStatus", "build_lifecycle"] diff --git a/patterns/behavioral/state/examples/order_lifecycle/__main__.py b/patterns/behavioral/state/examples/order_lifecycle/__main__.py new file mode 100644 index 0000000..2a95f81 --- /dev/null +++ b/patterns/behavioral/state/examples/order_lifecycle/__main__.py @@ -0,0 +1,32 @@ +"""Demo: one order's happy path, with the machine refusing the wrong turns.""" + +from __future__ import annotations + +from patterns.behavioral.state.examples.order_lifecycle.lifecycle import build_lifecycle +from patterns.behavioral.state.examples.order_lifecycle.models import Order, OrderAction +from patterns.behavioral.state.pattern import IllegalTransitionError + + +def main() -> None: + order = Order("O-1", total=59.0, items=["keyboard"]) + lifecycle = build_lifecycle(order) + + lifecycle.trigger(OrderAction.PLACE) + lifecycle.trigger(OrderAction.PAY) + order.amount_paid = order.total + lifecycle.trigger(OrderAction.SHIP) + + try: + lifecycle.trigger(OrderAction.CANCEL) # too late: it's on the truck + except IllegalTransitionError as err: + print(f"refused: {err}") + + lifecycle.trigger(OrderAction.DELIVER) + print(f"final status: {lifecycle.state.name}") + print("audit log:") + for step in lifecycle.log: + print(f" {step.source.name} --{step.event.name}--> {step.target.name}") + + +if __name__ == "__main__": + main() diff --git a/patterns/behavioral/state/examples/order_lifecycle/lifecycle.py b/patterns/behavioral/state/examples/order_lifecycle/lifecycle.py new file mode 100644 index 0000000..716530b --- /dev/null +++ b/patterns/behavioral/state/examples/order_lifecycle/lifecycle.py @@ -0,0 +1,41 @@ +"""The order lifecycle: one table, two guards, an audit log for free. + +The whole business policy is readable in ``LIFECYCLE`` — which motions +exist — plus two guards for the rules that depend on data, not shape: +you can't pay for an empty cart, and you can't refund money never taken. +""" + +from __future__ import annotations + +from patterns.behavioral.state.examples.order_lifecycle.models import ( + Order, + OrderAction, + OrderStatus, +) +from patterns.behavioral.state.pattern import StateMachine + +#: (current status, action) -> next status. Absent pairs are illegal: +#: cancelling after shipment and refunding before payment simply don't exist. +LIFECYCLE: dict[tuple[OrderStatus, OrderAction], OrderStatus] = { + (OrderStatus.CART, OrderAction.PLACE): OrderStatus.PLACED, + (OrderStatus.PLACED, OrderAction.PAY): OrderStatus.PAID, + (OrderStatus.PLACED, OrderAction.CANCEL): OrderStatus.CANCELLED, + (OrderStatus.PAID, OrderAction.SHIP): OrderStatus.SHIPPED, + (OrderStatus.PAID, OrderAction.CANCEL): OrderStatus.CANCELLED, + (OrderStatus.PAID, OrderAction.REFUND): OrderStatus.REFUNDED, + (OrderStatus.SHIPPED, OrderAction.DELIVER): OrderStatus.DELIVERED, + (OrderStatus.DELIVERED, OrderAction.REFUND): OrderStatus.REFUNDED, +} + + +def build_lifecycle(order: Order) -> StateMachine[OrderStatus, OrderAction]: + """A fresh machine for one order; guards close over the order's data.""" + return StateMachine( + initial=OrderStatus.CART, + table=LIFECYCLE, + guards={ + (OrderStatus.CART, OrderAction.PLACE): lambda: bool(order.items), + (OrderStatus.PAID, OrderAction.REFUND): lambda: order.amount_paid > 0, + (OrderStatus.DELIVERED, OrderAction.REFUND): lambda: order.amount_paid > 0, + }, + ) diff --git a/patterns/behavioral/state/examples/order_lifecycle/models.py b/patterns/behavioral/state/examples/order_lifecycle/models.py new file mode 100644 index 0000000..7456592 --- /dev/null +++ b/patterns/behavioral/state/examples/order_lifecycle/models.py @@ -0,0 +1,35 @@ +"""Domain types for the order-lifecycle mini-project.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum, auto + + +class OrderStatus(Enum): + CART = auto() + PLACED = auto() + PAID = auto() + SHIPPED = auto() + DELIVERED = auto() + CANCELLED = auto() + REFUNDED = auto() + + +class OrderAction(Enum): + PLACE = auto() + PAY = auto() + SHIP = auto() + DELIVER = auto() + CANCEL = auto() + REFUND = auto() + + +@dataclass +class Order: + """The domain object whose behavior depends on where it is in its life.""" + + order_id: str + total: float + amount_paid: float = 0.0 + items: list[str] = field(default_factory=list) diff --git a/patterns/behavioral/state/naive.py b/patterns/behavioral/state/naive.py deleted file mode 100644 index b7cd2d0..0000000 --- a/patterns/behavioral/state/naive.py +++ /dev/null @@ -1,53 +0,0 @@ -"""The Gang of Four State: a class per state, context delegates.""" - -from __future__ import annotations - -from abc import ABC, abstractmethod - - -class TurnstileState(ABC): - @abstractmethod - def coin(self, turnstile: Turnstile) -> str: ... - - @abstractmethod - def push(self, turnstile: Turnstile) -> str: ... - - -class Locked(TurnstileState): - def coin(self, turnstile: Turnstile) -> str: - turnstile.state = Unlocked() - return "unlocked" - - def push(self, turnstile: Turnstile) -> str: - return "locked: push refused" - - -class Unlocked(TurnstileState): - def coin(self, turnstile: Turnstile) -> str: - return "already unlocked: coin returned" - - def push(self, turnstile: Turnstile) -> str: - turnstile.state = Locked() - return "pushed through, locking" - - -class Turnstile: - def __init__(self) -> None: - self.state: TurnstileState = Locked() - - def coin(self) -> str: - return self.state.coin(self) - - def push(self) -> str: - return self.state.push(self) - - -def main() -> None: - turnstile = Turnstile() - for event in ("push", "coin", "coin", "push", "push"): - result = turnstile.coin() if event == "coin" else turnstile.push() - print(f"{event}: {result}") - - -if __name__ == "__main__": - main() diff --git a/patterns/behavioral/state/pattern/__init__.py b/patterns/behavioral/state/pattern/__init__.py new file mode 100644 index 0000000..3e070f7 --- /dev/null +++ b/patterns/behavioral/state/pattern/__init__.py @@ -0,0 +1,10 @@ +"""The State pattern, importable as library code.""" + +from patterns.behavioral.state.pattern.machine import ( + Guard, + IllegalTransitionError, + StateMachine, + Step, +) + +__all__ = ["Guard", "IllegalTransitionError", "StateMachine", "Step"] diff --git a/patterns/behavioral/state/pattern/machine.py b/patterns/behavioral/state/pattern/machine.py new file mode 100644 index 0000000..3ccef02 --- /dev/null +++ b/patterns/behavioral/state/pattern/machine.py @@ -0,0 +1,74 @@ +"""State as an importable, typed building block. + +The machine is data: a transition table mapping ``(state, event)`` to the +next state, optional guards that can veto a listed transition, and a log of +every step taken. States and events are any hashable values — ``Enum`` +members read best. +""" + +from __future__ import annotations + +from collections.abc import Callable, Hashable, Mapping +from dataclasses import dataclass +from typing import Generic, TypeVar + +State = TypeVar("State", bound=Hashable) +Event = TypeVar("Event", bound=Hashable) + +Guard = Callable[[], bool] + + +class IllegalTransitionError(Exception): + """The event is not allowed from the current state.""" + + +@dataclass(frozen=True) +class Step(Generic[State, Event]): + """One recorded transition: where the machine was, what moved it, where it went.""" + + source: State + event: Event + target: State + + +class StateMachine(Generic[State, Event]): + """An explicit-table state machine with guards and a transition log.""" + + def __init__( + self, + initial: State, + table: Mapping[tuple[State, Event], State], + guards: Mapping[tuple[State, Event], Guard] | None = None, + ) -> None: + self._state = initial + self._table = dict(table) + self._guards = dict(guards or {}) + self.log: list[Step[State, Event]] = [] + + @property + def state(self) -> State: + return self._state + + def can(self, event: Event) -> bool: + """True if the event is in the table AND its guard (if any) passes.""" + key = (self._state, event) + if key not in self._table: + return False + guard = self._guards.get(key) + return guard() if guard is not None else True + + def trigger(self, event: Event) -> State: + """Fire an event: move to the target state or raise, never half-move.""" + key = (self._state, event) + if key not in self._table: + allowed = sorted(str(e) for s, e in self._table if s == self._state) + raise IllegalTransitionError( + f"{event} is not allowed from {self._state} (allowed: {allowed or 'none'})" + ) + guard = self._guards.get(key) + if guard is not None and not guard(): + raise IllegalTransitionError(f"{event} from {self._state} rejected by its guard") + target = self._table[key] + self.log.append(Step(self._state, event, target)) + self._state = target + return target diff --git a/patterns/behavioral/state/pythonic.py b/patterns/behavioral/state/pythonic.py deleted file mode 100644 index d39c4b6..0000000 --- a/patterns/behavioral/state/pythonic.py +++ /dev/null @@ -1,61 +0,0 @@ -"""Two pythonic state machines. - -1. Enum + transition table: the machine is data, visible in one dict. -2. A generator: the suspension point is the state; send() drives it. -""" - -from __future__ import annotations - -from collections.abc import Generator -from enum import Enum, auto - - -class State(Enum): - LOCKED = auto() - UNLOCKED = auto() - - -#: (state, event) -> (next_state, output) -TRANSITIONS: dict[tuple[State, str], tuple[State, str]] = { - (State.LOCKED, "coin"): (State.UNLOCKED, "unlocked"), - (State.LOCKED, "push"): (State.LOCKED, "locked: push refused"), - (State.UNLOCKED, "coin"): (State.UNLOCKED, "already unlocked: coin returned"), - (State.UNLOCKED, "push"): (State.LOCKED, "pushed through, locking"), -} - - -class Turnstile: - def __init__(self) -> None: - self.state = State.LOCKED - - def handle(self, event: str) -> str: - self.state, output = TRANSITIONS[(self.state, event)] - return output - - -def turnstile_machine() -> Generator[str, str, None]: - """The generator form: 'where the code is paused' is the state.""" - output = "ready" - while True: - event = yield output - if event == "coin": - output = "unlocked" - event = yield output # ---- the UNLOCKED state lives here ---- - while event == "coin": - event = yield "already unlocked: coin returned" - output = "pushed through, locking" - else: - output = "locked: push refused" - - -def main() -> None: - machine = Turnstile() - print([machine.handle(e) for e in ("push", "coin", "push")]) - - gen = turnstile_machine() - next(gen) - print([gen.send(e) for e in ("push", "coin", "push")]) - - -if __name__ == "__main__": - main() diff --git a/patterns/behavioral/state/real_world.py b/patterns/behavioral/state/real_world.py deleted file mode 100644 index d34460f..0000000 --- a/patterns/behavioral/state/real_world.py +++ /dev/null @@ -1,31 +0,0 @@ -"""Generators as protocol scanners: frame suspension holds the state. - -A scanner for BEGIN/END blocks -- no state flag anywhere; being inside the -``while`` loop IS the "in a block" state. -""" - -from __future__ import annotations - -from collections.abc import Iterable, Iterator - - -def blocks(lines: Iterable[str]) -> Iterator[list[str]]: - """Yield the lines between each BEGIN/END pair.""" - it = iter(lines) - for line in it: - if line == "BEGIN": - collected: list[str] = [] - for inner in it: # <- the machine is now in the "collecting" state - if inner == "END": - break - collected.append(inner) - yield collected - - -def main() -> None: - text = ["noise", "BEGIN", "a", "b", "END", "more noise", "BEGIN", "c", "END"] - print(list(blocks(text))) - - -if __name__ == "__main__": - main() diff --git a/patterns/behavioral/state/tests/test_machine.py b/patterns/behavioral/state/tests/test_machine.py new file mode 100644 index 0000000..ee68087 --- /dev/null +++ b/patterns/behavioral/state/tests/test_machine.py @@ -0,0 +1,77 @@ +"""Behavioral tests for the State pattern's StateMachine.""" + +from __future__ import annotations + +from enum import Enum, auto + +import pytest + +from patterns.behavioral.state import IllegalTransitionError, StateMachine, Step + + +class Phase(Enum): + IDLE = auto() + RUNNING = auto() + DONE = auto() + + +TABLE = { + (Phase.IDLE, "start"): Phase.RUNNING, + (Phase.RUNNING, "finish"): Phase.DONE, + (Phase.RUNNING, "abort"): Phase.IDLE, +} + + +class TestTransitions: + def test_a_listed_event_moves_the_machine_and_returns_the_target(self) -> None: + machine = StateMachine(Phase.IDLE, TABLE) + assert machine.trigger("start") is Phase.RUNNING + assert machine.state is Phase.RUNNING + + def test_an_unlisted_event_raises_and_names_the_allowed_ones(self) -> None: + machine = StateMachine(Phase.IDLE, TABLE) + with pytest.raises(IllegalTransitionError, match="start"): + machine.trigger("finish") + assert machine.state is Phase.IDLE # never half-moves + + def test_can_reports_the_table_without_moving(self) -> None: + machine = StateMachine(Phase.RUNNING, TABLE) + assert machine.can("finish") + assert machine.can("abort") + assert not machine.can("start") + assert machine.state is Phase.RUNNING + + +class TestGuards: + def test_a_failing_guard_vetoes_a_listed_transition(self) -> None: + armed = False + machine = StateMachine(Phase.IDLE, TABLE, guards={(Phase.IDLE, "start"): lambda: armed}) + with pytest.raises(IllegalTransitionError, match="guard"): + machine.trigger("start") + + def test_a_passing_guard_lets_the_transition_through(self) -> None: + machine = StateMachine(Phase.IDLE, TABLE, guards={(Phase.IDLE, "start"): lambda: True}) + assert machine.trigger("start") is Phase.RUNNING + + def test_can_consults_the_guard_too(self) -> None: + machine = StateMachine(Phase.IDLE, TABLE, guards={(Phase.IDLE, "start"): lambda: False}) + assert not machine.can("start") + + +class TestLog: + def test_every_transition_is_recorded_in_order(self) -> None: + machine = StateMachine(Phase.IDLE, TABLE) + machine.trigger("start") + machine.trigger("abort") + machine.trigger("start") + assert machine.log == [ + Step(Phase.IDLE, "start", Phase.RUNNING), + Step(Phase.RUNNING, "abort", Phase.IDLE), + Step(Phase.IDLE, "start", Phase.RUNNING), + ] + + def test_refused_transitions_leave_no_log_entry(self) -> None: + machine = StateMachine(Phase.IDLE, TABLE) + with pytest.raises(IllegalTransitionError): + machine.trigger("finish") + assert machine.log == [] diff --git a/patterns/behavioral/state/tests/test_order_lifecycle.py b/patterns/behavioral/state/tests/test_order_lifecycle.py new file mode 100644 index 0000000..e4575f5 --- /dev/null +++ b/patterns/behavioral/state/tests/test_order_lifecycle.py @@ -0,0 +1,102 @@ +"""Behavioral tests for the order-lifecycle mini-project.""" + +from __future__ import annotations + +import pytest + +from patterns.behavioral.state import IllegalTransitionError +from patterns.behavioral.state.examples.order_lifecycle import ( + Order, + OrderAction, + OrderStatus, + build_lifecycle, +) +from patterns.behavioral.state.examples.order_lifecycle.__main__ import main + + +def order_with_items() -> Order: + return Order("O-1", total=100.0, items=["book"]) + + +class TestHappyPath: + def test_place_pay_ship_deliver(self) -> None: + order = order_with_items() + lifecycle = build_lifecycle(order) + lifecycle.trigger(OrderAction.PLACE) + lifecycle.trigger(OrderAction.PAY) + order.amount_paid = order.total + lifecycle.trigger(OrderAction.SHIP) + assert lifecycle.trigger(OrderAction.DELIVER) is OrderStatus.DELIVERED + + def test_the_audit_log_tells_the_whole_story(self) -> None: + order = order_with_items() + lifecycle = build_lifecycle(order) + lifecycle.trigger(OrderAction.PLACE) + lifecycle.trigger(OrderAction.CANCEL) + assert [(s.source, s.event, s.target) for s in lifecycle.log] == [ + (OrderStatus.CART, OrderAction.PLACE, OrderStatus.PLACED), + (OrderStatus.PLACED, OrderAction.CANCEL, OrderStatus.CANCELLED), + ] + + +class TestShapeRules: + def test_cancel_after_shipment_is_not_a_thing(self) -> None: + order = order_with_items() + lifecycle = build_lifecycle(order) + lifecycle.trigger(OrderAction.PLACE) + lifecycle.trigger(OrderAction.PAY) + order.amount_paid = order.total + lifecycle.trigger(OrderAction.SHIP) + with pytest.raises(IllegalTransitionError): + lifecycle.trigger(OrderAction.CANCEL) + assert lifecycle.state is OrderStatus.SHIPPED + + def test_shipping_an_unpaid_order_is_not_a_thing(self) -> None: + lifecycle = build_lifecycle(order_with_items()) + lifecycle.trigger(OrderAction.PLACE) + with pytest.raises(IllegalTransitionError): + lifecycle.trigger(OrderAction.SHIP) + + +class TestDataGuards: + def test_an_empty_cart_cannot_be_placed(self) -> None: + empty = Order("O-2", total=0.0) + lifecycle = build_lifecycle(empty) + with pytest.raises(IllegalTransitionError, match="guard"): + lifecycle.trigger(OrderAction.PLACE) + + def test_a_paid_order_can_still_cancel(self) -> None: + lifecycle = build_lifecycle(order_with_items()) + lifecycle.trigger(OrderAction.PLACE) + lifecycle.trigger(OrderAction.PAY) + assert lifecycle.trigger(OrderAction.CANCEL) is OrderStatus.CANCELLED + + def test_delivered_order_refund_exists_and_is_guarded(self) -> None: + order = order_with_items() + lifecycle = build_lifecycle(order) + for action in (OrderAction.PLACE, OrderAction.PAY, OrderAction.SHIP, OrderAction.DELIVER): + lifecycle.trigger(action) + assert not lifecycle.can(OrderAction.REFUND) # nothing was charged + order.amount_paid = order.total + assert lifecycle.trigger(OrderAction.REFUND) is OrderStatus.REFUNDED + + def test_refund_requires_money_actually_taken(self) -> None: + order = order_with_items() + lifecycle = build_lifecycle(order) + lifecycle.trigger(OrderAction.PLACE) + lifecycle.trigger(OrderAction.PAY) # status moves, but no money landed + assert not lifecycle.can(OrderAction.REFUND) + order.amount_paid = order.total + assert lifecycle.can(OrderAction.REFUND) + assert lifecycle.trigger(OrderAction.REFUND) is OrderStatus.REFUNDED + + +class TestDemo: + def test_main_shows_refusal_delivery_and_audit( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + main() + out = capsys.readouterr().out + assert "refused:" in out + assert "final status: DELIVERED" in out + assert "CART --PLACE--> PLACED" in out diff --git a/patterns/behavioral/state/tests/test_state.py b/patterns/behavioral/state/tests/test_state.py deleted file mode 100644 index fead77e..0000000 --- a/patterns/behavioral/state/tests/test_state.py +++ /dev/null @@ -1,43 +0,0 @@ -"""Behavioral tests for all three state variants.""" - -from patterns.behavioral.state import naive, pythonic, real_world - - -class TestNaive: - def test_full_cycle(self) -> None: - turnstile = naive.Turnstile() - assert turnstile.push() == "locked: push refused" - assert turnstile.coin() == "unlocked" - assert turnstile.coin() == "already unlocked: coin returned" - assert turnstile.push() == "pushed through, locking" - assert turnstile.push() == "locked: push refused" - - -class TestPythonic: - def test_table_machine_matches_naive(self) -> None: - machine = pythonic.Turnstile() - outputs = [machine.handle(e) for e in ("push", "coin", "coin", "push", "push")] - assert outputs == [ - "locked: push refused", - "unlocked", - "already unlocked: coin returned", - "pushed through, locking", - "locked: push refused", - ] - - def test_generator_machine(self) -> None: - gen = pythonic.turnstile_machine() - assert next(gen) == "ready" - assert gen.send("push") == "locked: push refused" - assert gen.send("coin") == "unlocked" - assert gen.send("coin") == "already unlocked: coin returned" - assert gen.send("push") == "pushed through, locking" - - -class TestRealWorld: - def test_scanner_extracts_blocks(self) -> None: - text = ["x", "BEGIN", "a", "b", "END", "y", "BEGIN", "c", "END"] - assert list(real_world.blocks(text)) == [["a", "b"], ["c"]] - - def test_unterminated_block_yields_partial(self) -> None: - assert list(real_world.blocks(["BEGIN", "a"])) == [["a"]] diff --git a/patterns/behavioral/strategy/README.md b/patterns/behavioral/strategy/README.md index 6e163a6..742fa3e 100644 --- a/patterns/behavioral/strategy/README.md +++ b/patterns/behavioral/strategy/README.md @@ -14,32 +14,17 @@ stdlib_sightings: [sorted, list.sort, functools.cmp_to_key] # Strategy -## Problem - -A checkout applies one of several promotion rules; a sorter orders by one of -several keys. The algorithm must vary independently of the code that uses it. - -## Naive solution - -`naive.py` is the book's shape: a `Promotion` interface, one class per -algorithm, and a context object holding the chosen strategy. (Fluent Python -fans will recognize the running example.) - -## Pythonic solution - -Functions *are* strategies. `pythonic.py` passes plain functions, and adds the -decorator-registry twist: `@promotion` collects every rule into a list so -`best_promo` can try them all — new rules register themselves by existing. -This also fixes the legacy repo's bug, where a misplaced `return` inside the -loop made `bulk_item` score only the first cart line. - -## In the wild - -`sorted(data, key=...)` is the Strategy pattern as an argument: the key -function is an interchangeable ordering algorithm, and `functools.cmp_to_key` -adapts old-style comparator strategies into key strategies. - -## Verdict - -**Prefer an alternative** — the alternative being a plain function. The -pattern's *intent* is everywhere in Python; the class ceremony almost never is. +Swap the algorithm without touching the caller. **Verdict: prefer an +alternative** — in Python a strategy is a function passed as an argument; +the registry below is for families that grow. + +| Where | What | +|---|---| +| [`pattern/`](pattern/) | The importable code: `StrategyRegistry`, `UnknownStrategyError` | +| [`docs/`](docs/) | [Fundamentals](docs/fundamentals.md) · [Implementation guide](docs/implementation.md) · [External examples](docs/examples.md) | +| [`examples/promotions/`](examples/promotions/) | Mini-project: checkout pricing rules built on `pattern/` | +| [`tests/`](tests/) | Behavioral tests for the pattern and the mini-project | + +```bash +uv run python -m patterns.behavioral.strategy.examples.promotions +``` diff --git a/patterns/behavioral/strategy/__init__.py b/patterns/behavioral/strategy/__init__.py index 5989b89..1758720 100644 --- a/patterns/behavioral/strategy/__init__.py +++ b/patterns/behavioral/strategy/__init__.py @@ -1 +1,8 @@ -"""Strategy: interchangeable algorithms. Verdict: pass a function.""" +"""Strategy — public API. + +>>> from patterns.behavioral.strategy import StrategyRegistry +""" + +from patterns.behavioral.strategy.pattern import StrategyRegistry, UnknownStrategyError + +__all__ = ["StrategyRegistry", "UnknownStrategyError"] diff --git a/patterns/behavioral/strategy/docs/examples.md b/patterns/behavioral/strategy/docs/examples.md new file mode 100644 index 0000000..5e87533 --- /dev/null +++ b/patterns/behavioral/strategy/docs/examples.md @@ -0,0 +1,38 @@ +# Strategy — where it lives outside this repo + +Cited, real implementations to study (or point an agent at) when designing or +reviewing strategy-shaped code. + +## Python standard library + +- **`sorted(key=...)` / `list.sort`.** The key function is an interchangeable + ordering algorithm passed as an argument — the pattern with zero ceremony. + `functools.cmp_to_key` adapts old-style comparator strategies into key + strategies. + [docs.python.org/3/howto/sorting.html](https://docs.python.org/3/howto/sorting.html) +- **`logging.Formatter`.** A formatting strategy injected into handlers; + swapping output formats is constructing a different formatter, not + subclassing the handler. + [docs.python.org/3/library/logging.html#formatter-objects](https://docs.python.org/3/library/logging.html#formatter-objects) + +## Major ecosystems + +- **requests custom authentication.** Anything callable can be passed as + `auth=`; `AuthBase` subclasses are strategy objects attached per-request — + the "strategy carries state" case done right. + [requests.readthedocs.io/en/latest/user/advanced/#custom-authentication](https://requests.readthedocs.io/en/latest/user/advanced/#custom-authentication) +- **Django password hashers.** `PASSWORD_HASHERS` is a configured, ordered + family of hashing algorithms; verification tries them by preference and + upgrades stored hashes — a registry of strategies plus a selection policy. + [docs.djangoproject.com/en/stable/topics/auth/passwords/](https://docs.djangoproject.com/en/stable/topics/auth/passwords/) +- **Fluent Python's strategy→function refactor (Ramalho).** The canonical + written account of the class-hierarchy-to-functions collapse; this unit's + promotions example descends from it. + +## What to notice across all of them + +None of these define a `Strategy` interface with one method — the signature +*is* the interface. And each pairs the family with an explicit **selection +policy** (first match, best score, configured order): when reviewing +strategy code, find where selection happens and check it is deliberate and +tested, not an accident of iteration order. diff --git a/patterns/behavioral/strategy/docs/fundamentals.md b/patterns/behavioral/strategy/docs/fundamentals.md new file mode 100644 index 0000000..793271d --- /dev/null +++ b/patterns/behavioral/strategy/docs/fundamentals.md @@ -0,0 +1,76 @@ +# Strategy — fundamentals + +## Intent + +Define a family of algorithms, encapsulate each one, and make them +interchangeable — so the algorithm can vary independently of the code that +uses it. A checkout applies one of several promotion rules; a sorter orders by +one of several keys; the caller neither knows nor cares which variant it got. + +## Participants + +| Role | Classic (GoF) form | Python form | +|---|---|---| +| Strategy contract | An interface with one method | Any callable `(argument) -> result` — a type alias documents it | +| Concrete strategies | One class per algorithm | Plain functions | +| Context | An object holding a strategy reference | An argument: `sorted(data, key=...)` | +| Open families | Manual wiring in the client | A registry — `StrategyRegistry` in [`pattern/registry.py`](../pattern/registry.py) | + +## Mechanism + +1. Name the strategy signature (what goes in, what comes out). +2. Write each algorithm to that signature. +3. Pass the chosen one where the work happens — or register the whole family + so callers can look one up, run them all, or take the best. + +## The classic form, and what Python absorbs + +The textbook implementation builds a class hierarchy because 1994 languages +had no first-class functions: + +```python +class Promotion(ABC): + """The strategy interface.""" + + @abstractmethod + def discount(self, order: Order) -> float: ... + + +class BulkItemPromo(Promotion): + def discount(self, order: Order) -> float: + return sum(item.total() * 0.1 for item in order.cart if item.quantity >= 20) + + +class LargeOrderPromo(Promotion): ... + + +class Order: # the context, holding one interchangeable strategy + def __init__(self, cart: list[LineItem], promotion: Promotion | None = None) -> None: + self.promotion = promotion +``` + +One interface, one class per algorithm, a context that stores one — all to +pass behavior as a value. Python passes behavior as a value natively: +`sorted(words, key=str.casefold)` **is** the Strategy pattern, in four +characters of ceremony. What survives translation is the *intent* (a named, +swappable family of algorithms behind one signature), not the class diagram. + +## When to use it + +- The same operation has several legitimate algorithms chosen at runtime + (pricing rules, retry policies, sort orders, auth schemes). +- The set of algorithms is open — new ones should slot in without editing the + code that runs them (that is what the registry adds). + +## When not to use it + +- Only one algorithm exists → just write the function. +- The variants differ by *data*, not logic → a parameter or a config value. +- A strategy needs state or several cooperating methods → a class is then the + right form; that is the surviving use of the classic shape. + +## Verdict: prefer an alternative + +The alternative is a plain function passed as an argument. Use +`StrategyRegistry` when the family is open and discoverable-by-name matters; +reach for strategy *classes* only when a strategy owns state of its own. diff --git a/patterns/behavioral/strategy/docs/implementation.md b/patterns/behavioral/strategy/docs/implementation.md new file mode 100644 index 0000000..2fc22ea --- /dev/null +++ b/patterns/behavioral/strategy/docs/implementation.md @@ -0,0 +1,81 @@ +# Strategy — putting it into a system + +## The smell it fixes + +An `if/elif` ladder choosing *behavior*, or a flag argument that swaps +algorithm mid-function: + +```python +def price(order, promo_kind): + if promo_kind == "bulk": + ... + elif promo_kind == "large_order": + ... + elif promo_kind == "loyalty": + ... +``` + +Every new algorithm edits this function, and nothing stops the branches from +drifting apart in signature or behavior. + +## Steps + +1. **Name the signature.** One type alias — e.g. `PromoRule = Callable[[Order], float]` + — is the whole "strategy interface"; `mypy` enforces it from then on. +2. **Extract each branch into a function** with that signature. The branch + condition usually becomes the function's early `return 0.0` (or equivalent + "not applicable" value). +3. **Pass the strategy where the work happens.** For a closed set, a plain + parameter (`sorted(key=...)` style) is finished — stop here. +4. **Register open families.** When rules arrive over time (plugins, pricing, + policies), a `StrategyRegistry` makes joining the family a decorator: + + ```python + from patterns.behavioral.strategy import StrategyRegistry + + promotion: StrategyRegistry[Order, float] = StrategyRegistry() + + + @promotion.register + def loyalty(order: Order) -> float: ... + + + promotion.results(order) # every rule's answer, keyed by name + promotion.get("loyalty") # or one by name — UnknownStrategyError otherwise + ``` + +5. **Make the selection policy explicit and tested.** "Best discount wins" + (`max` over `results()`) is a business rule — pin it with a test, next to + tests for each individual strategy. + +## Python idioms that keep it small + +- **`functools.partial` parameterizes a strategy** without a class: + `partial(percent_off, rate=0.05)` is a new family member from an old recipe. +- **Registration by decoration** puts a rule's membership at its definition + site — the same move Flask routes and `singledispatch` use. +- **A strategy needing state** graduates to a callable object (`__call__`) + and slots into the same registry unchanged. + +## Pitfalls + +- **Module-level registries are import-order state.** A rule registers when + its module is imported; a rule nobody imports silently does not exist. + Import the rules module somewhere deliberate (the package `__init__`). +- **Registries are shared across tests.** Registering test-only strategies + mutates global state — register into a fresh `StrategyRegistry` in tests, + or clean up. +- **Signature drift.** The alias only protects call sites that use it; + annotate every strategy with the alias's exact shape. +- **Comparing incomparable results.** `best`-style selection needs an + ordering; keep strategy outputs plain (floats, tuples) or supply a key. + +## Worked example + +[`examples/promotions/`](../examples/promotions/) applies every step above to +checkout pricing — three registered rules, a best-rule engine, and a +comparison report: + +```bash +uv run python -m patterns.behavioral.strategy.examples.promotions +``` diff --git a/patterns/behavioral/strategy/examples/__init__.py b/patterns/behavioral/strategy/examples/__init__.py new file mode 100644 index 0000000..d1deeba --- /dev/null +++ b/patterns/behavioral/strategy/examples/__init__.py @@ -0,0 +1 @@ +"""Mini-projects demonstrating the Strategy pattern in practice.""" diff --git a/patterns/behavioral/strategy/examples/promotions/__init__.py b/patterns/behavioral/strategy/examples/promotions/__init__.py new file mode 100644 index 0000000..0c6c7c6 --- /dev/null +++ b/patterns/behavioral/strategy/examples/promotions/__init__.py @@ -0,0 +1,13 @@ +"""A promotions engine built on the Strategy pattern. + +Run it: ``uv run python -m patterns.behavioral.strategy.examples.promotions`` +""" + +from patterns.behavioral.strategy.examples.promotions.models import LineItem, Order +from patterns.behavioral.strategy.examples.promotions.rules import ( + best_promo, + due, + promotion, +) + +__all__ = ["LineItem", "Order", "best_promo", "due", "promotion"] diff --git a/patterns/behavioral/strategy/examples/promotions/__main__.py b/patterns/behavioral/strategy/examples/promotions/__main__.py new file mode 100644 index 0000000..b7c6722 --- /dev/null +++ b/patterns/behavioral/strategy/examples/promotions/__main__.py @@ -0,0 +1,25 @@ +"""Demo: three carts compared under every registered pricing rule.""" + +from __future__ import annotations + +from patterns.behavioral.strategy.examples.promotions.models import LineItem, Order +from patterns.behavioral.strategy.examples.promotions.rules import best_promo, due, promotion + + +def main() -> None: + carts = { + "bulk banana buyer": Order((LineItem("banana", 30, 0.5), LineItem("apple", 10, 1.5))), + "variety shopper": Order(tuple(LineItem(f"item-{n}", 1, 1.0) for n in range(10))), + "loyal regular": Order((LineItem("coffee", 2, 9.0),), loyalty_points=1500), + } + for label, order in carts.items(): + results = promotion.results(order) + winner, _ = best_promo(order) + columns = " ".join(f"{name}: {value:5.2f}" for name, value in results.items()) + print( + f"{label:18} total {order.total():6.2f} {columns} -> {winner}, pay {due(order):.2f}" + ) + + +if __name__ == "__main__": + main() diff --git a/patterns/behavioral/strategy/examples/promotions/models.py b/patterns/behavioral/strategy/examples/promotions/models.py new file mode 100644 index 0000000..f94c9ad --- /dev/null +++ b/patterns/behavioral/strategy/examples/promotions/models.py @@ -0,0 +1,26 @@ +"""Domain types for the promotions mini-project.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class LineItem: + product: str + quantity: int + price: float + + def total(self) -> float: + return self.quantity * self.price + + +@dataclass(frozen=True) +class Order: + """A cart at checkout; loyalty points may unlock extra promotions.""" + + cart: tuple[LineItem, ...] + loyalty_points: int = 0 + + def total(self) -> float: + return sum(item.total() for item in self.cart) diff --git a/patterns/behavioral/strategy/examples/promotions/rules.py b/patterns/behavioral/strategy/examples/promotions/rules.py new file mode 100644 index 0000000..976201b --- /dev/null +++ b/patterns/behavioral/strategy/examples/promotions/rules.py @@ -0,0 +1,54 @@ +"""Pricing rules as registered strategies, and the engine that compares them. + +Each rule is a plain function ``(Order) -> float`` (the discount it grants). +Adding a rule is defining one — ``best_promo`` and the comparison report +pick it up with no other edit. +""" + +from __future__ import annotations + +from patterns.behavioral.strategy.examples.promotions.models import Order +from patterns.behavioral.strategy.pattern import StrategyRegistry + +promotion: StrategyRegistry[Order, float] = StrategyRegistry() + + +@promotion.register +def bulk_item(order: Order) -> float: + """10% off each line item of 20+ units.""" + return sum(item.total() * 0.1 for item in order.cart if item.quantity >= 20) + + +@promotion.register +def large_order(order: Order) -> float: + """7% off orders with 10+ distinct products.""" + if len({item.product for item in order.cart}) >= 10: + return order.total() * 0.07 + return 0.0 + + +@promotion.register +def loyalty(order: Order) -> float: + """5% off for customers holding 1000+ loyalty points.""" + if order.loyalty_points >= 1000: + return order.total() * 0.05 + return 0.0 + + +def best_promo( + order: Order, rules: StrategyRegistry[Order, float] | None = None +) -> tuple[str, float]: + """Compare every registered rule; return the winner's name and discount. + + Ties go to the earliest-registered rule — ``max`` keeps the first of + equals, and the registry iterates in registration order. + """ + results = (rules if rules is not None else promotion).results(order) + name = max(results, key=lambda n: results[n]) + return name, results[name] + + +def due(order: Order, rules: StrategyRegistry[Order, float] | None = None) -> float: + """What the customer pays after the best promotion.""" + _, discount = best_promo(order, rules) + return order.total() - discount diff --git a/patterns/behavioral/strategy/naive.py b/patterns/behavioral/strategy/naive.py deleted file mode 100644 index d1b89e1..0000000 --- a/patterns/behavioral/strategy/naive.py +++ /dev/null @@ -1,67 +0,0 @@ -"""The Gang of Four Strategy: one class per algorithm, a context that holds one. - -An order applies whichever promotion strategy it was configured with. -""" - -from __future__ import annotations - -from abc import ABC, abstractmethod -from dataclasses import dataclass - - -@dataclass(frozen=True) -class LineItem: - product: str - quantity: int - price: float - - def total(self) -> float: - return self.quantity * self.price - - -class Promotion(ABC): - """The strategy interface.""" - - @abstractmethod - def discount(self, order: Order) -> float: ... - - -class Order: - """The context: holds cart plus one interchangeable strategy.""" - - def __init__(self, cart: list[LineItem], promotion: Promotion | None = None) -> None: - self.cart = cart - self.promotion = promotion - - def total(self) -> float: - return sum(item.total() for item in self.cart) - - def due(self) -> float: - discount = self.promotion.discount(self) if self.promotion else 0.0 - return self.total() - discount - - -class BulkItemPromo(Promotion): - """10% off each line item of 20+ units.""" - - def discount(self, order: Order) -> float: - return sum(item.total() * 0.1 for item in order.cart if item.quantity >= 20) - - -class LargeOrderPromo(Promotion): - """7% off orders with 10+ distinct products.""" - - def discount(self, order: Order) -> float: - if len({item.product for item in order.cart}) >= 10: - return order.total() * 0.07 - return 0.0 - - -def main() -> None: - cart = [LineItem("banana", 30, 0.5), LineItem("apple", 10, 1.5)] - print(f"bulk promo due: {Order(cart, BulkItemPromo()).due():.2f}") - print(f"no promo due: {Order(cart).due():.2f}") - - -if __name__ == "__main__": - main() diff --git a/patterns/behavioral/strategy/pattern/__init__.py b/patterns/behavioral/strategy/pattern/__init__.py new file mode 100644 index 0000000..327d105 --- /dev/null +++ b/patterns/behavioral/strategy/pattern/__init__.py @@ -0,0 +1,8 @@ +"""The Strategy pattern, importable as library code.""" + +from patterns.behavioral.strategy.pattern.registry import ( + StrategyRegistry, + UnknownStrategyError, +) + +__all__ = ["StrategyRegistry", "UnknownStrategyError"] diff --git a/patterns/behavioral/strategy/pattern/registry.py b/patterns/behavioral/strategy/pattern/registry.py new file mode 100644 index 0000000..1c08dd6 --- /dev/null +++ b/patterns/behavioral/strategy/pattern/registry.py @@ -0,0 +1,74 @@ +"""Strategy in its Python form: functions registered as interchangeable rules. + +A strategy is any callable ``(argument) -> result``. ``StrategyRegistry`` +collects a family of them — registering is decorating — so callers can pick +one by name, run them all, or compare their results. +""" + +from __future__ import annotations + +from collections.abc import Callable, Iterator +from typing import Generic, TypeVar + +In_ = TypeVar("In_") +Out = TypeVar("Out") + + +class UnknownStrategyError(LookupError): + """No strategy with that name is registered.""" + + +class StrategyRegistry(Generic[In_, Out]): + """A named family of interchangeable algorithms. + + New strategies join by being defined (``@registry.register``) — the code + that *uses* the family never changes. + """ + + def __init__(self) -> None: + self._strategies: dict[str, Callable[[In_], Out]] = {} + + def register( + self, strategy: Callable[[In_], Out], *, replace: bool = False + ) -> Callable[[In_], Out]: + """Add a strategy under its function name; usable as a decorator. + + A duplicate name is an error unless ``replace=True`` — the key is + ``__name__``, so two same-named functions from different modules + collide by accident, and silently dropping a rule is how a discount + stops applying with nothing logged. + """ + name = str(getattr(strategy, "__name__", repr(strategy))) + if name in self._strategies and not replace: + raise ValueError(f"strategy {name!r} already registered (pass replace=True)") + self._strategies[name] = strategy + return strategy + + def unregister(self, name: str) -> None: + """Remove a strategy by name; membership, like order, is policy.""" + try: + del self._strategies[name] + except KeyError: + known = ", ".join(sorted(self._strategies)) or "none" + raise UnknownStrategyError(f"no strategy {name!r} (known: {known})") from None + + def get(self, name: str) -> Callable[[In_], Out]: + """Look one strategy up by name.""" + try: + return self._strategies[name] + except KeyError: + known = ", ".join(sorted(self._strategies)) or "none" + raise UnknownStrategyError(f"no strategy {name!r} (known: {known})") from None + + def names(self) -> list[str]: + return list(self._strategies) + + def results(self, argument: In_) -> dict[str, Out]: + """Run every registered strategy on one argument, keyed by name.""" + return {name: strategy(argument) for name, strategy in self._strategies.items()} + + def __iter__(self) -> Iterator[Callable[[In_], Out]]: + return iter(self._strategies.values()) + + def __len__(self) -> int: + return len(self._strategies) diff --git a/patterns/behavioral/strategy/pythonic.py b/patterns/behavioral/strategy/pythonic.py deleted file mode 100644 index 05df98f..0000000 --- a/patterns/behavioral/strategy/pythonic.py +++ /dev/null @@ -1,74 +0,0 @@ -"""Strategies as plain functions, plus the decorator registry. - -``@promotion`` appends each rule to a module-level list, so ``best_promo`` -always considers every registered rule -- adding a strategy is just defining -one. -""" - -from __future__ import annotations - -from collections.abc import Callable -from dataclasses import dataclass - - -@dataclass(frozen=True) -class LineItem: - product: str - quantity: int - price: float - - def total(self) -> float: - return self.quantity * self.price - - -@dataclass(frozen=True) -class Order: - cart: tuple[LineItem, ...] - - def total(self) -> float: - return sum(item.total() for item in self.cart) - - -PromoFunc = Callable[[Order], float] - -promos: list[PromoFunc] = [] - - -def promotion(func: PromoFunc) -> PromoFunc: - """Register a promotion strategy by decorating it.""" - promos.append(func) - return func - - -@promotion -def bulk_item(order: Order) -> float: - """10% off each line item of 20+ units.""" - return sum(item.total() * 0.1 for item in order.cart if item.quantity >= 20) - - -@promotion -def large_order(order: Order) -> float: - """7% off orders with 10+ distinct products.""" - if len({item.product for item in order.cart}) >= 10: - return order.total() * 0.07 - return 0.0 - - -def best_promo(order: Order) -> float: - """Try every registered strategy; keep the best discount.""" - return max(promo(order) for promo in promos) - - -def due(order: Order, promo: PromoFunc | None = None) -> float: - """A strategy is just an argument.""" - return order.total() - (promo(order) if promo else 0.0) - - -def main() -> None: - order = Order((LineItem("banana", 30, 0.5), LineItem("apple", 10, 1.5))) - print(f"bulk_item due: {due(order, bulk_item):.2f}") - print(f"best promo: {best_promo(order):.2f}") - - -if __name__ == "__main__": - main() diff --git a/patterns/behavioral/strategy/real_world.py b/patterns/behavioral/strategy/real_world.py deleted file mode 100644 index 6f6cc58..0000000 --- a/patterns/behavioral/strategy/real_world.py +++ /dev/null @@ -1,30 +0,0 @@ -"""``sorted(key=...)``: the Strategy pattern as an argument. - -The key function is an interchangeable ordering algorithm; swapping -strategies is passing a different callable. -""" - -from __future__ import annotations - - -def by_length(words: list[str]) -> list[str]: - return sorted(words, key=len) - - -def by_last_letter(words: list[str]) -> list[str]: - return sorted(words, key=lambda w: w[-1]) - - -def case_insensitive(words: list[str]) -> list[str]: - return sorted(words, key=str.casefold) - - -def main() -> None: - words = ["banana", "Fig", "cherry"] - print(f"by length: {by_length(words)}") - print(f"by last letter: {by_last_letter(words)}") - print(f"case-insensitive: {case_insensitive(words)}") - - -if __name__ == "__main__": - main() diff --git a/patterns/behavioral/strategy/tests/test_promotions.py b/patterns/behavioral/strategy/tests/test_promotions.py new file mode 100644 index 0000000..30cc2f4 --- /dev/null +++ b/patterns/behavioral/strategy/tests/test_promotions.py @@ -0,0 +1,88 @@ +"""Behavioral tests for the promotions mini-project.""" + +from __future__ import annotations + +import pytest + +from patterns.behavioral.strategy.examples.promotions import ( + LineItem, + Order, + best_promo, + due, + promotion, +) +from patterns.behavioral.strategy.examples.promotions.__main__ import main +from patterns.behavioral.strategy.pattern import StrategyRegistry + + +def bulk_cart() -> Order: + return Order((LineItem("banana", 30, 0.5), LineItem("apple", 10, 1.5))) + + +class TestIndividualRules: + def test_bulk_item_discounts_only_the_bulky_lines(self) -> None: + order = bulk_cart() # 30 bananas qualify (15.00), 10 apples do not + assert promotion.get("bulk_item")(order) == 1.5 + + def test_large_order_needs_ten_distinct_products(self) -> None: + nine = Order(tuple(LineItem(f"p{n}", 1, 1.0) for n in range(9))) + ten = Order(tuple(LineItem(f"p{n}", 1, 1.0) for n in range(10))) + assert promotion.get("large_order")(nine) == 0.0 + assert promotion.get("large_order")(ten) == pytest.approx(0.7) + + def test_loyalty_needs_a_thousand_points(self) -> None: + casual = Order((LineItem("coffee", 2, 9.0),), loyalty_points=999) + regular = Order((LineItem("coffee", 2, 9.0),), loyalty_points=1000) + assert promotion.get("loyalty")(casual) == 0.0 + assert promotion.get("loyalty")(regular) == 0.9 + + +class TestSelectionPolicy: + def test_best_promo_names_the_winning_rule(self) -> None: + name, discount = best_promo(bulk_cart()) + assert name == "bulk_item" + assert discount == 1.5 + + def test_due_charges_total_minus_best_discount(self) -> None: + order = bulk_cart() # total 30.00, best discount 1.50 + assert due(order) == 28.5 + + def test_a_rule_added_at_runtime_joins_the_comparison(self) -> None: + # A local registry: the module-global one stays untouched by tests. + local: StrategyRegistry[Order, float] = StrategyRegistry() + for rule in promotion: + local.register(rule) + + @local.register + def everything_free(order: Order) -> float: + return order.total() + + name, _ = best_promo(bulk_cart(), local) + assert name == "everything_free" + assert "everything_free" not in promotion.names() + + def test_ties_go_to_the_earliest_registered_rule(self) -> None: + local: StrategyRegistry[Order, float] = StrategyRegistry() + + def first(order: Order) -> float: + return 1.0 + + def second(order: Order) -> float: + return 1.0 + + local.register(first) + local.register(second) + name, discount = best_promo(bulk_cart(), local) + assert (name, discount) == ("first", 1.0) + + +class TestDemo: + def test_demo_reports_every_cart_with_its_winner( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + main() + out = capsys.readouterr().out + assert "bulk banana buyer" in out + assert "-> bulk_item" in out + assert "-> large_order" in out + assert "-> loyalty" in out diff --git a/patterns/behavioral/strategy/tests/test_registry.py b/patterns/behavioral/strategy/tests/test_registry.py new file mode 100644 index 0000000..1ac97fc --- /dev/null +++ b/patterns/behavioral/strategy/tests/test_registry.py @@ -0,0 +1,117 @@ +"""Behavioral tests for the StrategyRegistry building block.""" + +from __future__ import annotations + +import pytest + +from patterns.behavioral.strategy import StrategyRegistry, UnknownStrategyError + + +def make_registry() -> StrategyRegistry[int, int]: + registry: StrategyRegistry[int, int] = StrategyRegistry() + + @registry.register + def double(n: int) -> int: + return n * 2 + + @registry.register + def square(n: int) -> int: + return n * n + + return registry + + +class TestRegistration: + def test_registering_is_decorating_and_keeps_the_function_usable(self) -> None: + registry: StrategyRegistry[int, int] = StrategyRegistry() + + @registry.register + def negate(n: int) -> int: + return -n + + assert negate(3) == -3 # the decorator hands the function back + assert registry.names() == ["negate"] + + def test_len_and_iteration_expose_the_family(self) -> None: + registry = make_registry() + assert len(registry) == 2 + assert [strategy(3) for strategy in registry] == [6, 9] + + def test_duplicate_name_is_refused(self) -> None: + registry = make_registry() + with pytest.raises(ValueError, match="already registered"): + + @registry.register + def double(n: int) -> int: # same __name__ as an existing rule + return n + n + + assert registry.get("double")(3) == 6 # the original survives + + def test_accidental_same_name_collision_from_helpers_is_caught(self) -> None: + # Two factories both produce a function named "promo" — the classic + # accidental collision the name-keying invites. + registry: StrategyRegistry[int, int] = StrategyRegistry() + + def make_promo_a() -> None: + @registry.register + def promo(n: int) -> int: + return n - 1 + + def make_promo_b() -> None: + @registry.register + def promo(n: int) -> int: + return n + 1 + + make_promo_a() + with pytest.raises(ValueError, match="'promo' already registered"): + make_promo_b() + + def test_replace_swaps_a_strategy_intentionally(self) -> None: + registry = make_registry() + + def double(n: int) -> int: + return n + n + n # deliberately different behavior + + registry.register(double, replace=True) + assert registry.get("double")(3) == 9 + + def test_unregister_unknown_name_raises(self) -> None: + registry = make_registry() + with pytest.raises(UnknownStrategyError, match="no strategy 'cube'"): + registry.unregister("cube") + + def test_names_keep_registration_order(self) -> None: + registry: StrategyRegistry[int, int] = StrategyRegistry() + + def zeta(n: int) -> int: + return n + + def alpha(n: int) -> int: + return n + + registry.register(zeta) + registry.register(alpha) + assert registry.names() == ["zeta", "alpha"] # insertion, not sorted + + +class TestLookup: + def test_get_returns_the_named_strategy(self) -> None: + registry = make_registry() + assert registry.get("square")(4) == 16 + + def test_unknown_name_raises_with_the_known_names(self) -> None: + registry = make_registry() + with pytest.raises(UnknownStrategyError, match="double, square"): + registry.get("cube") + + +class TestResults: + def test_results_runs_every_strategy_keyed_by_name(self) -> None: + registry = make_registry() + assert registry.results(3) == {"double": 6, "square": 9} + + def test_independent_registries_do_not_share_strategies(self) -> None: + first = make_registry() + second: StrategyRegistry[int, int] = StrategyRegistry() + assert len(first) == 2 + assert len(second) == 0 diff --git a/patterns/behavioral/strategy/tests/test_strategy.py b/patterns/behavioral/strategy/tests/test_strategy.py deleted file mode 100644 index f8ffa53..0000000 --- a/patterns/behavioral/strategy/tests/test_strategy.py +++ /dev/null @@ -1,48 +0,0 @@ -"""Behavioral tests for all three strategy variants.""" - -from patterns.behavioral.strategy import naive, pythonic, real_world - - -def _cart() -> list[naive.LineItem]: - return [naive.LineItem("banana", 30, 0.5), naive.LineItem("apple", 10, 1.5)] - - -class TestNaive: - def test_bulk_promo_discounts_every_qualifying_line(self) -> None: - # 30 bananas -> 15.00 total -> 1.50 off; apples don't qualify. - order = naive.Order(_cart(), naive.BulkItemPromo()) - assert order.due() == 30.0 - 1.5 - - def test_swapping_strategy_changes_result(self) -> None: - cart = _cart() - assert naive.Order(cart, naive.LargeOrderPromo()).due() == 30.0 # <10 products - assert naive.Order(cart).due() == 30.0 - - def test_regression_all_lines_counted(self) -> None: - # The legacy repo returned inside the loop, scoring only the first line. - cart = [naive.LineItem("a", 20, 1.0), naive.LineItem("b", 20, 2.0)] - assert naive.BulkItemPromo().discount(naive.Order(cart)) == 2.0 + 4.0 - - -class TestPythonic: - def _order(self) -> pythonic.Order: - return pythonic.Order( - (pythonic.LineItem("banana", 30, 0.5), pythonic.LineItem("apple", 10, 1.5)) - ) - - def test_function_is_the_strategy(self) -> None: - assert pythonic.due(self._order(), pythonic.bulk_item) == 30.0 - 1.5 - - def test_decorator_registered_all_strategies(self) -> None: - assert pythonic.bulk_item in pythonic.promos - assert pythonic.large_order in pythonic.promos - - def test_best_promo_picks_the_maximum(self) -> None: - assert pythonic.best_promo(self._order()) == 1.5 - - -class TestRealWorld: - def test_key_functions_are_swappable_strategies(self) -> None: - words = ["banana", "Fig", "cherry"] - assert real_world.by_length(words) == ["Fig", "banana", "cherry"] - assert real_world.case_insensitive(words) == ["banana", "cherry", "Fig"] diff --git a/patterns/behavioral/template_method/README.md b/patterns/behavioral/template_method/README.md index bdbbd55..d623d8d 100644 --- a/patterns/behavioral/template_method/README.md +++ b/patterns/behavioral/template_method/README.md @@ -14,29 +14,17 @@ stdlib_sightings: [json.JSONEncoder.default, unittest.TestCase.setUp, socketserv # Template Method -## Problem - -Report generation always goes fetch → format → deliver, but each report -formats differently. The skeleton must stay fixed while steps vary. - -## Naive solution - -`naive.py` is the GoF form: the base class owns the skeleton as a concrete -method; subclasses override the abstract hook steps. - -## Pythonic solution - -The skeleton is a function; the varying steps are callable parameters with -defaults. No subclass per variation, and steps combine freely at call time. - -## In the wild - -`json.JSONEncoder` runs the encoding skeleton and calls your `default()` -hook for objects it can't serialize — a template method you've probably -already overridden. `unittest.TestCase.setUp`/`tearDown` and -`socketserver.BaseRequestHandler.handle` are the same shape. - -## Verdict - -**Prefer an alternative** in your own code — pass the steps. Recognize and -use the subclass form at framework boundaries. +Fix the algorithm's spine, vary its steps. **Verdict: prefer an alternative** +— pass the steps as callables; subclass hooks belong at framework boundaries +that hand them to you. + +| Where | What | +|---|---| +| [`pattern/`](pattern/) | The importable code: `Skeleton`, `keep_all`, `discard` | +| [`docs/`](docs/) | [Fundamentals](docs/fundamentals.md) · [Implementation guide](docs/implementation.md) · [External examples](docs/examples.md) | +| [`examples/report_pipeline/`](examples/report_pipeline/) | Mini-project: sales reports built on `pattern/` | +| [`tests/`](tests/) | Behavioral tests for the pattern and the mini-project | + +```bash +uv run python -m patterns.behavioral.template_method.examples.report_pipeline +``` diff --git a/patterns/behavioral/template_method/__init__.py b/patterns/behavioral/template_method/__init__.py index e928fcb..94dcc5a 100644 --- a/patterns/behavioral/template_method/__init__.py +++ b/patterns/behavioral/template_method/__init__.py @@ -1 +1,8 @@ -"""Template Method: fixed skeleton, variable steps. Verdict: pass the steps.""" +"""Template Method — public API. + +>>> from patterns.behavioral.template_method import Skeleton +""" + +from patterns.behavioral.template_method.pattern import Skeleton, discard, keep_all + +__all__ = ["Skeleton", "discard", "keep_all"] diff --git a/patterns/behavioral/template_method/docs/examples.md b/patterns/behavioral/template_method/docs/examples.md new file mode 100644 index 0000000..83de425 --- /dev/null +++ b/patterns/behavioral/template_method/docs/examples.md @@ -0,0 +1,38 @@ +# Template Method — where it lives outside this repo + +Cited, real implementations to study (or point an agent at) when designing or +reviewing template-shaped code. + +## Python standard library + +- **`json.JSONEncoder.default`.** `encode()` owns the encoding skeleton and + calls your `default()` hook exactly at the step it cannot handle — the + template method most Python developers have already overridden. + [docs.python.org/3/library/json.html#json.JSONEncoder.default](https://docs.python.org/3/library/json.html#json.JSONEncoder.default) +- **`unittest.TestCase.setUp` / `tearDown`.** The runner owns the fixed run + loop (setUp → test → tearDown, with error policy); you own the hooks. + [docs.python.org/3/library/unittest.html#unittest.TestCase.setUp](https://docs.python.org/3/library/unittest.html#unittest.TestCase.setUp) +- **`socketserver.BaseRequestHandler.handle`.** Accept loop, request + lifecycle, and cleanup are fixed by the framework; `handle()` is the one + step handed to you. + [docs.python.org/3/library/socketserver.html](https://docs.python.org/3/library/socketserver.html) + +## Major ecosystems + +- **Django class-based views.** The request pipeline (`dispatch` → handler → + response) is fixed; `get_queryset`, `get_context_data` and friends are the + named hooks — the subclass form at a true framework boundary. + [docs.djangoproject.com/en/stable/topics/class-based-views/](https://docs.djangoproject.com/en/stable/topics/class-based-views/) +- **Scrapy spiders.** The crawl loop, scheduling, and retries belong to the + framework; `parse()` is your extraction step. + [docs.scrapy.org](https://docs.scrapy.org/) + +## What to notice across all of them + +Every citation above is a *framework* boundary: the code that owns the loop +and the code that owns a step are maintained by different people — that +asymmetry is what justifies subclass hooks. Inside one codebase that +asymmetry is absent, and passing callables (this unit's `Skeleton`) gives the +same fixed spine with composition instead of a class per variant. When +reviewing, ask who owns the loop: someone else → hooks are fine; you → pass +the steps. diff --git a/patterns/behavioral/template_method/docs/fundamentals.md b/patterns/behavioral/template_method/docs/fundamentals.md new file mode 100644 index 0000000..b475acf --- /dev/null +++ b/patterns/behavioral/template_method/docs/fundamentals.md @@ -0,0 +1,81 @@ +# Template Method — fundamentals + +## Intent + +Fix an algorithm's skeleton — the order and number of its steps — while +letting individual steps vary. Report generation always goes fetch → +transform → render → deliver; only the details of each step change per +report. + +## Participants + +| Role | Classic (GoF) form | Python form | +|---|---|---| +| Skeleton owner | Abstract base class; the template method is concrete | A function or a frozen dataclass's `run` — `Skeleton` in [`pattern/skeleton.py`](../pattern/skeleton.py) | +| Variable steps | Abstract "primitive operations" overridden in subclasses | Callable parameters / fields with sensible defaults | +| Variants | One subclass per combination of steps | One *value* per combination — steps compose at call time | + +## Mechanism + +1. The skeleton calls its steps in a fixed order; nobody overrides the spine. +2. Each step is a hook: the classic form binds hooks by inheritance, the + Python form binds them by passing callables. +3. A new variant is a new combination of steps — `with_steps(render=...)` — + not a new class. + +## The classic form, and what Python absorbs + +The textbook implementation puts the spine in a base class and each variable +step behind an abstract method: + +```python +class Report(ABC): + def render(self, data: dict[str, int]) -> str: + """The template method: the skeleton nobody overrides.""" + rows = self.format_rows(data) + return f"{self.header()}\n{rows}" + + @abstractmethod + def header(self) -> str: ... + + @abstractmethod + def format_rows(self, data: dict[str, int]) -> str: ... + + +class TextReport(Report): ... # one subclass + + +class CsvReport(Report): ... # per combination of steps +``` + +Inheritance is doing one job here: passing functions to a function. Python +passes functions directly, so the same design collapses to callable +parameters — and combinations that would each need a subclass become call +sites. What survives is the discipline: **the spine is fixed and owns the +order; the steps are named, typed seams.** + +The subclass form is not dead — it survives at *framework boundaries*, where +the framework owns the loop and hands you the hook: `unittest.TestCase.setUp`, +`socketserver.BaseRequestHandler.handle`, `json.JSONEncoder.default`. +Recognize it there; don't build it for your own code. + +## When to use it + +- Several procedures share an invariant step order but differ in step details + (ETL jobs, report generation, request pipelines). +- You want the *spine* to be the single audited place where ordering, + error-handling, and logging live. + +## When not to use it + +- Steps don't share a fixed order → that's composition of functions, not a + template. +- Only one variant exists → write the plain function; extract seams when the + second variant arrives. +- Variants need to change the *spine* → the skeleton is the wrong boundary; + split it. + +## Verdict: prefer an alternative + +Pass the steps as callables (what `Skeleton` packages); subclass hooks only +at framework boundaries that hand them to you. diff --git a/patterns/behavioral/template_method/docs/implementation.md b/patterns/behavioral/template_method/docs/implementation.md new file mode 100644 index 0000000..f8f3943 --- /dev/null +++ b/patterns/behavioral/template_method/docs/implementation.md @@ -0,0 +1,73 @@ +# Template Method — putting it into a system + +## The smell it fixes + +Two (then three, then five) near-identical procedures, copy-pasted and +drifting: + +```python +def daily_csv_report(): ... # fetch, clean, format csv, print +def daily_markdown_report(): ... # fetch, clean, format md, print — 90% the same +def weekly_csv_report(): ... # subtle drift: forgot the clean step +``` + +The duplicated spine is where bugs breed — the fix is one spine, many steps. + +## Steps + +1. **Write out the spine once** and name its stages. Four is typical: + acquire, normalize, produce, ship (`Skeleton`'s fetch/transform/render/ + deliver). +2. **Type each seam.** `Callable[[Sales], str]` per step; `mypy` then rejects + a step wired into the wrong slot. +3. **Extract the variants' differing code into step functions** matching the + seams. Identical code stays in the spine. +4. **Assemble variants as values**, deriving from a baseline instead of + repeating yourself: + + ```python + from patterns.behavioral.template_method import Skeleton + + csv_report = Skeleton(fetch=pull, transform=drop_refunds, render=csv_rows, deliver=print_delivery) + md_report = csv_report.with_steps(render=markdown_table) + ``` + +5. **Test the spine's order once, each step alone, and each variant's + output.** The spine test uses recording steps; step tests are plain + function tests — no fixtures, no subclass scaffolding. + +## Python idioms that keep it small + +- **`with_steps` (or `dataclasses.replace`) is the variant factory** — a new + report is a diff against the baseline, so what varies is visible at a + glance. +- **`functools.partial` configures a step** (`partial(top_n, n=10)`) without + widening the seam's signature. +- **Explicit no-op steps** (`keep_all`, `discard`) beat `if step is not None` + branches in the spine — the spine stays a straight line. +- At a **framework boundary**, take the hook the framework gives you + (`JSONEncoder.default`, `setUp`) — wrapping a framework's template in your + own adds a layer for nothing. + +## Pitfalls + +- **The spine growing conditionals.** An `if kind == "csv"` inside `run` + means a step leaked into the skeleton; push it back out into a step. +- **Steps calling each other.** Seams talk only through the spine's data; + a step reaching into another step re-couples what you separated. +- **Hook explosion.** Ten seams make every call site a wall of keywords — + group related steps into one object, or accept that these are two + different templates. +- **Mutable data flowing between steps** hides ordering dependencies; pass + immutable snapshots (tuples, frozen dataclasses) so a reordered spine + fails loudly in tests. + +## Worked example + +[`examples/report_pipeline/`](../examples/report_pipeline/) applies every +step above to sales reporting — one spine, CSV and Markdown variants derived +from a baseline: + +```bash +uv run python -m patterns.behavioral.template_method.examples.report_pipeline +``` diff --git a/patterns/behavioral/template_method/examples/__init__.py b/patterns/behavioral/template_method/examples/__init__.py new file mode 100644 index 0000000..3587ec1 --- /dev/null +++ b/patterns/behavioral/template_method/examples/__init__.py @@ -0,0 +1 @@ +"""Mini-projects demonstrating the Template Method pattern in practice.""" diff --git a/patterns/behavioral/template_method/examples/report_pipeline/__init__.py b/patterns/behavioral/template_method/examples/report_pipeline/__init__.py new file mode 100644 index 0000000..cb24886 --- /dev/null +++ b/patterns/behavioral/template_method/examples/report_pipeline/__init__.py @@ -0,0 +1,23 @@ +"""A sales-report pipeline built on the Template Method pattern. + +Run it: ``uv run python -m patterns.behavioral.template_method.examples.report_pipeline`` +""" + +from patterns.behavioral.template_method.examples.report_pipeline.models import Sale, Sales +from patterns.behavioral.template_method.examples.report_pipeline.pipeline import ( + build_csv_report, + build_markdown_report, + csv_rows, + drop_refunds, + markdown_table, +) + +__all__ = [ + "Sale", + "Sales", + "build_csv_report", + "build_markdown_report", + "csv_rows", + "drop_refunds", + "markdown_table", +] diff --git a/patterns/behavioral/template_method/examples/report_pipeline/__main__.py b/patterns/behavioral/template_method/examples/report_pipeline/__main__.py new file mode 100644 index 0000000..49d1f60 --- /dev/null +++ b/patterns/behavioral/template_method/examples/report_pipeline/__main__.py @@ -0,0 +1,19 @@ +"""Demo: the same report skeleton delivered as CSV, then as Markdown.""" + +from __future__ import annotations + +from patterns.behavioral.template_method.examples.report_pipeline.pipeline import ( + build_csv_report, + build_markdown_report, +) + + +def main() -> None: + print("-- csv --") + build_csv_report().run() + print("-- markdown --") + build_markdown_report().run() + + +if __name__ == "__main__": + main() diff --git a/patterns/behavioral/template_method/examples/report_pipeline/models.py b/patterns/behavioral/template_method/examples/report_pipeline/models.py new file mode 100644 index 0000000..76e0fe2 --- /dev/null +++ b/patterns/behavioral/template_method/examples/report_pipeline/models.py @@ -0,0 +1,19 @@ +"""Domain types for the report-pipeline mini-project.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Sale: + product: str + quantity: int + unit_price: float + refunded: bool = False + + def revenue(self) -> float: + return self.quantity * self.unit_price + + +Sales = tuple[Sale, ...] diff --git a/patterns/behavioral/template_method/examples/report_pipeline/pipeline.py b/patterns/behavioral/template_method/examples/report_pipeline/pipeline.py new file mode 100644 index 0000000..7d3a0d4 --- /dev/null +++ b/patterns/behavioral/template_method/examples/report_pipeline/pipeline.py @@ -0,0 +1,56 @@ +"""A sales-report pipeline: one fixed skeleton, interchangeable steps. + +The spine (fetch → transform → render → deliver) is ``Skeleton.run`` from +the pattern package; every report variant below is the same spine with +different steps plugged in — no subclass per report. +""" + +from __future__ import annotations + +from patterns.behavioral.template_method.examples.report_pipeline.models import Sale, Sales +from patterns.behavioral.template_method.pattern import Skeleton + + +def fetch_sample_sales() -> Sales: + """Stand-in for a database or API pull.""" + return ( + Sale("espresso machine", 2, 249.0), + Sale("grinder", 5, 89.0), + Sale("filter pack", 40, 3.5), + Sale("gift card", 1, 50.0, refunded=True), + ) + + +def drop_refunds(sales: Sales) -> Sales: + return tuple(sale for sale in sales if not sale.refunded) + + +def csv_rows(sales: Sales) -> str: + lines = ["product,quantity,revenue"] + lines += [f"{s.product},{s.quantity},{s.revenue():.2f}" for s in sales] + return "\n".join(lines) + + +def markdown_table(sales: Sales) -> str: + lines = ["| product | quantity | revenue |", "|---|---|---|"] + lines += [f"| {s.product} | {s.quantity} | {s.revenue():.2f} |" for s in sales] + return "\n".join(lines) + + +def print_delivery(document: str) -> None: + print(document) + + +def build_csv_report() -> Skeleton[Sales, str]: + """The baseline report; variants derive from it by swapping steps.""" + return Skeleton( + fetch=fetch_sample_sales, + transform=drop_refunds, + render=csv_rows, + deliver=print_delivery, + ) + + +def build_markdown_report() -> Skeleton[Sales, str]: + """Same spine, same data — only the render step differs.""" + return build_csv_report().with_steps(render=markdown_table) diff --git a/patterns/behavioral/template_method/naive.py b/patterns/behavioral/template_method/naive.py deleted file mode 100644 index 1863347..0000000 --- a/patterns/behavioral/template_method/naive.py +++ /dev/null @@ -1,44 +0,0 @@ -"""The Gang of Four Template Method: skeleton in the base, hooks in subclasses.""" - -from __future__ import annotations - -from abc import ABC, abstractmethod - - -class Report(ABC): - def render(self, data: dict[str, int]) -> str: - """The template method: the skeleton nobody overrides.""" - rows = self.format_rows(data) - return f"{self.header()}\n{rows}" - - @abstractmethod - def header(self) -> str: ... - - @abstractmethod - def format_rows(self, data: dict[str, int]) -> str: ... - - -class TextReport(Report): - def header(self) -> str: - return "REPORT" - - def format_rows(self, data: dict[str, int]) -> str: - return "\n".join(f"{key}: {value}" for key, value in data.items()) - - -class CsvReport(Report): - def header(self) -> str: - return "key,value" - - def format_rows(self, data: dict[str, int]) -> str: - return "\n".join(f"{key},{value}" for key, value in data.items()) - - -def main() -> None: - data = {"apples": 3, "pears": 5} - print(TextReport().render(data)) - print(CsvReport().render(data)) - - -if __name__ == "__main__": - main() diff --git a/patterns/behavioral/template_method/pattern/__init__.py b/patterns/behavioral/template_method/pattern/__init__.py new file mode 100644 index 0000000..7870ce7 --- /dev/null +++ b/patterns/behavioral/template_method/pattern/__init__.py @@ -0,0 +1,9 @@ +"""The Template Method pattern, importable as library code.""" + +from patterns.behavioral.template_method.pattern.skeleton import ( + Skeleton, + discard, + keep_all, +) + +__all__ = ["Skeleton", "discard", "keep_all"] diff --git a/patterns/behavioral/template_method/pattern/skeleton.py b/patterns/behavioral/template_method/pattern/skeleton.py new file mode 100644 index 0000000..10776ca --- /dev/null +++ b/patterns/behavioral/template_method/pattern/skeleton.py @@ -0,0 +1,61 @@ +"""Template Method in its Python form: a fixed spine, steps as data. + +The classic pattern fixes an algorithm's skeleton in a base class and defers +steps to subclass hooks. Here the skeleton is ``run`` and the steps are +fields — varying a step is constructing (or ``with_steps``-ing) a value, +not declaring a class. +""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass, replace +from typing import Generic, TypeVar + +Raw = TypeVar("Raw") +Out = TypeVar("Out") + + +@dataclass(frozen=True) +class Skeleton(Generic[Raw, Out]): + """A four-step algorithm spine: fetch → transform → render → deliver. + + The spine never varies; every step does. ``run`` is the template method. + """ + + fetch: Callable[[], Raw] + transform: Callable[[Raw], Raw] + render: Callable[[Raw], Out] + deliver: Callable[[Out], None] + + def run(self) -> Out: + """Execute the fixed skeleton; return what was delivered.""" + document = self.render(self.transform(self.fetch())) + self.deliver(document) + return document + + def with_steps( + self, + *, + fetch: Callable[[], Raw] | None = None, + transform: Callable[[Raw], Raw] | None = None, + render: Callable[[Raw], Out] | None = None, + deliver: Callable[[Out], None] | None = None, + ) -> Skeleton[Raw, Out]: + """A copy with some steps swapped — variation without subclassing.""" + return replace( + self, + fetch=fetch if fetch is not None else self.fetch, + transform=transform if transform is not None else self.transform, + render=render if render is not None else self.render, + deliver=deliver if deliver is not None else self.deliver, + ) + + +def keep_all(rows: Raw) -> Raw: + """The identity transform — the explicit 'this step does nothing' hook.""" + return rows + + +def discard(document: Out) -> None: + """The no-op delivery — run for the return value alone.""" diff --git a/patterns/behavioral/template_method/pythonic.py b/patterns/behavioral/template_method/pythonic.py deleted file mode 100644 index 8a56ed6..0000000 --- a/patterns/behavioral/template_method/pythonic.py +++ /dev/null @@ -1,33 +0,0 @@ -"""The skeleton as a function, the steps as callable parameters.""" - -from __future__ import annotations - -from collections.abc import Callable - - -def plain_rows(data: dict[str, int]) -> str: - return "\n".join(f"{key}: {value}" for key, value in data.items()) - - -def csv_rows(data: dict[str, int]) -> str: - return "\n".join(f"{key},{value}" for key, value in data.items()) - - -def render( - data: dict[str, int], - *, - header: str = "REPORT", - format_rows: Callable[[dict[str, int]], str] = plain_rows, -) -> str: - """The whole template method: skeleton fixed, steps injected.""" - return f"{header}\n{format_rows(data)}" - - -def main() -> None: - data = {"apples": 3, "pears": 5} - print(render(data)) - print(render(data, header="key,value", format_rows=csv_rows)) - - -if __name__ == "__main__": - main() diff --git a/patterns/behavioral/template_method/real_world.py b/patterns/behavioral/template_method/real_world.py deleted file mode 100644 index 98a98ad..0000000 --- a/patterns/behavioral/template_method/real_world.py +++ /dev/null @@ -1,32 +0,0 @@ -"""``json.JSONEncoder``: a template method you override in the wild. - -encode() owns the skeleton; the default() hook is called exactly at the -step the skeleton cannot handle itself. -""" - -from __future__ import annotations - -import json -from datetime import date -from typing import Any - - -class DateAwareEncoder(json.JSONEncoder): - """Override the one hook; inherit the whole encoding skeleton.""" - - def default(self, o: Any) -> Any: - if isinstance(o, date): - return o.isoformat() - return super().default(o) - - -def dump_event(event: dict[str, object]) -> str: - return json.dumps(event, cls=DateAwareEncoder, sort_keys=True) - - -def main() -> None: - print(dump_event({"name": "launch", "when": date(2026, 8, 26)})) - - -if __name__ == "__main__": - main() diff --git a/patterns/behavioral/template_method/tests/test_report_pipeline.py b/patterns/behavioral/template_method/tests/test_report_pipeline.py new file mode 100644 index 0000000..2ca4675 --- /dev/null +++ b/patterns/behavioral/template_method/tests/test_report_pipeline.py @@ -0,0 +1,56 @@ +"""Behavioral tests for the report-pipeline mini-project.""" + +from __future__ import annotations + +import pytest + +from patterns.behavioral.template_method.examples.report_pipeline import ( + Sale, + build_csv_report, + build_markdown_report, + csv_rows, + drop_refunds, + markdown_table, +) +from patterns.behavioral.template_method.examples.report_pipeline.__main__ import main + + +class TestSteps: + def test_drop_refunds_removes_only_refunded_sales(self) -> None: + kept = Sale("grinder", 1, 89.0) + gone = Sale("gift card", 1, 50.0, refunded=True) + assert drop_refunds((kept, gone)) == (kept,) + + def test_csv_rows_renders_header_plus_one_line_per_sale(self) -> None: + out = csv_rows((Sale("grinder", 5, 89.0),)) + assert out == "product,quantity,revenue\ngrinder,5,445.00" + + def test_markdown_table_renders_the_same_data_as_a_table(self) -> None: + out = markdown_table((Sale("grinder", 5, 89.0),)) + assert out.splitlines()[0] == "| product | quantity | revenue |" + assert "| grinder | 5 | 445.00 |" in out + + +class TestVariants: + def test_csv_report_excludes_the_refunded_sale( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + document = build_csv_report().run() + assert "gift card" not in document + assert document.startswith("product,quantity,revenue") + assert capsys.readouterr().out.strip() == document # delivered by printing + + def test_markdown_variant_shares_fetch_and_transform_with_csv(self) -> None: + csv_doc = build_csv_report().run() + md_doc = build_markdown_report().run() + assert "espresso machine" in csv_doc and "espresso machine" in md_doc + assert "gift card" not in md_doc # same transform step ran + + +class TestDemo: + def test_demo_prints_both_report_formats(self, capsys: pytest.CaptureFixture[str]) -> None: + main() + out = capsys.readouterr().out + assert "-- csv --" in out + assert "product,quantity,revenue" in out + assert "| product | quantity | revenue |" in out diff --git a/patterns/behavioral/template_method/tests/test_skeleton.py b/patterns/behavioral/template_method/tests/test_skeleton.py new file mode 100644 index 0000000..4276caa --- /dev/null +++ b/patterns/behavioral/template_method/tests/test_skeleton.py @@ -0,0 +1,78 @@ +"""Behavioral tests for the Skeleton building block.""" + +from __future__ import annotations + +from patterns.behavioral.template_method import Skeleton, discard, keep_all + + +def recording_skeleton(trace: list[str]) -> Skeleton[str, str]: + def fetch() -> str: + trace.append("fetch") + return "raw" + + def transform(data: str) -> str: + trace.append("transform") + return f"{data}+clean" + + def render(data: str) -> str: + trace.append("render") + return f"[{data}]" + + def deliver(document: str) -> None: + trace.append(f"deliver:{document}") + + return Skeleton(fetch=fetch, transform=transform, render=render, deliver=deliver) + + +class TestSpine: + def test_run_executes_the_four_steps_in_fixed_order(self) -> None: + trace: list[str] = [] + result = recording_skeleton(trace).run() + assert result == "[raw+clean]" + assert trace == ["fetch", "transform", "render", "deliver:[raw+clean]"] + + def test_the_delivered_document_is_the_rendered_one(self) -> None: + trace: list[str] = [] + recording_skeleton(trace).run() + assert trace[-1] == "deliver:[raw+clean]" + + +class TestVariation: + def test_with_steps_swaps_one_step_and_keeps_the_rest(self) -> None: + trace: list[str] = [] + variant = recording_skeleton(trace).with_steps(render=lambda data: data.upper()) + assert variant.run() == "RAW+CLEAN" + assert "fetch" in trace # untouched steps still ran + + def test_with_steps_returns_a_new_skeleton_leaving_the_original_alone(self) -> None: + trace: list[str] = [] + base = recording_skeleton(trace) + base.with_steps(render=lambda data: "other") + assert base.run() == "[raw+clean]" # original unchanged + + def test_every_step_is_individually_swappable(self) -> None: + trace: list[str] = [] + base = recording_skeleton(trace) + + assert base.with_steps(fetch=lambda: "other").run() == "[other+clean]" + assert base.with_steps(transform=lambda data: data).run() == "[raw]" + assert base.with_steps(render=lambda data: data.upper()).run() == "RAW+CLEAN" + + delivered: list[str] = [] + base.with_steps(deliver=delivered.append).run() + assert delivered == ["[raw+clean]"] + + +class TestExplicitNoOps: + def test_keep_all_is_the_identity_transform(self) -> None: + assert keep_all((1, 2)) == (1, 2) + + def test_discard_delivers_nowhere(self) -> None: + # Prove nothing is delivered: a skeleton whose only sink is a + # recording list, with discard swapped in, records nothing. + delivered: list[str] = [] + trace: list[str] = [] + base = recording_skeleton(trace).with_steps(deliver=delivered.append) + base.with_steps(deliver=discard).run() + assert delivered == [] + assert base.run() == "[raw+clean]" and delivered == ["[raw+clean]"] diff --git a/patterns/behavioral/template_method/tests/test_template_method.py b/patterns/behavioral/template_method/tests/test_template_method.py deleted file mode 100644 index 60db5f2..0000000 --- a/patterns/behavioral/template_method/tests/test_template_method.py +++ /dev/null @@ -1,38 +0,0 @@ -"""Behavioral tests for all three template-method variants.""" - -import json -from datetime import date - -import pytest - -from patterns.behavioral.template_method import naive, pythonic, real_world - - -class TestNaive: - def test_subclasses_vary_steps_not_skeleton(self) -> None: - data = {"apples": 3} - assert naive.TextReport().render(data) == "REPORT\napples: 3" - assert naive.CsvReport().render(data) == "key,value\napples,3" - - -class TestPythonic: - def test_default_steps(self) -> None: - assert pythonic.render({"apples": 3}) == "REPORT\napples: 3" - - def test_injected_steps(self) -> None: - out = pythonic.render({"apples": 3}, header="key,value", format_rows=pythonic.csv_rows) - assert out == "key,value\napples,3" - - def test_steps_compose_at_call_time(self) -> None: - loud = pythonic.render({"a": 1}, format_rows=lambda d: pythonic.plain_rows(d).upper()) - assert loud == "REPORT\nA: 1" - - -class TestRealWorld: - def test_hook_handles_dates_inside_the_inherited_skeleton(self) -> None: - out = real_world.dump_event({"name": "launch", "when": date(2026, 8, 26)}) - assert json.loads(out) == {"name": "launch", "when": "2026-08-26"} - - def test_unknown_types_still_raise_via_super(self) -> None: - with pytest.raises(TypeError): - real_world.dump_event({"bad": object()}) diff --git a/patterns/behavioral/visitor/README.md b/patterns/behavioral/visitor/README.md index 2dd74ef..0857e89 100644 --- a/patterns/behavioral/visitor/README.md +++ b/patterns/behavioral/visitor/README.md @@ -14,29 +14,17 @@ stdlib_sightings: [functools.singledispatch, ast.NodeVisitor] # Visitor -## Problem - -An expression tree (or document tree, or AST) needs new operations — render, -optimize, measure — and you'd rather not add a method to every node class for -every new operation. - -## Naive solution - -`naive.py` is the full GoF double dispatch: every node implements -`accept(visitor)`, every visitor implements one `visit_X` per node type. - -## Pythonic solution - -`functools.singledispatch` dispatches on the node's type directly — the -`accept()` plumbing evaporates, node classes stay untouched, and a new -operation is one decorated function per node type. - -## In the wild - -`ast.NodeVisitor` walks Python source with a `visit_ClassName` method per -node — the Visitor pattern as a supported stdlib API. - -## Verdict - -**Prefer an alternative:** `singledispatch`. Use `ast.NodeVisitor` when the -tree is Python itself. +New operations over a node structure, without editing the nodes. **Verdict: +prefer an alternative** — `singledispatch` deletes the `accept()` plumbing; +the subclass form survives at stdlib boundaries (`ast.NodeVisitor`). + +| Where | What | +|---|---| +| [`pattern/`](pattern/) | The importable code: `Operation`, `UnhandledNodeError` | +| [`docs/`](docs/) | [Fundamentals](docs/fundamentals.md) · [Implementation guide](docs/implementation.md) · [External examples](docs/examples.md) | +| [`examples/doc_exporters/`](examples/doc_exporters/) | Mini-project: document exporters built on `pattern/` | +| [`tests/`](tests/) | Behavioral tests for the pattern and the mini-project | + +```bash +uv run python -m patterns.behavioral.visitor.examples.doc_exporters +``` diff --git a/patterns/behavioral/visitor/__init__.py b/patterns/behavioral/visitor/__init__.py index 2e01634..8e49dd5 100644 --- a/patterns/behavioral/visitor/__init__.py +++ b/patterns/behavioral/visitor/__init__.py @@ -1 +1,8 @@ -"""Visitor: new operations over a node family. Verdict: singledispatch.""" +"""Visitor — public API. + +>>> from patterns.behavioral.visitor import Operation +""" + +from patterns.behavioral.visitor.pattern import Operation, UnhandledNodeError + +__all__ = ["Operation", "UnhandledNodeError"] diff --git a/patterns/behavioral/visitor/docs/examples.md b/patterns/behavioral/visitor/docs/examples.md new file mode 100644 index 0000000..7c5d1a8 --- /dev/null +++ b/patterns/behavioral/visitor/docs/examples.md @@ -0,0 +1,43 @@ +# Visitor — where it lives outside this repo + +Cited, real implementations to study (or point an agent at) when designing or +reviewing visitor-shaped code. + +## Python standard library + +- **`ast.NodeVisitor` / `ast.NodeTransformer`.** The classic form as a + supported API: subclass, implement `visit_ClassName` per node, call + `generic_visit` to recurse. When the tree is Python source, this is the + right tool — the stdlib owns the node family, you own the operation. + [docs.python.org/3/library/ast.html#ast.NodeVisitor](https://docs.python.org/3/library/ast.html#ast.NodeVisitor) +- **`functools.singledispatch`.** The deletion of the pattern's plumbing: + dispatch on argument type, registered by annotation — what this unit's + `Operation` wraps with a strict default. + [docs.python.org/3/library/functools.html#functools.singledispatch](https://docs.python.org/3/library/functools.html#functools.singledispatch) + +## Major ecosystems + +- **pylint checkers.** Every lint rule is a visitor: checkers implement + `visit_` methods over the parsed tree, and new rules ship + without touching the node classes — the open-operation-set promise at + ecosystem scale. + [pylint.readthedocs.io](https://pylint.readthedocs.io/) +- **LibCST.** Concrete-syntax-tree visitors and transformers powering + large-scale codemods (Instagram's refactors); the visitor as a production + migration tool. + [libcst.readthedocs.io](https://libcst.readthedocs.io/) +- **SQLAlchemy's `visitors` module.** SQL compilation walks clause trees + with visitor machinery (`ClauseVisitor`, traversal utilities) — the + pattern deep inside a library most Python services already depend on. + [docs.sqlalchemy.org/en/latest/core/visitors.html](https://docs.sqlalchemy.org/en/latest/core/visitors.html) + +## What to notice across all of them + +The pattern appears wherever the **node family is stable and owned by +someone else** (Python's grammar, SQL clauses) while operations multiply +(lint rules, compilers, codemods). None of the Python examples hand-write +`accept()` — dispatch is either a naming convention (`visit_X`) or +`singledispatch`. When reviewing, check the unknown-node policy: `ast`'s +`generic_visit` deliberately recurses past unknown nodes, lint rules +deliberately skip — an *exporter* that skips unknown nodes is losing data +silently. diff --git a/patterns/behavioral/visitor/docs/fundamentals.md b/patterns/behavioral/visitor/docs/fundamentals.md new file mode 100644 index 0000000..0b78d62 --- /dev/null +++ b/patterns/behavioral/visitor/docs/fundamentals.md @@ -0,0 +1,83 @@ +# Visitor — fundamentals + +## Intent + +Run an operation over every node of an object structure — render, measure, +lint — without adding a method to every node class for every new operation. +The pattern separates *what the tree is* from *what you do to it*. + +## Participants + +| Role | Classic (GoF) form | Python form | +|---|---|---| +| Node contract | `accept(visitor)` on an Element interface | Nothing — nodes are plain (frozen) dataclasses | +| Concrete nodes | Each implements `accept` calling `visitor.visit_X(self)` | Just data | +| Visitor contract | An interface with one `visit_X` per node type | A dispatch family — `Operation` in [`pattern/dispatch.py`](../pattern/dispatch.py) | +| Concrete visitors | One class per operation | One `Operation` per operation; one small function per node type | +| Dispatch | Hand-written double dispatch via `accept` | `functools.singledispatch` on the node's type | + +## Mechanism + +1. Define the node types as plain data (a union type names the family). +2. For each operation, create an `Operation` and register one case per node + type; composite cases recurse by calling the operation on children. +3. Apply the operation to the root. An unregistered node type raises + `UnhandledNodeError` naming what *is* handled — the strict default the + stdlib's `singledispatch` leaves to you. + +## The classic form, and what Python absorbs + +The textbook implementation threads dispatch plumbing through every class on +both sides: + +```python +class Node(ABC): + @abstractmethod + def accept(self, visitor: Visitor) -> str: ... + + +class Number(Node): + def accept(self, visitor: Visitor) -> str: + return visitor.visit_number(self) # plumbing, per node class + + +class Add(Node): + def accept(self, visitor: Visitor) -> str: + return visitor.visit_add(self) # ...and again + + +class Visitor(ABC): + @abstractmethod + def visit_number(self, node: Number) -> str: ... + + @abstractmethod + def visit_add(self, node: Add) -> str: ... +``` + +The `accept`/`visit_X` pair exists to fake **double dispatch** in languages +whose method calls dispatch only on the receiver. `functools.singledispatch` +dispatches on the argument's type directly, so the entire plumbing layer — +`accept` methods, the visitor interface, the node base class — evaporates. +What survives is the separation itself: operations live outside the node +classes, and a new operation touches zero of them. + +## When to use it + +- A stable node family needs an *open* set of operations (exporters, + analyzers, metrics) — the pattern trades easy-new-operation for + hard-new-node-type. +- You're walking a tree someone else defined and must not modify. + +## When not to use it + +- The *node family* grows more often than the operations → put methods on the + nodes; every new type would force edits to every dispatch family anyway. +- One operation, once → a plain recursive function needs no registry. +- The tree is Python source → the stdlib already hands you the classic form: + `ast.NodeVisitor`. Take it. + +## Verdict: prefer an alternative + +The alternative is `singledispatch` (what `Operation` packages, with a strict +default). The classic subclass form survives exactly where a framework hands +it to you — `ast.NodeVisitor` being the canonical case. diff --git a/patterns/behavioral/visitor/docs/implementation.md b/patterns/behavioral/visitor/docs/implementation.md new file mode 100644 index 0000000..e3446de --- /dev/null +++ b/patterns/behavioral/visitor/docs/implementation.md @@ -0,0 +1,83 @@ +# Visitor — putting it into a system + +## The smell it fixes + +Either a growing `isinstance` ladder duplicated in every operation: + +```python +def to_markdown(node): + if isinstance(node, Paragraph): + ... + elif isinstance(node, Section): + ... + elif isinstance(node, CodeBlock): + ... # copy-pasted into to_html, + ... # word_count, lint, ... +``` + +…or its mirror image: node classes accreting one method per operation +(`to_markdown`, `to_html`, `word_count`, …) until every new operation is a +cross-cutting edit of the whole file. + +## Steps + +1. **Make the nodes plain data.** Frozen dataclasses; a union alias + (`Block = Paragraph | CodeBlock | ...`) names the family. No `accept`, + no base class needed. +2. **One `Operation` per operation**, typed by its result: + + ```python + from patterns.behavioral.visitor import Operation + + markdown: Operation[str] = Operation("markdown") + + + @markdown.register + def _(node: Paragraph) -> str: + return node.text + ``` + +3. **One case per node type**, dispatched by the annotation. Composite nodes + recurse by calling the operation on their children — recursion lives in + the cases, not in a walker. +4. **Keep the default strict.** `Operation` raises `UnhandledNodeError` + (naming the handled types) for an unregistered node — a new node type + then fails the first test that touches it, instead of being silently + skipped. +5. **Test the promise.** One test should add a brand-new operation without + editing `nodes.py` — that is the property the pattern exists to provide. + +## Python idioms that keep it small + +- **Dispatch on annotations** (`def _(node: Section) -> str`) keeps each + case self-documenting; `singledispatch` keys on the annotated type, so + the function names don't matter — every case can be named `_`. +- **Same-module registration** keeps an operation reviewable as one unit — + a dispatch family scattered across files is the ladder again, hidden. +- **`ast.NodeVisitor` at the boundary:** when the tree is Python source, + subclass the stdlib visitor rather than rebuilding dispatch over `ast` + nodes. + +## Pitfalls + +- **A permissive default** (`return ""` / `pass` for unknown nodes) turns + new node types into silent data loss. Strictness is the safety net. +- **Growing the node family is expensive by design** — every operation needs + a new case. If node types churn, the pattern is working against you; + prefer methods on the nodes. +- **Inheritance surprises:** `singledispatch` matches subclasses; a case for + a base dataclass will absorb its subclasses unless more-specific cases are + registered. +- **State in the operation.** Cases should be pure node → result; an + operation needing traversal state (numbering, indentation depth) should + pass it explicitly or wrap results, not stash it in globals. + +## Worked example + +[`examples/doc_exporters/`](../examples/doc_exporters/) applies every step +above to a document tree — Markdown, plain-text, and word-count operations +over five node types the operations never edit: + +```bash +uv run python -m patterns.behavioral.visitor.examples.doc_exporters +``` diff --git a/patterns/behavioral/visitor/examples/__init__.py b/patterns/behavioral/visitor/examples/__init__.py new file mode 100644 index 0000000..7fdc4b0 --- /dev/null +++ b/patterns/behavioral/visitor/examples/__init__.py @@ -0,0 +1 @@ +"""Mini-projects demonstrating the Visitor pattern in practice.""" diff --git a/patterns/behavioral/visitor/examples/doc_exporters/__init__.py b/patterns/behavioral/visitor/examples/doc_exporters/__init__.py new file mode 100644 index 0000000..b03c3fa --- /dev/null +++ b/patterns/behavioral/visitor/examples/doc_exporters/__init__.py @@ -0,0 +1,30 @@ +"""Document exporters built on the Visitor pattern. + +Run it: ``uv run python -m patterns.behavioral.visitor.examples.doc_exporters`` +""" + +from patterns.behavioral.visitor.examples.doc_exporters.exporters import ( + markdown, + plain_text, + word_count, +) +from patterns.behavioral.visitor.examples.doc_exporters.nodes import ( + Block, + BulletList, + CodeBlock, + Document, + Paragraph, + Section, +) + +__all__ = [ + "Block", + "BulletList", + "CodeBlock", + "Document", + "Paragraph", + "Section", + "markdown", + "plain_text", + "word_count", +] diff --git a/patterns/behavioral/visitor/examples/doc_exporters/__main__.py b/patterns/behavioral/visitor/examples/doc_exporters/__main__.py new file mode 100644 index 0000000..dfa209b --- /dev/null +++ b/patterns/behavioral/visitor/examples/doc_exporters/__main__.py @@ -0,0 +1,45 @@ +"""Demo: one document through all three exporters.""" + +from __future__ import annotations + +from patterns.behavioral.visitor.examples.doc_exporters.exporters import ( + markdown, + plain_text, + word_count, +) +from patterns.behavioral.visitor.examples.doc_exporters.nodes import ( + BulletList, + CodeBlock, + Document, + Paragraph, + Section, +) + + +def sample_document() -> Document: + return Document( + "Release notes", + ( + Paragraph("Version 2.0 ships three long-requested features."), + Section( + "Highlights", + ( + BulletList(("faster startup", "dark mode", "offline sync")), + CodeBlock("bash", "pip install app==2.0"), + ), + ), + ), + ) + + +def main() -> None: + document = sample_document() + print("-- markdown --") + print(markdown(document)) + print("-- plain text --") + print(plain_text(document)) + print(f"-- word count: {word_count(document)} --") + + +if __name__ == "__main__": + main() diff --git a/patterns/behavioral/visitor/examples/doc_exporters/exporters.py b/patterns/behavioral/visitor/examples/doc_exporters/exporters.py new file mode 100644 index 0000000..c3e30bd --- /dev/null +++ b/patterns/behavioral/visitor/examples/doc_exporters/exporters.py @@ -0,0 +1,103 @@ +"""Three operations over the document tree, each a dispatch family. + +A new exporter is a new ``Operation`` plus one case per node type — the node +classes in ``nodes.py`` are never touched. +""" + +from __future__ import annotations + +from patterns.behavioral.visitor.examples.doc_exporters.nodes import ( + BulletList, + CodeBlock, + Document, + Paragraph, + Section, +) +from patterns.behavioral.visitor.pattern import Operation + +markdown: Operation[str] = Operation("markdown") + + +@markdown.register +def _document_md(node: Document) -> str: + body = "\n\n".join(markdown(child) for child in node.children) + return f"# {node.title}\n\n{body}" + + +@markdown.register +def _section_md(node: Section) -> str: + body = "\n\n".join(markdown(child) for child in node.children) + return f"## {node.title}\n\n{body}" + + +@markdown.register +def _paragraph_md(node: Paragraph) -> str: + return node.text + + +@markdown.register +def _code_md(node: CodeBlock) -> str: + return f"```{node.language}\n{node.code}\n```" + + +@markdown.register +def _bullets_md(node: BulletList) -> str: + return "\n".join(f"- {item}" for item in node.items) + + +plain_text: Operation[str] = Operation("plain_text") + + +@plain_text.register +def _document_txt(node: Document) -> str: + body = "\n\n".join(plain_text(child) for child in node.children) + return f"{node.title.upper()}\n\n{body}" + + +@plain_text.register +def _section_txt(node: Section) -> str: + body = "\n\n".join(plain_text(child) for child in node.children) + return f"{node.title}\n{'-' * len(node.title)}\n{body}" + + +@plain_text.register +def _paragraph_txt(node: Paragraph) -> str: + return node.text + + +@plain_text.register +def _code_txt(node: CodeBlock) -> str: + return "\n".join(f" {line}" for line in node.code.splitlines()) + + +@plain_text.register +def _bullets_txt(node: BulletList) -> str: + return "\n".join(f" * {item}" for item in node.items) + + +word_count: Operation[int] = Operation("word_count") + + +@word_count.register +def _document_wc(node: Document) -> int: + return len(node.title.split()) + sum(word_count(child) for child in node.children) + + +@word_count.register +def _section_wc(node: Section) -> int: + return len(node.title.split()) + sum(word_count(child) for child in node.children) + + +@word_count.register +def _paragraph_wc(node: Paragraph) -> int: + return len(node.text.split()) + + +@word_count.register +def _code_wc(node: CodeBlock) -> int: + return 0 # code is not prose + + +@word_count.register +def _bullets_wc(node: BulletList) -> int: + return sum(len(item.split()) for item in node.items) diff --git a/patterns/behavioral/visitor/examples/doc_exporters/nodes.py b/patterns/behavioral/visitor/examples/doc_exporters/nodes.py new file mode 100644 index 0000000..7f50e5f --- /dev/null +++ b/patterns/behavioral/visitor/examples/doc_exporters/nodes.py @@ -0,0 +1,40 @@ +"""The document tree: plain frozen dataclasses, no ``accept()`` anywhere. + +Adding an operation over these nodes never edits this file — that is the +pattern's promise, kept by keeping the nodes ignorant of their visitors. +""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Paragraph: + text: str + + +@dataclass(frozen=True) +class CodeBlock: + language: str + code: str + + +@dataclass(frozen=True) +class BulletList: + items: tuple[str, ...] + + +@dataclass(frozen=True) +class Section: + title: str + children: tuple[Block, ...] + + +@dataclass(frozen=True) +class Document: + title: str + children: tuple[Block, ...] + + +Block = Paragraph | CodeBlock | BulletList | Section diff --git a/patterns/behavioral/visitor/naive.py b/patterns/behavioral/visitor/naive.py deleted file mode 100644 index 9d79e5d..0000000 --- a/patterns/behavioral/visitor/naive.py +++ /dev/null @@ -1,51 +0,0 @@ -"""The Gang of Four Visitor: accept() on every node, visit_X on every visitor.""" - -from __future__ import annotations - -from abc import ABC, abstractmethod - - -class Node(ABC): - @abstractmethod - def accept(self, visitor: Visitor) -> str: ... - - -class Number(Node): - def __init__(self, value: int) -> None: - self.value = value - - def accept(self, visitor: Visitor) -> str: - return visitor.visit_number(self) - - -class Add(Node): - def __init__(self, left: Node, right: Node) -> None: - self.left, self.right = left, right - - def accept(self, visitor: Visitor) -> str: - return visitor.visit_add(self) - - -class Visitor(ABC): - @abstractmethod - def visit_number(self, node: Number) -> str: ... - - @abstractmethod - def visit_add(self, node: Add) -> str: ... - - -class Renderer(Visitor): - def visit_number(self, node: Number) -> str: - return str(node.value) - - def visit_add(self, node: Add) -> str: - return f"({node.left.accept(self)} + {node.right.accept(self)})" - - -def main() -> None: - tree = Add(Number(1), Add(Number(2), Number(3))) - print(tree.accept(Renderer())) - - -if __name__ == "__main__": - main() diff --git a/patterns/behavioral/visitor/pattern/__init__.py b/patterns/behavioral/visitor/pattern/__init__.py new file mode 100644 index 0000000..c1ad4c2 --- /dev/null +++ b/patterns/behavioral/visitor/pattern/__init__.py @@ -0,0 +1,5 @@ +"""The Visitor pattern, importable as library code.""" + +from patterns.behavioral.visitor.pattern.dispatch import Operation, UnhandledNodeError + +__all__ = ["Operation", "UnhandledNodeError"] diff --git a/patterns/behavioral/visitor/pattern/dispatch.py b/patterns/behavioral/visitor/pattern/dispatch.py new file mode 100644 index 0000000..f6be9b6 --- /dev/null +++ b/patterns/behavioral/visitor/pattern/dispatch.py @@ -0,0 +1,53 @@ +"""Visitor in its Python form: ``singledispatch`` families, no ``accept()``. + +An operation over a node structure is a family of small functions dispatched +on node type. ``Operation`` wraps ``functools.singledispatch`` with the two +things a visitor needs and the stdlib leaves open: a *strict* default (an +unregistered node type is an error, not a silent pass) and an inspectable +set of handled types. +""" + +from __future__ import annotations + +from collections.abc import Callable +from functools import singledispatch +from typing import Any, Generic, TypeVar + +R = TypeVar("R") +N = TypeVar("N") + + +class UnhandledNodeError(TypeError): + """The operation has no case registered for this node type.""" + + +class Operation(Generic[R]): + """One operation over a node structure, as a type-dispatched family. + + Registering a case is decorating a function whose argument annotation + names the node type — node classes are never edited. + """ + + def __init__(self, name: str) -> None: + self.name = name + + @singledispatch + def dispatch(node: object) -> R: + handled = ", ".join(sorted(t.__name__ for t in self.registered_types())) or "none" + raise UnhandledNodeError( + f"operation {self.name!r} has no case for {type(node).__name__} " + f"(handles: {handled})" + ) + + self._dispatch = dispatch + + def register(self, case: Callable[[N], R]) -> Callable[[N], R]: + """Add the case for one node type (read from the annotation).""" + self._dispatch.register(case) + return case + + def __call__(self, node: object) -> R: + return self._dispatch(node) + + def registered_types(self) -> frozenset[type[Any]]: + return frozenset(t for t in self._dispatch.registry if t is not object) diff --git a/patterns/behavioral/visitor/pythonic.py b/patterns/behavioral/visitor/pythonic.py deleted file mode 100644 index 3cf3e3b..0000000 --- a/patterns/behavioral/visitor/pythonic.py +++ /dev/null @@ -1,60 +0,0 @@ -"""The visitor with the plumbing deleted: functools.singledispatch. - -Node classes are plain dataclasses with no accept(); each operation is a -dispatch family of small functions. -""" - -from __future__ import annotations - -from dataclasses import dataclass -from functools import singledispatch - - -@dataclass(frozen=True) -class Number: - value: int - - -@dataclass(frozen=True) -class Add: - left: Number | Add - right: Number | Add - - -@singledispatch -def render(node: object) -> str: - raise TypeError(f"no renderer for {type(node).__name__}") - - -@render.register -def _(node: Number) -> str: - return str(node.value) - - -@render.register -def _(node: Add) -> str: - return f"({render(node.left)} + {render(node.right)})" - - -@singledispatch -def evaluate(node: object) -> int: - raise TypeError(f"no evaluator for {type(node).__name__}") - - -@evaluate.register -def _(node: Number) -> int: - return node.value - - -@evaluate.register -def _(node: Add) -> int: - return evaluate(node.left) + evaluate(node.right) - - -def main() -> None: - tree = Add(Number(1), Add(Number(2), Number(3))) - print(f"{render(tree)} = {evaluate(tree)}") - - -if __name__ == "__main__": - main() diff --git a/patterns/behavioral/visitor/real_world.py b/patterns/behavioral/visitor/real_world.py deleted file mode 100644 index 5265bcf..0000000 --- a/patterns/behavioral/visitor/real_world.py +++ /dev/null @@ -1,38 +0,0 @@ -"""``ast.NodeVisitor``: the Visitor pattern as a stdlib API. - -Count the function definitions and calls in any piece of Python source. -""" - -from __future__ import annotations - -import ast - - -class Census(ast.NodeVisitor): - def __init__(self) -> None: - self.functions: list[str] = [] - self.calls = 0 - - def visit_FunctionDef(self, node: ast.FunctionDef) -> None: - self.functions.append(node.name) - self.generic_visit(node) - - def visit_Call(self, node: ast.Call) -> None: - self.calls += 1 - self.generic_visit(node) - - -def census_of(source: str) -> Census: - census = Census() - census.visit(ast.parse(source)) - return census - - -def main() -> None: - source = "def greet():\n print('hi')\n\ndef leave():\n print(exit())\n" - census = census_of(source) - print(f"functions: {census.functions}, calls: {census.calls}") - - -if __name__ == "__main__": - main() diff --git a/patterns/behavioral/visitor/tests/test_dispatch.py b/patterns/behavioral/visitor/tests/test_dispatch.py new file mode 100644 index 0000000..56df0f9 --- /dev/null +++ b/patterns/behavioral/visitor/tests/test_dispatch.py @@ -0,0 +1,77 @@ +"""Behavioral tests for the Operation building block.""" + +from __future__ import annotations + +from dataclasses import dataclass + +import pytest + +from patterns.behavioral.visitor import Operation, UnhandledNodeError + + +@dataclass(frozen=True) +class Circle: + radius: float + + +@dataclass(frozen=True) +class Square: + side: float + + +class TestDispatch: + def test_calls_dispatch_on_the_nodes_type(self) -> None: + area: Operation[float] = Operation("area") + + @area.register + def _(node: Circle) -> float: + return 3.14159 * node.radius**2 + + @area.register + def _(node: Square) -> float: + return node.side**2 + + assert area(Square(3.0)) == 9.0 + assert area(Circle(1.0)) == pytest.approx(3.14159) + + def test_register_hands_the_case_back_usable(self) -> None: + name: Operation[str] = Operation("name") + + @name.register + def circle_name(node: Circle) -> str: + return "circle" + + assert circle_name(Circle(1.0)) == "circle" + + def test_registered_types_reports_the_handled_family(self) -> None: + op: Operation[str] = Operation("op") + + @op.register + def _(node: Circle) -> str: + return "c" + + assert op.registered_types() == frozenset({Circle}) + + +class TestStrictDefault: + def test_unregistered_type_raises_naming_operation_and_handled_types(self) -> None: + area: Operation[float] = Operation("area") + + @area.register + def _(node: Circle) -> float: + return 0.0 + + with pytest.raises(UnhandledNodeError, match=r"'area' has no case for Square.*Circle"): + area(Square(2.0)) + + def test_operations_are_independent_families(self) -> None: + first: Operation[int] = Operation("first") + second: Operation[int] = Operation("second") + + @first.register + def _(node: Circle) -> int: + return 1 + + assert first(Circle(1.0)) == 1 + with pytest.raises(UnhandledNodeError): + second(Circle(1.0)) diff --git a/patterns/behavioral/visitor/tests/test_doc_exporters.py b/patterns/behavioral/visitor/tests/test_doc_exporters.py new file mode 100644 index 0000000..0c674a0 --- /dev/null +++ b/patterns/behavioral/visitor/tests/test_doc_exporters.py @@ -0,0 +1,86 @@ +"""Behavioral tests for the doc-exporters mini-project.""" + +from __future__ import annotations + +import pytest + +from patterns.behavioral.visitor import Operation, UnhandledNodeError +from patterns.behavioral.visitor.examples.doc_exporters import ( + BulletList, + CodeBlock, + Document, + Paragraph, + Section, + markdown, + plain_text, + word_count, +) +from patterns.behavioral.visitor.examples.doc_exporters.__main__ import main, sample_document + + +class TestMarkdown: + def test_renders_the_whole_tree_with_heading_levels(self) -> None: + out = markdown(sample_document()) + lines = out.splitlines() + # Exact-line assertions: "## Highlights" as a substring would also + # match "### Highlights", hiding a heading-level regression. + assert lines[0] == "# Release notes" + assert "## Highlights" in lines + assert "- dark mode" in lines + assert "```bash\npip install app==2.0\n```" in out + + +class TestPlainText: + def test_renders_titles_and_indents_code(self) -> None: + out = plain_text(sample_document()) + assert out.startswith("RELEASE NOTES") + assert "Highlights\n----------" in out + assert " pip install app==2.0" in out + + +class TestWordCount: + def test_counts_prose_words_and_ignores_code(self) -> None: + doc = Document( + "Two words", # 2 + ( + Paragraph("one two three"), # 3 + Section("title", (CodeBlock("py", "print('not counted')"),)), # 1 + 0 + BulletList(("a b", "c")), # 3 + ), + ) + assert word_count(doc) == 9 + + +class TestThePatternsPromise: + def test_a_new_operation_needs_no_edit_to_the_node_classes(self) -> None: + html: Operation[str] = Operation("html") + + @html.register + def _p(node: Paragraph) -> str: + return f"

{node.text}

" + + @html.register + def _d(node: Document) -> str: + return f"

{node.title}

" + "".join(html(child) for child in node.children) + + out = html(Document("T", (Paragraph("hello"),))) + assert out == "

T

hello

" + + def test_an_unregistered_node_type_is_an_error_not_silence(self) -> None: + html: Operation[str] = Operation("html") + + @html.register + def _(node: Paragraph) -> str: + return node.text + + with pytest.raises(UnhandledNodeError, match="'html' has no case for CodeBlock"): + html(CodeBlock("py", "x = 1")) + + +class TestDemo: + def test_demo_prints_all_three_exports(self, capsys: pytest.CaptureFixture[str]) -> None: + main() + out = capsys.readouterr().out + assert "-- markdown --" in out + assert "-- plain text --" in out + assert "-- word count: " in out diff --git a/patterns/behavioral/visitor/tests/test_visitor.py b/patterns/behavioral/visitor/tests/test_visitor.py deleted file mode 100644 index 2c4918c..0000000 --- a/patterns/behavioral/visitor/tests/test_visitor.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Behavioral tests for all three visitor variants.""" - -import pytest - -from patterns.behavioral.visitor import naive, pythonic, real_world - - -class TestNaive: - def test_double_dispatch_renders_the_tree(self) -> None: - tree = naive.Add(naive.Number(1), naive.Add(naive.Number(2), naive.Number(3))) - assert tree.accept(naive.Renderer()) == "(1 + (2 + 3))" - - -class TestPythonic: - def test_two_operations_no_node_changes(self) -> None: - tree = pythonic.Add( - pythonic.Number(1), pythonic.Add(pythonic.Number(2), pythonic.Number(3)) - ) - assert pythonic.render(tree) == "(1 + (2 + 3))" - assert pythonic.evaluate(tree) == 6 - - def test_unknown_node_type_fails_loudly(self) -> None: - with pytest.raises(TypeError, match="no renderer"): - pythonic.render("not a node") - - -class TestRealWorld: - def test_ast_census(self) -> None: - source = "def greet():\n print('hi')\n\ndef leave():\n print(exit())\n" - census = real_world.census_of(source) - assert census.functions == ["greet", "leave"] - assert census.calls == 3 diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index cae1778..541036e 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -246,17 +246,27 @@ async def test_catalog_index_resource(self) -> None: assert isinstance(contents, TextResourceContents) assert len(json.loads(contents.text)) == 32 - async def test_pattern_doc_and_source_templates(self) -> None: + async def test_pattern_doc_and_docs_templates(self) -> None: async with Client(mcp) as client: doc = await client.read_resource("pattern://behavioral/iterator") first = doc.contents[0] assert isinstance(first, TextResourceContents) assert "# Iterator" in first.text - src = await client.read_resource("pattern://behavioral/iterator/naive") + fund = await client.read_resource("pattern://behavioral/iterator/docs/fundamentals") + first_fund = fund.contents[0] + assert isinstance(first_fund, TextResourceContents) + assert "# Iterator — fundamentals" in first_fund.text + + async def test_legacy_variant_source_template( + self, legacy_catalog: Catalog, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr(server_module, "get_catalog", lambda: legacy_catalog) + async with Client(mcp) as client: + src = await client.read_resource("pattern://creational/oldthing/pythonic") first_src = src.contents[0] assert isinstance(first_src, TextResourceContents) - assert "__next__" in first_src.text + assert "pythonic oldthing runs" in first_src.text class TestPrompts: