diff --git a/.claude/commands/new-pattern.md b/.claude/commands/new-pattern.md index 7fdab6f..844f74b 100644 --- a/.claude/commands/new-pattern.md +++ b/.claude/commands/new-pattern.md @@ -1,17 +1,28 @@ --- -description: Scaffold a new pattern unit under patterns// +description: Scaffold a new pattern module under patterns// argument-hint: / "Pattern Name" --- -Scaffold a new pattern unit for $ARGUMENTS. +Scaffold a new pattern module for $ARGUMENTS. 1. Validate the group is one of: principle, python, creational, structural, behavioral, modern. Refuse anything else. -2. Create `patterns///` with the exact template from CLAUDE.md: - README.md (frontmatter with `id: /`, all schema keys present, - `verdict:` left as `use-with-care` with a `TODO` caveat), empty-but-importable - `__init__.py`, and stub `naive.py`, `pythonic.py`, `real_world.py` each with a - typed `main() -> None` and script guard, plus `tests/test_.py` with one - failing `test_todo` marked `xfail(reason="unit not yet written")`. -3. Run `make check` and report the result. Do not write the actual pattern content — +2. Create `patterns///` with the exact module template from CLAUDE.md: + - `README.md` — frontmatter with `id: /`, all schema keys present, + `verdict:` left as `use-with-care` with a `TODO` caveat; a ~10-line front door + mapping the folders. + - `__init__.py` and `pattern/__init__.py` holding ONLY as-alias re-export + lines (`from .pattern. import X as X` / `from . import X as X` + — no docstrings, no `__all__`); `pattern/.py` with a typed stub. + - `docs/fundamentals.md`, `docs/implementation.md`, `docs/examples.md` — each a + heading plus a `TODO` line naming what belongs there (classic-form contrast in + fundamentals; never use the word "naive"). + - `examples/demo/main.py` with a typed `main() -> None` + script guard + that imports from `...pattern`. Create NO other `__init__.py` — empty ones + are banned (PEP 420 namespace packages); the loader rejects them. + - `tests/test_.py` with one failing `test_todo` marked + `xfail(reason="unit not yet written")`. +3. Run `make check` and report the result. Note: the catalog loader will fail the + unit until the docs files, a runnable example importing `pattern/`, and real + tests exist — that is the point. Do not write the actual pattern content — scaffolding only. diff --git a/.gitignore b/.gitignore index 5c09459..2361930 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,9 @@ uv.lock # oh-my-claudecode runtime state .omc/ + +# local working files (plans, research briefs) +.cache/ + +# agent worktrees +.claude/worktrees/ diff --git a/AGENTS.md b/AGENTS.md index 6f5c045..4e3d4d7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,17 +1,17 @@ # Agent instructions — python-design-patterns Companion catalog to [python-patterns.guide](https://python-patterns.guide/): every design -pattern as runnable, tested, typed Python — plus an MCP server (`src/design_patterns_mcp/`) -that serves the catalog to agents. +pattern as a self-contained, tested, typed Python module — plus an MCP server +(`src/design_patterns/mcp/`) that serves the catalog to agents. ## Layout -- `patterns///` — one directory per pattern ("unit"). Groups: +- `patterns///` — one module per pattern ("unit"). Groups: `principle`, `python`, `creational`, `structural`, `behavioral`, `modern`. -- `src/design_patterns/` — catalog loader (frontmatter → typed `Pattern` objects). -- `src/design_patterns_mcp/` — FastMCP server (tools, resources, prompts, sandbox). -- Legacy flat dirs (`behavioral/`, `combos/`, `creational/`, `structural/`) are - pre-migration code: excluded from lint, deleted as units absorb them. Do not add to them. +- `src/design_patterns/` — catalog loader (frontmatter → typed `Pattern` objects, + strict structure validation in CI). +- `src/design_patterns/mcp/` — MCP server on the mcp 2.x SDK (`MCPServer`, not + FastMCP): tools, resources, prompts, sandbox. ## Pattern unit template @@ -19,19 +19,43 @@ Every unit has exactly this shape (scaffold one with `/new-pattern`): ``` patterns/// -├── README.md # YAML frontmatter + prose -├── __init__.py -├── naive.py # the literal 1994/Java-style translation -├── pythonic.py # what you actually write in Python -├── real_world.py # the pattern as it appears in the stdlib -└── tests/test_.py +├── README.md # YAML frontmatter + ~10-line front door: problem, verdict, folder map +├── __init__.py # public API — re-exports from pattern/ +├── pattern/ # the pattern as importable, typed library code +│ ├── __init__.py +│ └── .py # named for what it provides (chain.py, decorators.py, …) +├── docs/ +│ ├── fundamentals.md # intent, participants, mechanism, when/when-not, +│ │ # the classic (GoF) form as an annotated listing — never call it "naive" +│ ├── implementation.md# introducing it into a real system: smell, steps, idioms, pitfalls +│ └── examples.md # cited EXTERNAL usages: stdlib, OSS, articles +├── examples/ # runnable mini-projects that import pattern/ +│ └── / # realistic domain, no Foo/Bar; main.py + modules +└── tests/ # isolated: test_.py + test_.py ``` -- Each `.py` variant is import-safe (no side effects at import) and has a - `main() -> None` demo runnable as a script (`if __name__ == "__main__": main()`). -- Tests import the variants and assert behavior — never just "it runs". +No other `__init__.py` exist — namespace packages (PEP 420) carry the rest; +the loader rejects empty ones. + +- Everything import-safe (no side effects at import); mini-projects run via + `uv run python -m patterns...examples..main`. +- Tests assert behavior, never just "it runs"; load-bearing claims get the + mutation treatment (mutate the code, prove the suite fails, revert). +- Examples must genuinely build on `pattern/` — the loader and a catalog test + enforce the structure and the import; reviewers reject token imports. - Full type hints; `mypy --strict` must pass. +## House rules + +- Name-keyed registries refuse silent duplicates (`ValueError` unless + `replace=True`); ordered collections append freely. +- `ParamSpec` only where a wrapper callable is returned; identity typing otherwise. +- Never `None` as a cache sentinel; immutability guards recurse into containers. +- No empty `__init__.py` — delete them (PEP 420 namespace packages). One exists + only when load-bearing: the unit's public-API re-exports + (`from .pattern.x import Y as Y` — the as-alias form) or an import-time + effect the unit teaches. Never `__all__`. + ## Frontmatter schema (the MCP server indexes this — keep it valid) ```yaml @@ -47,14 +71,15 @@ stdlib_sightings: [functools.wraps, contextlib.contextmanager] ``` Verdicts: `pythonic` = use it as shown; `use-with-care` = valid but has sharp edges -(caveats say which); `prefer-alternative` = the naive form exists for study, the -pythonic file shows what to write instead (e.g. Singleton → module global, -Visitor → singledispatch). See `docs/verdicts.md`. +(caveats say which); `prefer-alternative` = the classic form exists for study in +docs/fundamentals.md, `pattern/` exports the alternative to write instead +(e.g. Singleton → module global, Visitor → singledispatch). ## Workflow - Branches: `main ← staging ← feat/`. PRs target `staging`. Never push to `main`. -- Gate before any PR: `make check` (ruff lint+format, mypy --strict, pytest+cov) green. +- Gate before any PR: `make check` (ruff lint+format, mypy --strict, pytest+cov, + readme-table drift) green. - Commit style: `: ` (`feat`, `fix`, `chore`, `docs`, `refactor`). - Toolchain is uv only — no pip/poetry. `make install` to set up. @@ -63,5 +88,6 @@ Visitor → singledispatch). See `docs/verdicts.md`. - Lead with the problem, not the pattern name's history. - Say plainly when Python makes the pattern unnecessary — that honesty is the point of the repo. Cite the guide chapter when one exists. -- naive.py mirrors the GoF book faithfully, even when un-Pythonic (that's its job); - pythonic.py is idiomatic; real_world.py points at real stdlib usage. +- The classic form in fundamentals.md mirrors the GoF book faithfully, even when + un-Pythonic (that's its job); `pattern/` is idiomatic; examples.md points at + real external usage. diff --git a/CLAUDE.md b/CLAUDE.md index 9eb6141..a4ca06f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,17 +1,17 @@ # python-design-patterns Companion catalog to [python-patterns.guide](https://python-patterns.guide/): every design -pattern as runnable, tested, typed Python — plus an MCP server (`src/design_patterns_mcp/`) -that serves the catalog to agents. +pattern as a self-contained, tested, typed Python module — plus an MCP server +(`src/design_patterns/mcp/`) that serves the catalog to agents. ## Layout -- `patterns///` — one directory per pattern ("unit"). Groups: +- `patterns///` — one module per pattern ("unit"). Groups: `principle`, `python`, `creational`, `structural`, `behavioral`, `modern`. -- `src/design_patterns/` — catalog loader (frontmatter → typed `Pattern` objects). -- `src/design_patterns_mcp/` — FastMCP server (tools, resources, prompts, sandbox). -- Legacy flat dirs (`behavioral/`, `combos/`, `creational/`, `structural/`) are - pre-migration code: excluded from lint, deleted as units absorb them. Do not add to them. +- `src/design_patterns/` — catalog loader (frontmatter → typed `Pattern` objects, + strict structure validation in CI). +- `src/design_patterns/mcp/` — MCP server on the mcp 2.x SDK (`MCPServer`, not + FastMCP): tools, resources, prompts, sandbox. ## Pattern unit template @@ -19,19 +19,43 @@ Every unit has exactly this shape (scaffold one with `/new-pattern`): ``` patterns/// -├── README.md # YAML frontmatter + prose -├── __init__.py -├── naive.py # the literal 1994/Java-style translation -├── pythonic.py # what you actually write in Python -├── real_world.py # the pattern as it appears in the stdlib -└── tests/test_.py +├── README.md # YAML frontmatter + ~10-line front door: problem, verdict, folder map +├── __init__.py # public API — re-exports from pattern/ +├── pattern/ # the pattern as importable, typed library code +│ ├── __init__.py +│ └── .py # named for what it provides (chain.py, decorators.py, …) +├── docs/ +│ ├── fundamentals.md # intent, participants, mechanism, when/when-not, +│ │ # the classic (GoF) form as an annotated listing — never call it "naive" +│ ├── implementation.md# introducing it into a real system: smell, steps, idioms, pitfalls +│ └── examples.md # cited EXTERNAL usages: stdlib, OSS, articles +├── examples/ # runnable mini-projects that import pattern/ +│ └── / # realistic domain, no Foo/Bar; main.py + modules +└── tests/ # isolated: test_.py + test_.py ``` -- Each `.py` variant is import-safe (no side effects at import) and has a - `main() -> None` demo runnable as a script (`if __name__ == "__main__": main()`). -- Tests import the variants and assert behavior — never just "it runs". +No other `__init__.py` exist — namespace packages (PEP 420) carry the rest; +the loader rejects empty ones. + +- Everything import-safe (no side effects at import); mini-projects run via + `uv run python -m patterns...examples..main`. +- Tests assert behavior, never just "it runs"; load-bearing claims get the + mutation treatment (mutate the code, prove the suite fails, revert). +- Examples must genuinely build on `pattern/` — the loader and a catalog test + enforce the structure and the import; reviewers reject token imports. - Full type hints; `mypy --strict` must pass. +## House rules + +- Name-keyed registries refuse silent duplicates (`ValueError` unless + `replace=True`); ordered collections append freely. +- `ParamSpec` only where a wrapper callable is returned; identity typing otherwise. +- Never `None` as a cache sentinel; immutability guards recurse into containers. +- No empty `__init__.py` — delete them (PEP 420 namespace packages). One exists + only when load-bearing: the unit's public-API re-exports + (`from .pattern.x import Y as Y` — the as-alias form) or an import-time + effect the unit teaches. Never `__all__`. + ## Frontmatter schema (the MCP server indexes this — keep it valid) ```yaml @@ -47,14 +71,15 @@ stdlib_sightings: [functools.wraps, contextlib.contextmanager] ``` Verdicts: `pythonic` = use it as shown; `use-with-care` = valid but has sharp edges -(caveats say which); `prefer-alternative` = the naive form exists for study, the -pythonic file shows what to write instead (e.g. Singleton → module global, -Visitor → singledispatch). See `docs/verdicts.md`. +(caveats say which); `prefer-alternative` = the classic form exists for study in +docs/fundamentals.md, `pattern/` exports the alternative to write instead +(e.g. Singleton → module global, Visitor → singledispatch). ## Workflow - Branches: `main ← staging ← feat/`. PRs target `staging`. Never push to `main`. -- Gate before any PR: `make check` (ruff lint+format, mypy --strict, pytest+cov) green. +- Gate before any PR: `make check` (ruff lint+format, mypy --strict, pytest+cov, + readme-table drift) green. - Commit style: `: ` (`feat`, `fix`, `chore`, `docs`, `refactor`). - Toolchain is uv only — no pip/poetry. `make install` to set up. @@ -63,5 +88,6 @@ Visitor → singledispatch). See `docs/verdicts.md`. - Lead with the problem, not the pattern name's history. - Say plainly when Python makes the pattern unnecessary — that honesty is the point of the repo. Cite the guide chapter when one exists. -- naive.py mirrors the GoF book faithfully, even when un-Pythonic (that's its job); - pythonic.py is idiomatic; real_world.py points at real stdlib usage. +- The classic form in fundamentals.md mirrors the GoF book faithfully, even when + un-Pythonic (that's its job); `pattern/` is idiomatic; examples.md points at + real external usage. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..5f1b84e --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,32 @@ +# Contributing + +## Workflow + +Branches flow `main ← staging ← feat/`. PRs target `staging`; `main` +takes only reviewed milestone merges. CI (3.11/3.12/3.13) must pass. + +## Adding a pattern unit + +1. Scaffold: `/new-pattern / "Name"` (Claude Code) or copy an + existing unit's shape (the template is in [CLAUDE.md](CLAUDE.md)). +2. Fill the frontmatter — every key; `id` must equal `/`; pick the + verdict (`pythonic` | `use-with-care` | `prefer-alternative`, defined in + [CLAUDE.md](CLAUDE.md)). The catalog loader validates this in CI and fails loudly. +3. Build the module: `pattern/` (the importable code), the three `docs/` files, + at least one `examples//` mini-project that genuinely imports + `pattern/` (entry point `main.py`, never `__main__.py`), and behavioral + tests for both. +4. `make check` — ruff, mypy --strict, pytest must all pass. The loader rejects + a unit missing any part of the template, and a catalog test rejects an + example that never imports its own pattern package. +5. `make readme` — regenerate the catalog table (CI rejects a stale one). + +## Quality bar + +- Full type hints; import-safe modules (no side effects at import). +- Tests assert behavior, not "it runs" — and load-bearing claims get the + mutation treatment (mutate the code, prove the suite fails, revert). Reviews + are severity-ordered; machines own style, humans argue design. +- Mini-projects use realistic domains, no Foo/Bar. +- Prose: one page, problem-first, no UML, no history lessons. The classic + (GoF) form lives in each unit's `docs/fundamentals.md` as an annotated listing. diff --git a/README.md b/README.md index 533c959..fcbc991 100644 --- a/README.md +++ b/README.md @@ -1,99 +1,68 @@ # Python Design Patterns -Look up any design pattern and see what a fluent Python developer would -*actually* write — the classic GoF form, the pythonic form, and where the -standard library already does it — with an honest verdict when the right -answer is "don't". All 23 Gang of Four patterns plus Python-native and -modern ones, every example typed, tested, and runnable. +Every design pattern as a fluent Python developer would actually write it — +the classic GoF form contrasted with the Python form, importable code, a +runnable mini-project, and an honest verdict when the right answer is "don't". -## Use it +## MCP + +```bash +# add to Claude Code +claude mcp add design-patterns -- uv run --directory python-design-patterns-mcp -Each pattern is a folder — read them in this order: +# stdio server, for any MCP client +uv run python-design-patterns-mcp +# streamable HTTP on /mcp +uv run python-design-patterns-mcp --http --host 127.0.0.1 --port 8734 ``` -patterns/structural/decorator/ -├── README.md # the problem, the trade-offs, the verdict -├── naive.py # the classic 1994 translation -├── pythonic.py # what you actually write in Python -└── real_world.py # where the stdlib already does this -``` - -Run any example: `uv run python -m patterns.structural.decorator.pythonic` -Give it to your agents (MCP server with search, runnable examples, and -pattern recommendations): +## Use it -```bash -claude mcp add design-patterns -- uv run --directory python-design-patterns-mcp +```python +from patterns.structural.decorator import retry, logged ``` +Run any mini-project: `uv run python -m patterns.structural.decorator.examples.resilient_client.main` + ## Catalog Based on [python-patterns.guide](https://python-patterns.guide/). -### Principles - -| Pattern | Verdict | Problem it solves | -|---|---|---| -| [Composition Over Inheritance](patterns/principle/composition_over_inheritance/) | ✅ pythonic | Vary independent behaviors without one subclass per combination of them. | - -### Python-native - -| Pattern | Verdict | Problem it solves | -|---|---|---| -| [Global Object](patterns/python/global_object/) | ⚠️ use with care | Give a whole program shared access to a constant or a pre-built object by assigning it at module level. | -| [Prebound Method](patterns/python/prebound_method/) | ✅ pythonic | Offer module-level functions that share state, by binding the methods of one hidden instance to module globals. | -| [Sentinel Object](patterns/python/sentinel_object/) | ✅ pythonic | Mark 'no value here' unambiguously when None itself is a legitimate value. | - -### Creational (GoF) - -| Pattern | Verdict | Problem it solves | -|---|---|---| -| [Abstract Factory](patterns/creational/abstract_factory/) | 🔄 prefer alternative | Let code build families of related objects without naming their concrete classes. | -| [Builder](patterns/creational/builder/) | ⚠️ use with care | Assemble a complex object step by step, so the assembly process is reusable and readable. | -| [Factory Method](patterns/creational/factory_method/) | 🔄 prefer alternative | Let a class defer which helper object it constructs, so subclasses or callers can substitute another. | -| [Prototype](patterns/creational/prototype/) | 🔄 prefer alternative | Create new objects by copying a pre-configured exemplar instead of constructing from scratch. | -| [Singleton](patterns/creational/singleton/) | 🔄 prefer alternative | Guarantee a class has exactly one instance and give the whole program access to it. | - -### Structural (GoF) - -| Pattern | Verdict | Problem it solves | -|---|---|---| -| [Adapter](patterns/structural/adapter/) | ✅ pythonic | Make an existing class usable through the interface your code expects, without editing either side. | -| [Bridge](patterns/structural/bridge/) | 🔄 prefer alternative | Let an abstraction and its implementation vary independently, instead of multiplying subclasses across both axes. | -| [Composite](patterns/structural/composite/) | ✅ pythonic | Let callers treat a single object and a whole tree of objects through one interface. | -| [Decorator](patterns/structural/decorator/) | ✅ pythonic | Add behavior around an object or callable without editing it or subclassing it. | -| [Facade](patterns/structural/facade/) | ✅ pythonic | Give a complicated subsystem one simple entry point for the common case. | -| [Flyweight](patterns/structural/flyweight/) | ⚠️ use with care | Support huge numbers of fine-grained objects by sharing immutable instances instead of duplicating them. | -| [Proxy](patterns/structural/proxy/) | ⚠️ use with care | Stand in for another object to control access to it — deferring, guarding, or instrumenting the real thing. | - -### Behavioral (GoF) - -| Pattern | Verdict | Problem it solves | -|---|---|---| -| [Chain of Responsibility](patterns/behavioral/chain_of_responsibility/) | 🔄 prefer alternative | Pass a request along a line of handlers until one of them takes it. | -| [Command](patterns/behavioral/command/) | ⚠️ use with care | Package a request as an object so it can be queued, logged, undone, or executed later by code that doesn't know its details. | -| [Interpreter](patterns/behavioral/interpreter/) | 🔄 prefer alternative | Represent a small language's grammar as data and evaluate sentences in it. | -| [Iterator](patterns/behavioral/iterator/) | ✅ pythonic | Traverse a container's elements without exposing how the container stores them. | -| [Mediator](patterns/behavioral/mediator/) | ⚠️ use with care | Stop a web of objects from referencing each other by routing their interactions through one coordinator. | -| [Memento](patterns/behavioral/memento/) | ⚠️ use with care | Capture an object's state so it can be restored later, without exposing its internals. | -| [Observer](patterns/behavioral/observer/) | ✅ pythonic | Notify interested parties when something changes, without the subject knowing who they are. | -| [State](patterns/behavioral/state/) | ⚠️ use with care | Change an object's behavior when its internal state changes, without an if-forest over a mode flag. | -| [Strategy](patterns/behavioral/strategy/) | 🔄 prefer alternative | Make an algorithm interchangeable at runtime without the caller knowing which variant it got. | -| [Template Method](patterns/behavioral/template_method/) | 🔄 prefer alternative | Fix an algorithm's skeleton while letting callers vary individual steps. | -| [Visitor](patterns/behavioral/visitor/) | 🔄 prefer alternative | Run a new operation over every node of an object structure without adding a method to every node class. | - -### Modern Python - -| Pattern | Verdict | Problem it solves | -|---|---|---| -| [Async Producer/Consumer](patterns/modern/async_producer_consumer/) | ⚠️ use with care | Decouple work generation from work processing under asyncio, with bounded memory and clean shutdown. | -| [Context Manager](patterns/modern/context_manager/) | ✅ pythonic | Guarantee acquire/release pairing around a block of code, even when it raises. | -| [Dependency Injection](patterns/modern/dependency_injection/) | ✅ pythonic | Hand an object its collaborators instead of letting it construct them, so they can be swapped — above all in tests. | -| [Registry](patterns/modern/registry/) | ✅ pythonic | Let implementations announce themselves by name, so dispatch is a lookup instead of an if/elif ladder. | -| [Repository](patterns/modern/repository/) | ⚠️ use with care | Keep domain logic ignorant of how objects are stored, behind a collection-like interface. | +| Pattern | Group | Verdict | Problem it solves | +|---|---|---|---| +| [Composition Over Inheritance](patterns/principle/composition_over_inheritance/) | Principles | ✅ pythonic | Vary independent behaviors without one subclass per combination of them. | +| [Global Object](patterns/python/global_object/) | Python-native | ⚠️ use with care | Give a whole program shared access to a constant or a pre-built object by assigning it at module level. | +| [Prebound Method](patterns/python/prebound_method/) | Python-native | ✅ pythonic | Offer module-level functions that share state, by binding the methods of one hidden instance to module globals. | +| [Sentinel Object](patterns/python/sentinel_object/) | Python-native | ✅ pythonic | Mark 'no value here' unambiguously when None itself is a legitimate value. | +| [Abstract Factory](patterns/creational/abstract_factory/) | Creational (GoF) | 🔄 prefer alternative | Let code build families of related objects without naming their concrete classes. | +| [Builder](patterns/creational/builder/) | Creational (GoF) | ⚠️ use with care | Assemble a complex object step by step, so the assembly process is reusable and readable. | +| [Factory Method](patterns/creational/factory_method/) | Creational (GoF) | 🔄 prefer alternative | Let a class defer which helper object it constructs, so subclasses or callers can substitute another. | +| [Prototype](patterns/creational/prototype/) | Creational (GoF) | 🔄 prefer alternative | Create new objects by copying a pre-configured exemplar instead of constructing from scratch. | +| [Singleton](patterns/creational/singleton/) | Creational (GoF) | 🔄 prefer alternative | Guarantee a class has exactly one instance and give the whole program access to it. | +| [Adapter](patterns/structural/adapter/) | Structural (GoF) | ✅ pythonic | Make an existing class usable through the interface your code expects, without editing either side. | +| [Bridge](patterns/structural/bridge/) | Structural (GoF) | 🔄 prefer alternative | Let an abstraction and its implementation vary independently, instead of multiplying subclasses across both axes. | +| [Composite](patterns/structural/composite/) | Structural (GoF) | ✅ pythonic | Let callers treat a single object and a whole tree of objects through one interface. | +| [Decorator](patterns/structural/decorator/) | Structural (GoF) | ✅ pythonic | Add behavior around an object or callable without editing it or subclassing it. | +| [Facade](patterns/structural/facade/) | Structural (GoF) | ✅ pythonic | Give a complicated subsystem one simple entry point for the common case. | +| [Flyweight](patterns/structural/flyweight/) | Structural (GoF) | ⚠️ use with care | Support huge numbers of fine-grained objects by sharing immutable instances instead of duplicating them. | +| [Proxy](patterns/structural/proxy/) | Structural (GoF) | ⚠️ use with care | Stand in for another object to control access to it — deferring, guarding, or instrumenting the real thing. | +| [Chain of Responsibility](patterns/behavioral/chain_of_responsibility/) | Behavioral (GoF) | 🔄 prefer alternative | Pass a request along a line of handlers until one of them takes it. | +| [Command](patterns/behavioral/command/) | Behavioral (GoF) | ⚠️ use with care | Package a request as an object so it can be queued, logged, undone, or executed later by code that doesn't know its details. | +| [Interpreter](patterns/behavioral/interpreter/) | Behavioral (GoF) | 🔄 prefer alternative | Represent a small language's grammar as data and evaluate sentences in it. | +| [Iterator](patterns/behavioral/iterator/) | Behavioral (GoF) | ✅ pythonic | Traverse a container's elements without exposing how the container stores them. | +| [Mediator](patterns/behavioral/mediator/) | Behavioral (GoF) | ⚠️ use with care | Stop a web of objects from referencing each other by routing their interactions through one coordinator. | +| [Memento](patterns/behavioral/memento/) | Behavioral (GoF) | ⚠️ use with care | Capture an object's state so it can be restored later, without exposing its internals. | +| [Observer](patterns/behavioral/observer/) | Behavioral (GoF) | ✅ pythonic | Notify interested parties when something changes, without the subject knowing who they are. | +| [State](patterns/behavioral/state/) | Behavioral (GoF) | ⚠️ use with care | Change an object's behavior when its internal state changes, without an if-forest over a mode flag. | +| [Strategy](patterns/behavioral/strategy/) | Behavioral (GoF) | 🔄 prefer alternative | Make an algorithm interchangeable at runtime without the caller knowing which variant it got. | +| [Template Method](patterns/behavioral/template_method/) | Behavioral (GoF) | 🔄 prefer alternative | Fix an algorithm's skeleton while letting callers vary individual steps. | +| [Visitor](patterns/behavioral/visitor/) | Behavioral (GoF) | 🔄 prefer alternative | Run a new operation over every node of an object structure without adding a method to every node class. | +| [Async Producer/Consumer](patterns/modern/async_producer_consumer/) | Modern Python | ⚠️ use with care | Decouple work generation from work processing under asyncio, with bounded memory and clean shutdown. | +| [Context Manager](patterns/modern/context_manager/) | Modern Python | ✅ pythonic | Guarantee acquire/release pairing around a block of code, even when it raises. | +| [Dependency Injection](patterns/modern/dependency_injection/) | Modern Python | ✅ pythonic | Hand an object its collaborators instead of letting it construct them, so they can be swapped — above all in tests. | +| [Registry](patterns/modern/registry/) | Modern Python | ✅ pythonic | Let implementations announce themselves by name, so dispatch is a lookup instead of an if/elif ladder. | +| [Repository](patterns/modern/repository/) | Modern Python | ⚠️ use with care | Keep domain logic ignorant of how objects are stored, behind a collection-like interface. | - -More in [docs/](docs/index.md) — verdict definitions, MCP reference, contributing. diff --git a/docs/code-review.md b/docs/code-review.md deleted file mode 100644 index 3d301aa..0000000 --- a/docs/code-review.md +++ /dev/null @@ -1,61 +0,0 @@ -# Code review standards - -The reviewer's contract for this repo — and a reusable checklist for any -Python team. - -## Layer 0: machines argue about style, humans argue about design - -These run in CI; a human review comment about anything they cover is wasted: - -| Tool | Standard it enforces | -|---|---| -| `ruff check` + `ruff format` | PEP 8, import order, bugbear/simplify/pyupgrade rule packs — each rule documented at [docs.astral.sh/ruff/rules](https://docs.astral.sh/ruff/rules/) | -| `mypy --strict` | PEP 484 typing, no untyped defs, no implicit Any | -| `pytest` + coverage | behavior, not just "it imports" | -| `python -m design_patterns.readme_table --check` | docs can't drift from code | - -Worth adding for security-sensitive work: `bandit` (SAST) and `pip-audit` -(dependency CVEs). Note: bandit flags every `assert` (B101) — in pytest -tests that's idiomatic, not a finding. - -## Layer 1: the written standards behind the tools - -- **PEP 8** (style) · **PEP 257** (docstrings) · **PEP 20** (design sensibility) -- **Google Python Style Guide** — the most common team-level extension -- This repo's own bar: [CLAUDE.md](../CLAUDE.md) (unit template, frontmatter - schema) and [verdicts.md](verdicts.md) - -## Layer 2: what human reviewers actually check - -Severity-ordered — block on CRITICAL/HIGH, note MEDIUM: - -**CRITICAL** -- Injection: user input reaching `eval`/`exec`, SQL strings, `subprocess` with `shell=True` -- Unsafe deserialization: `pickle.loads`/`yaml.load` on data crossing a trust boundary -- Secrets in code - -**HIGH** -- `assert` as a runtime guard (vanishes under `python -O`) -- Mutable default arguments; shared mutable module state -- Swallowed exceptions (`except: pass`), or `except Exception` hiding real errors -- Resources without context managers; missing cleanup on the error path -- Thread-safety claims the code doesn't earn (unguarded lazy init, shared caches) -- Unbounded recursion/loops on user-controlled input - -**MEDIUM** -- Work at import time (I/O, big computation) — see `patterns/python/global_object` -- `isinstance` traps (`bool` passes `int` checks), `is` vs `==` on sentinels -- API honesty: docstrings/comments that promise more than the code delivers -- A design pattern where a language feature suffices — check the catalog's verdict first - -## Reference sources for reviewers (MCP) - -- **This repo's own MCP server** — `claude mcp add design-patterns -- uv run --directory python-design-patterns-mcp`. `recommend_pattern` answers "should this be a Singleton?" with python-patterns.guide's verdicts and caveats; `get_pattern` serves the reference implementation to compare against. -- **Context7 MCP** — current library/framework docs, for "is this the right API usage?" questions. -- **python-patterns.guide** — the prose authority behind this catalog's verdicts. - -## Review etiquette - -- Cite the rule or the file, not taste ("B008: mutable default" beats "I don't like this"). -- One approval pass = one severity sweep top-down; don't drip-feed. -- The author of a change never approves it. diff --git a/docs/contributing.md b/docs/contributing.md deleted file mode 100644 index 691d628..0000000 --- a/docs/contributing.md +++ /dev/null @@ -1,24 +0,0 @@ -# Contributing - -## Workflow - -Branches flow `main ← staging ← feat/`. PRs target `staging`; `main` -takes only reviewed milestone merges. CI (3.11/3.12/3.13) must pass. - -## Adding a pattern unit - -1. Scaffold: `/new-pattern / "Name"` (Claude Code) or copy an - existing unit's shape. -2. Fill the frontmatter — every key; `id` must equal `/`; pick - the verdict per [verdicts.md](verdicts.md). The catalog loader validates - this in CI and fails loudly. -3. Write the three variants (see [how-to-read-this-repo.md](how-to-read-this-repo.md) - for what each is for) and behavioral tests for all of them. -4. `make check` — ruff, mypy --strict, pytest must all pass. -5. `make readme` — regenerate the catalog table (CI rejects a stale one). - -## Quality bar - -- Full type hints; import-safe modules (no side effects at import). -- Tests assert behavior, not "it runs". -- Prose: one page, problem-first, no UML, no history lessons. diff --git a/docs/how-to-read-this-repo.md b/docs/how-to-read-this-repo.md deleted file mode 100644 index df48409..0000000 --- a/docs/how-to-read-this-repo.md +++ /dev/null @@ -1,26 +0,0 @@ -# How to read this repo - -Every pattern lives in `patterns///` with the same five parts: - -| File | Job | -|---|---| -| `README.md` | YAML frontmatter (machine-readable metadata) + one page of prose: problem → naive → pythonic → in the wild → verdict | -| `naive.py` | The Gang-of-Four/Java translation, faithfully — even where it looks silly in Python. It exists so you can diff it against `pythonic.py` and *see* what the language absorbs. | -| `pythonic.py` | What a fluent Python developer writes for the same problem. When the pattern collapses into a language feature, this file shows the collapse and names it. | -| `real_world.py` | A small program using the stdlib's own embodiment of the pattern. | -| `tests/` | Behavioral tests for all three variants. | - -## Where to start - -- Reading for education: start with `principle/composition_over_inheritance`, - then any pattern whose *symptom* you recognize (the frontmatter lists them). -- Solving a problem now: search the catalog through the [MCP server](mcp.md) - (`recommend_pattern`) or skim the README table's "problem it solves" column. -- Every example runs: `uv run python -m patterns...`. - -## Groups - -`principle` · `python` (patterns native to the language, from -[python-patterns.guide](https://python-patterns.guide/)) · `creational` / -`structural` / `behavioral` (the GoF 23) · `modern` (post-GoF additions: -DI, Repository, Context Manager, Registry, async producer/consumer). diff --git a/docs/index.md b/docs/index.md deleted file mode 100644 index 208cff9..0000000 --- a/docs/index.md +++ /dev/null @@ -1,7 +0,0 @@ -# Documentation - -- [How to read this repo](how-to-read-this-repo.md) — the unit anatomy and where to start -- [Verdicts](verdicts.md) — what ✅ / ⚠️ / 🔄 mean, and who decides -- [MCP server](mcp.md) — connect agents to the catalog -- [Code review standards](code-review.md) — the reviewer's contract and severity checklist -- [Contributing](contributing.md) — adding or improving a pattern unit diff --git a/docs/mcp.md b/docs/mcp.md deleted file mode 100644 index 9519077..0000000 --- a/docs/mcp.md +++ /dev/null @@ -1,52 +0,0 @@ -# MCP server - -The catalog ships as an MCP server so agents can search the docs, read the -reference code, execute the examples, and get pattern recommendations with -honest verdicts attached. - -## Connect - -From a checkout: - -```bash -claude mcp add design-patterns -- uv run --directory /path/to/python-design-patterns python-design-patterns-mcp -``` - -Once published to PyPI: - -```bash -claude mcp add design-patterns -- uvx python-design-patterns-mcp -``` - -Remote/HTTP (Streamable HTTP on `/mcp`): - -```bash -python-design-patterns-mcp --http --host 127.0.0.1 --port 8734 -``` - -## Tools - -| Tool | What it does | -|---|---| -| `list_patterns(group?, verdict?)` | Catalog listing, filterable | -| `get_pattern(pattern_id, variant?)` | Full prose + example source (`naive`/`pythonic`/`real_world`/`all`) | -| `search_patterns(query, limit?)` | BM25 full-text search over names, aliases, problems, symptoms, prose | -| `run_example(pattern_id, variant)` | Executes the vendored example in a sandboxed subprocess; returns real stdout | -| `recommend_pattern(problem_statement, limit?)` | Ranked candidates with caveats; `prefer-alternative` verdicts tell you what to write instead | - -## Resources - -- `catalog://index` — the whole catalog as JSON -- `pattern:///` — one pattern's prose -- `pattern:////` — one example's source - -## Prompts - -`refactor_toward(pattern_id, code)` · `explain_pattern(pattern_id, audience?)` · `choose_pattern(problem)` - -## Sandbox contract - -`run_example` executes only files resolved from the catalog index — the -`(id, variant)` pair is a dictionary lookup, never joined into a path. The -subprocess runs `python -I` in a temp cwd with a scrubbed environment, a 10s -timeout, and 64KB output caps. There is no arbitrary-code-execution tool. diff --git a/docs/verdicts.md b/docs/verdicts.md deleted file mode 100644 index 2ffe7bc..0000000 --- a/docs/verdicts.md +++ /dev/null @@ -1,15 +0,0 @@ -# Verdicts - -Every unit's frontmatter carries one verdict — the catalog's honest answer to -"should I write this in Python?" - -| Verdict | Meaning | -|---|---| -| ✅ `pythonic` | Use it as shown in `pythonic.py`; the pattern (in its Python form) is what we'd genuinely recommend. | -| ⚠️ `use-with-care` | Legitimate uses exist, but each caveat in the frontmatter names a concrete failure mode. Read them first. | -| 🔄 `prefer-alternative` | The honest answer is usually "don't". The naive form exists for study; `pythonic.py` shows what to write instead (e.g. Singleton → module global, Visitor → `functools.singledispatch`). | - -Where [python-patterns.guide](https://python-patterns.guide/) has a chapter, -its verdict wins and the unit links it. Where it doesn't, we reason from the -same principles: composition over inheritance, callables over class -hierarchies, the language's own features over ceremony. diff --git a/patterns/__init__.py b/patterns/__init__.py deleted file mode 100644 index c2468b6..0000000 --- a/patterns/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Pattern catalog: one directory per pattern unit.""" diff --git a/patterns/behavioral/__init__.py b/patterns/behavioral/__init__.py deleted file mode 100644 index 51eeff6..0000000 --- a/patterns/behavioral/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""behavioral patterns.""" diff --git a/patterns/behavioral/chain_of_responsibility/README.md b/patterns/behavioral/chain_of_responsibility/README.md index aad0e2a..60ebd00 100644 --- a/patterns/behavioral/chain_of_responsibility/README.md +++ b/patterns/behavioral/chain_of_responsibility/README.md @@ -14,30 +14,17 @@ stdlib_sightings: [logging propagation, urllib.request opener chain] # Chain of Responsibility -## Problem - -A support ticket should be handled by the first tier able to deal with it; -an HTTP request passes middleware until something produces a response. The -sender must not know which handler will answer. - -## Naive solution - -`naive.py` threads successor pointers through handler objects, GoF-style: -each handler either handles or forwards to `self.successor`. - -## Pythonic solution - -A chain is a *list of callables* tried in order — the first non-`None` answer -wins. Registration is appending; reordering is list surgery; the -fell-off-the-end case is explicit. That's the whole pattern. - -## In the wild - -`logging` propagation is a chain: a record climbs the logger hierarchy, -offered to each logger's handlers on the way up. `urllib.request` passes -requests through its chain of openers/handlers until one claims the scheme. - -## Verdict - -**Prefer an alternative:** a list and a loop. Objects with successor -pointers, only if handlers already are stateful objects. +Pass a request along an ordered line of handlers until one takes it — without +the sender knowing which. **Verdict: prefer an alternative** — in Python the +chain is callables in a list, not objects with successor pointers. + +| Where | What | +|---|---| +| [`pattern/`](pattern/) | The importable code: `Chain`, `Handler`, `UnhandledRequestError` | +| [`docs/`](docs/) | [Fundamentals](docs/fundamentals.md) · [Implementation guide](docs/implementation.md) · [External examples](docs/examples.md) | +| [`examples/ticket_escalation/`](examples/ticket_escalation/) | Mini-project: support-ticket routing built on `pattern/` | +| [`tests/`](tests/) | Behavioral tests for the pattern and the mini-project | + +```bash +uv run python -m patterns.behavioral.chain_of_responsibility.examples.ticket_escalation.main +``` diff --git a/patterns/behavioral/chain_of_responsibility/__init__.py b/patterns/behavioral/chain_of_responsibility/__init__.py index 1885894..5c09c6e 100644 --- a/patterns/behavioral/chain_of_responsibility/__init__.py +++ b/patterns/behavioral/chain_of_responsibility/__init__.py @@ -1 +1,3 @@ -"""Chain of Responsibility: first handler that can, does. Verdict: a list and a loop.""" +from .pattern.chain import Chain as Chain +from .pattern.chain import Handler as Handler +from .pattern.chain import UnhandledRequestError as UnhandledRequestError diff --git a/patterns/behavioral/chain_of_responsibility/docs/examples.md b/patterns/behavioral/chain_of_responsibility/docs/examples.md new file mode 100644 index 0000000..50b4606 --- /dev/null +++ b/patterns/behavioral/chain_of_responsibility/docs/examples.md @@ -0,0 +1,38 @@ +# Chain of Responsibility — where it lives outside this repo + +Cited, real implementations to study (or point an agent at) when designing or +reviewing chain-shaped code. + +## Python standard library + +- **`logging` propagation.** A record emitted on a child logger climbs the + dot-separated logger hierarchy, offered to each ancestor's handlers until + `propagate` stops it — a chain wired by naming convention. + [docs.python.org/3/library/logging.html#logging.Logger.propagate](https://docs.python.org/3/library/logging.html#logging.Logger.propagate) +- **`urllib.request.OpenerDirector`.** Openers hold an ordered list of + `BaseHandler`s; each protocol method is tried on each handler in order until + one returns a non-`None` response — decline-by-`None`, exactly this module's + contract. [docs.python.org/3/library/urllib.request.html#urllib.request.OpenerDirector](https://docs.python.org/3/library/urllib.request.html#urllib.request.OpenerDirector) + +## Major ecosystems + +- **Django middleware.** Requests descend an ordered middleware stack; any + layer may short-circuit by returning a response, otherwise it delegates + inward. Ordering is explicit configuration (`MIDDLEWARE`), and the docs + discuss it as policy. + [docs.djangoproject.com/en/stable/topics/http/middleware/](https://docs.djangoproject.com/en/stable/topics/http/middleware/) +- **pluggy `firstresult` hooks** (the engine under pytest). Hook + implementations run in registration order until the first non-`None` result + wins — Chain of Responsibility offered as a library feature flag. + [pluggy.readthedocs.io/en/stable/#first-result-only](https://pluggy.readthedocs.io/en/stable/#first-result-only) +- **WSGI middleware (PEP 3333).** Applications wrap applications; each layer + answers or passes inward. The chain here is built by function composition + rather than a list. + [peps.python.org/pep-3333/](https://peps.python.org/pep-3333/) + +## What to notice across all of them + +Every production example makes two decisions the GoF text leaves open: the +**decline convention** (`None`, `propagate=False`, "call the next app") and +the **unhandled policy** (logging's `lastResort` handler, urllib raising +`URLError`, Django's 404). When reviewing chain code, check both are explicit. diff --git a/patterns/behavioral/chain_of_responsibility/docs/fundamentals.md b/patterns/behavioral/chain_of_responsibility/docs/fundamentals.md new file mode 100644 index 0000000..951e646 --- /dev/null +++ b/patterns/behavioral/chain_of_responsibility/docs/fundamentals.md @@ -0,0 +1,87 @@ +# Chain of Responsibility — fundamentals + +## Intent + +Avoid coupling the sender of a request to its receiver by giving more than one +handler a chance to act. The request travels an ordered chain until one handler +takes it; the sender never knows — and never needs to know — which one will. + +## Participants + +| Role | Classic (GoF) form | Python form | +|---|---|---| +| Handler contract | Abstract class with a successor pointer | Any callable `(request) -> answer \| None` — `None` means "decline, try the next" | +| Concrete handlers | Subclasses overriding an `_attempt` hook | Plain functions (or any callable) | +| The chain itself | Implicit in the successor links | An explicit ordered collection — `Chain` in [`pattern/chain.py`](../pattern/chain.py) | +| Client | Talks to the head of the chain | Calls `chain.handle(request)` | + +## Mechanism + +1. Handlers are placed in a deliberate order. +2. A request is offered to each handler in turn. +3. A handler either returns an answer (the chain stops) or declines by + returning `None` (the chain continues). +4. If every handler declines, the *caller's* chosen policy applies — raise + (`handle`) or fall back to a default (`handle_or`). GoF leaves this case + undefined; making it explicit is the one improvement you should always add. + +## The classic form, and what Python absorbs + +The textbook implementation threads a successor pointer through handler +*objects* — each one both does its work and forwards to the next: + +```python +class Handler(ABC): + def __init__(self, successor: Handler | None = None) -> None: + self.successor = successor # every handler carries the wiring + + def handle(self, severity: int) -> str: + answer = self._attempt(severity) + if answer is not None: + return answer + if self.successor is None: + return "unhandled" # the fall-off-the-end case, buried + return self.successor.handle(severity) + + @abstractmethod + def _attempt(self, severity: int) -> str | None: ... + + +class Helpdesk(Handler): ... + + +class Engineer(Handler): ... + + +class Management(Handler): ... + + +chain = Helpdesk(Engineer(Management())) # order hidden in nesting +``` + +Three classes, an ABC, and pointer bookkeeping — because 1994 languages had no +first-class functions. In Python the same design collapses: handlers are +functions, the chain is a list, dispatch is a loop. That collapse *is* the +pattern's Python lesson: what survives is not the class diagram but the two +ideas — **decline by convention** and **order as policy**. + +## When to use it + +- Several handlers could serve a request and the right one is known only at + runtime (escalation tiers, fallback strategies, middleware). +- You want to add, remove, or reorder handling policies without touching the + sender. + +## When not to use it + +- Exactly one receiver is ever right → a plain function call or a dict lookup. +- Every handler must see the request (notification, not handling) → that is + Observer, not a chain. +- The dispatch key is a simple value → `dict[key, handler]` beats scanning. + +## Verdict: prefer an alternative + +A list of callables and a loop is the whole pattern (this module's `Chain` is +that loop with a name and an explicit unhandled policy). Reach for +successor-pointer objects only when handlers are already stateful objects that +own their forwarding decision. diff --git a/patterns/behavioral/chain_of_responsibility/docs/implementation.md b/patterns/behavioral/chain_of_responsibility/docs/implementation.md new file mode 100644 index 0000000..33ea243 --- /dev/null +++ b/patterns/behavioral/chain_of_responsibility/docs/implementation.md @@ -0,0 +1,79 @@ +# Chain of Responsibility — putting it into a system + +## The smell it fixes + +An `if/elif` ladder that keeps growing, where each arm is really a policy +owned by a different concern: + +```python +def route(ticket): + if is_faq(ticket): + ... + elif ticket.severity >= 5: + ... + elif ticket.severity <= 2: + ... + else: + ... +``` + +Every new policy edits this one function. The chain inverts that: each policy +becomes a handler that owns its own "is this mine?" test, and the router +becomes data — an ordered list you configure. + +## Steps + +1. **Define the request and answer types.** Small frozen dataclasses work + well; the types make `mypy` police the handler contract for you. +2. **Extract each ladder arm into a handler** `(request) -> answer | None`. + The arm's condition becomes the handler's decline test (`return None`). +3. **Choose the order deliberately.** Order is policy: put short-circuiting + handlers (cache hits, emergencies) before general ones — `chain.insert(0, h)` + and `chain.remove(h)` edit that policy at runtime. Write a test that + pins the order's observable behavior. +4. **Decide the unhandled policy at the call site.** `chain.handle(req)` + raises `UnhandledRequestError`; `chain.handle_or(req, default)` substitutes + a fallback. Never let "no handler" pass silently. +5. **Assemble the chain in one place** (a `build_*_chain()` factory), so the + whole routing policy is readable — and swappable in tests. + +```python +from patterns.behavioral.chain_of_responsibility import Chain + +chain: Chain[Ticket, Resolution] = Chain([auto_responder, incident_commander, helpdesk]) +chain.register(on_call) # or grow it later / use as decorator +resolution = chain.handle_or(ticket, triage(ticket)) +``` + +## Python idioms that keep it small + +- Handlers are **plain functions** until they need state; then any callable + object or `functools.partial(handler, config)` slots in unchanged. +- `chain.register` as a **decorator** turns registration into a one-liner at + definition site — the same move `singledispatch` and Flask routes use. +- Parameterize, don't subclass: `partial(severity_gate, max_severity=2)` + replaces a class hierarchy of near-identical handlers. + +## Pitfalls + +- **Silent fall-off-the-end** — the GoF form's biggest trap; the unhandled + case must be a visible decision (step 4). +- **`None` as a real answer.** The decline convention reserves `None`; if your + domain needs "the answer is nothing", wrap answers or use a sentinel. +- **Order coupling nobody wrote down.** If swapping two handlers changes + behavior, a test must fail. Test the chain's routing table, not just each + handler. +- **Handlers that mutate the request** turn a dispatch chain into a pipeline — + a different pattern with different guarantees. Keep requests immutable. +- **Overlapping predicates** make the first match arbitrary; keep each + handler's claim test exclusive enough that order expresses priority, not + accident. + +## Worked example + +[`examples/ticket_escalation/`](../examples/ticket_escalation/) applies every +step above to support-ticket routing — run it with: + +```bash +uv run python -m patterns.behavioral.chain_of_responsibility.examples.ticket_escalation.main +``` diff --git a/patterns/behavioral/chain_of_responsibility/examples/ticket_escalation/handlers.py b/patterns/behavioral/chain_of_responsibility/examples/ticket_escalation/handlers.py new file mode 100644 index 0000000..7db0ec5 --- /dev/null +++ b/patterns/behavioral/chain_of_responsibility/examples/ticket_escalation/handlers.py @@ -0,0 +1,65 @@ +"""Escalation policies as handlers, and the chain that orders them. + +Each handler claims a ticket by returning a ``Resolution`` or declines with +``None``. The chain's order *is* the escalation policy: knowledge-base +auto-replies first, outages jump every queue, then the human tiers. +""" + +from __future__ import annotations + +from patterns.behavioral.chain_of_responsibility.examples.ticket_escalation.models import ( + Resolution, + Ticket, +) +from patterns.behavioral.chain_of_responsibility.pattern import Chain + +KNOWLEDGE_BASE = { + "password-reset": "KB-101: resetting your password", + "invoice-copy": "KB-204: downloading past invoices", +} + + +def auto_responder(ticket: Ticket) -> Resolution | None: + """Answer known FAQ topics instantly, without a human.""" + for tag in ticket.tags: + if tag in KNOWLEDGE_BASE: + return Resolution(ticket.id, "bot", f"sent {KNOWLEDGE_BASE[tag]}") + return None + + +def incident_commander(ticket: Ticket) -> Resolution | None: + """Outages and severity-5 tickets bypass every queue.""" + if ticket.severity >= 5 or "outage" in ticket.tags: + return Resolution(ticket.id, "incident", "declared incident, paged commander") + return None + + +def helpdesk(ticket: Ticket) -> Resolution | None: + """First human tier: routine tickets.""" + if 1 <= ticket.severity <= 2: + return Resolution(ticket.id, "helpdesk", "assigned to helpdesk queue") + return None + + +def engineering_on_call(ticket: Ticket) -> Resolution | None: + """Second human tier: defects and anything the helpdesk can't take.""" + if 3 <= ticket.severity <= 4: + return Resolution(ticket.id, "on-call", "paged engineering on-call") + return None + + +def build_escalation_chain() -> Chain[Ticket, Resolution]: + return Chain( + [ + auto_responder, + incident_commander, # before the human tiers: outages jump the queue + helpdesk, + engineering_on_call, + ] + ) + + +def route(ticket: Ticket, chain: Chain[Ticket, Resolution] | None = None) -> Resolution: + """Route one ticket; anything no policy claims goes to human triage.""" + escalation = chain if chain is not None else build_escalation_chain() + return escalation.handle_or(ticket, Resolution(ticket.id, "triage", "queued for human triage")) diff --git a/patterns/behavioral/chain_of_responsibility/examples/ticket_escalation/main.py b/patterns/behavioral/chain_of_responsibility/examples/ticket_escalation/main.py new file mode 100644 index 0000000..1abe37f --- /dev/null +++ b/patterns/behavioral/chain_of_responsibility/examples/ticket_escalation/main.py @@ -0,0 +1,23 @@ +"""Demo: a morning's tickets through the escalation chain.""" + +from __future__ import annotations + +from patterns.behavioral.chain_of_responsibility.examples.ticket_escalation.handlers import route +from patterns.behavioral.chain_of_responsibility.examples.ticket_escalation.models import Ticket + + +def main() -> None: + inbox = [ + Ticket("T-1", "Can't log in", 1, frozenset({"password-reset"})), + Ticket("T-2", "Wrong charge on invoice", 2, frozenset({"billing"})), + Ticket("T-3", "Export breaks on large files", 4, frozenset({"bug"})), + Ticket("T-4", "API returning 500s for everyone", 3, frozenset({"outage"})), + Ticket("T-5", "Feature idea: dark mode", 0, frozenset()), + ] + for ticket in inbox: + resolution = route(ticket) + print(f"{ticket.id} [{ticket.subject}] -> {resolution.team}: {resolution.action}") + + +if __name__ == "__main__": + main() diff --git a/patterns/behavioral/chain_of_responsibility/examples/ticket_escalation/models.py b/patterns/behavioral/chain_of_responsibility/examples/ticket_escalation/models.py new file mode 100644 index 0000000..ed2b4ce --- /dev/null +++ b/patterns/behavioral/chain_of_responsibility/examples/ticket_escalation/models.py @@ -0,0 +1,24 @@ +"""Domain types for the ticket-escalation mini-project.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + + +@dataclass(frozen=True) +class Ticket: + """A customer support ticket. Severity: 1 (question) .. 5 (outage).""" + + id: str + subject: str + severity: int + tags: frozenset[str] = field(default_factory=frozenset) + + +@dataclass(frozen=True) +class Resolution: + """Where a ticket ended up and why.""" + + ticket_id: str + team: str + action: str diff --git a/patterns/behavioral/chain_of_responsibility/naive.py b/patterns/behavioral/chain_of_responsibility/naive.py deleted file mode 100644 index e062941..0000000 --- a/patterns/behavioral/chain_of_responsibility/naive.py +++ /dev/null @@ -1,50 +0,0 @@ -"""The Gang of Four chain: successor pointers through handler objects.""" - -from __future__ import annotations - -from abc import ABC, abstractmethod - - -class Handler(ABC): - def __init__(self, successor: Handler | None = None) -> None: - self.successor = successor - - def handle(self, severity: int) -> str: - answer = self._attempt(severity) - if answer is not None: - return answer - if self.successor is None: - return "unhandled" - return self.successor.handle(severity) - - @abstractmethod - def _attempt(self, severity: int) -> str | None: ... - - -class Helpdesk(Handler): - def _attempt(self, severity: int) -> str | None: - return "helpdesk resolves it" if severity <= 1 else None - - -class Engineer(Handler): - def _attempt(self, severity: int) -> str | None: - return "engineer resolves it" if severity <= 3 else None - - -class Management(Handler): - def _attempt(self, severity: int) -> str | None: - return "management escalation" if severity <= 5 else None - - -def build_chain() -> Handler: - return Helpdesk(Engineer(Management())) - - -def main() -> None: - chain = build_chain() - for severity in (1, 3, 5, 9): - print(f"severity {severity}: {chain.handle(severity)}") - - -if __name__ == "__main__": - main() diff --git a/patterns/behavioral/chain_of_responsibility/pattern/__init__.py b/patterns/behavioral/chain_of_responsibility/pattern/__init__.py new file mode 100644 index 0000000..9121055 --- /dev/null +++ b/patterns/behavioral/chain_of_responsibility/pattern/__init__.py @@ -0,0 +1,3 @@ +from .chain import Chain as Chain +from .chain import Handler as Handler +from .chain import UnhandledRequestError as UnhandledRequestError diff --git a/patterns/behavioral/chain_of_responsibility/pattern/chain.py b/patterns/behavioral/chain_of_responsibility/pattern/chain.py new file mode 100644 index 0000000..a1ffdfd --- /dev/null +++ b/patterns/behavioral/chain_of_responsibility/pattern/chain.py @@ -0,0 +1,68 @@ +"""Chain of Responsibility as an importable, typed building block. + +A handler is any callable that returns an answer or ``None`` to decline. +``Chain`` tries its handlers in order; the first non-``None`` answer wins. +What an unhandled request means is the caller's decision: ``handle`` raises, +``handle_or`` falls back to a default. +""" + +from __future__ import annotations + +from collections.abc import Callable, Iterable, Iterator +from typing import Generic, TypeVar + +Req = TypeVar("Req") +Res = TypeVar("Res") + +Handler = Callable[[Req], "Res | None"] + + +class UnhandledRequestError(LookupError): + """No handler in the chain accepted the request.""" + + +class Chain(Generic[Req, Res]): + """An ordered chain of handlers; the first non-``None`` answer wins.""" + + def __init__(self, handlers: Iterable[Handler[Req, Res]] = ()) -> None: + self._handlers: list[Handler[Req, Res]] = list(handlers) + + def register(self, handler: Handler[Req, Res]) -> Handler[Req, Res]: + """Append a handler to the end of the chain; usable as a decorator.""" + self._handlers.append(handler) + return handler + + def insert(self, index: int, handler: Handler[Req, Res]) -> None: + """Insert a handler at ``index`` — order is policy, so it is editable.""" + self._handlers.insert(index, handler) + + def remove(self, handler: Handler[Req, Res]) -> None: + """Remove a handler; ``ValueError`` if it is not in the chain.""" + self._handlers.remove(handler) + + def handle(self, request: Req) -> Res: + """Return the first handler's answer; raise if every handler declines.""" + for handler in self._handlers: + answer = handler(request) + if answer is not None: + return answer + raise UnhandledRequestError(f"no handler accepted {request!r}") + + def handle_or(self, request: Req, default: Res) -> Res: + """Like ``handle``, but fall back to ``default`` instead of raising. + + Only this chain's own exhaustion falls back: an + ``UnhandledRequestError`` raised *inside* a handler (say, a nested + chain's ``handle``) propagates — it is a routing bug, not a decline. + """ + for handler in self._handlers: + answer = handler(request) + if answer is not None: + return answer + return default + + def __iter__(self) -> Iterator[Handler[Req, Res]]: + return iter(self._handlers) + + def __len__(self) -> int: + return len(self._handlers) diff --git a/patterns/behavioral/chain_of_responsibility/pythonic.py b/patterns/behavioral/chain_of_responsibility/pythonic.py deleted file mode 100644 index ac57252..0000000 --- a/patterns/behavioral/chain_of_responsibility/pythonic.py +++ /dev/null @@ -1,43 +0,0 @@ -"""The chain as a list of callables and one loop. - -Each handler returns an answer or None; the first answer wins, and the -unhandled case is explicit at the end of the loop. -""" - -from __future__ import annotations - -from collections.abc import Callable, Sequence - -Handler = Callable[[int], str | None] - - -def helpdesk(severity: int) -> str | None: - return "helpdesk resolves it" if severity <= 1 else None - - -def engineer(severity: int) -> str | None: - return "engineer resolves it" if severity <= 3 else None - - -def management(severity: int) -> str | None: - return "management escalation" if severity <= 5 else None - - -CHAIN: list[Handler] = [helpdesk, engineer, management] - - -def handle(severity: int, chain: Sequence[Handler] | None = None) -> str: - for handler in chain if chain is not None else CHAIN: - answer = handler(severity) - if answer is not None: - return answer - return "unhandled" # falling off the end is a decision, made visible - - -def main() -> None: - for severity in (1, 3, 5, 9): - print(f"severity {severity}: {handle(severity)}") - - -if __name__ == "__main__": - main() diff --git a/patterns/behavioral/chain_of_responsibility/real_world.py b/patterns/behavioral/chain_of_responsibility/real_world.py deleted file mode 100644 index 025fd39..0000000 --- a/patterns/behavioral/chain_of_responsibility/real_world.py +++ /dev/null @@ -1,35 +0,0 @@ -"""``logging`` propagation: a record climbs the logger hierarchy. - -A child logger with no handlers still gets its records delivered -- they -propagate up the chain until some ancestor's handler takes them. -""" - -from __future__ import annotations - -import logging - - -def chain_delivery(sink: list[str]) -> None: - """Log on the child; watch the parent's handler receive it.""" - - class ListHandler(logging.Handler): - def emit(self, record: logging.LogRecord) -> None: - sink.append(f"{record.name}: {record.getMessage()}") - - parent = logging.getLogger("cor_demo") - parent.handlers.clear() - parent.setLevel(logging.INFO) - parent.addHandler(ListHandler()) - - child = logging.getLogger("cor_demo.web.requests") # no handlers of its own - child.info("timeout on /api") - - -def main() -> None: - sink: list[str] = [] - chain_delivery(sink) - print(sink) - - -if __name__ == "__main__": - main() diff --git a/patterns/behavioral/chain_of_responsibility/tests/__init__.py b/patterns/behavioral/chain_of_responsibility/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/patterns/behavioral/chain_of_responsibility/tests/test_chain.py b/patterns/behavioral/chain_of_responsibility/tests/test_chain.py new file mode 100644 index 0000000..2dee9f8 --- /dev/null +++ b/patterns/behavioral/chain_of_responsibility/tests/test_chain.py @@ -0,0 +1,116 @@ +"""Behavioral tests for the Chain building block.""" + +from __future__ import annotations + +import pytest + +from patterns.behavioral.chain_of_responsibility import ( + Chain, + Handler, + UnhandledRequestError, +) + + +def helpdesk(severity: int) -> str | None: + return "helpdesk" if severity <= 1 else None + + +def engineer(severity: int) -> str | None: + return "engineer" if severity <= 3 else None + + +def management(severity: int) -> str | None: + return "management" if severity <= 5 else None + + +class TestDispatch: + def test_first_capable_handler_wins(self) -> None: + chain: Chain[int, str] = Chain([helpdesk, engineer, management]) + assert chain.handle(1) == "helpdesk" + assert chain.handle(3) == "engineer" + assert chain.handle(5) == "management" + + def test_order_is_policy(self) -> None: + reordered: Chain[int, str] = Chain([management, helpdesk]) + assert reordered.handle(1) == "management" + + def test_declining_handlers_are_skipped_not_consulted_again(self) -> None: + calls: list[str] = [] + + def declines(severity: int) -> str | None: + calls.append("declines") + return None + + def answers(severity: int) -> str | None: + calls.append("answers") + return "ok" + + def never_reached(severity: int) -> str | None: # pragma: no cover + calls.append("never") + return "late" + + chain: Chain[int, str] = Chain([declines, answers, never_reached]) + assert chain.handle(1) == "ok" + assert calls == ["declines", "answers"] + + +class TestUnhandledPolicy: + def test_handle_raises_with_the_request_in_the_message(self) -> None: + chain: Chain[int, str] = Chain([helpdesk]) + with pytest.raises(UnhandledRequestError, match="9"): + chain.handle(9) + + def test_handle_or_falls_back_to_default(self) -> None: + chain: Chain[int, str] = Chain([helpdesk]) + assert chain.handle_or(9, "triage") == "triage" + + def test_handle_or_propagates_a_nested_chains_unhandled_error(self) -> None: + # A handler that delegates to a misconfigured inner chain is a routing + # bug, not a decline — the outer default must NOT paper over it. + inner: Chain[int, str] = Chain([helpdesk]) + + def delegate(severity: int) -> str | None: + return inner.handle(severity) + + outer: Chain[int, str] = Chain([delegate]) + with pytest.raises(UnhandledRequestError): + outer.handle_or(9, "default") + + def test_empty_chain_is_explicitly_unhandled(self) -> None: + empty: Chain[int, str] = Chain() + with pytest.raises(UnhandledRequestError): + empty.handle(1) + + +class TestRegistration: + def test_register_appends_and_returns_the_handler(self) -> None: + chain: Chain[int, str] = Chain([helpdesk]) + returned = chain.register(engineer) + assert returned is engineer + assert list(chain) == [helpdesk, engineer] + assert chain.handle(3) == "engineer" + + def test_insert_reorders_policy(self) -> None: + chain: Chain[int, str] = Chain([helpdesk]) + chain.insert(0, management) + assert chain.handle(1) == "management" + assert list(chain) == [management, helpdesk] + + def test_remove_deletes_and_raises_on_unknown(self) -> None: + chain: Chain[int, str] = Chain([helpdesk, engineer]) + chain.remove(helpdesk) + assert chain.handle(1) == "engineer" + with pytest.raises(ValueError): + chain.remove(helpdesk) + + def test_register_works_as_a_decorator(self) -> None: + chain: Chain[int, str] = Chain() + + @chain.register + def catch_all(severity: int) -> str | None: + return "caught" + + handler: Handler[int, str] = catch_all + assert handler(0) == "caught" + assert len(chain) == 1 + assert chain.handle(99) == "caught" diff --git a/patterns/behavioral/chain_of_responsibility/tests/test_chain_of_responsibility.py b/patterns/behavioral/chain_of_responsibility/tests/test_chain_of_responsibility.py deleted file mode 100644 index 97f7c6e..0000000 --- a/patterns/behavioral/chain_of_responsibility/tests/test_chain_of_responsibility.py +++ /dev/null @@ -1,35 +0,0 @@ -"""Behavioral tests for all three chain-of-responsibility variants.""" - -from patterns.behavioral.chain_of_responsibility import naive, pythonic, real_world - - -class TestNaive: - def test_first_capable_handler_wins(self) -> None: - chain = naive.build_chain() - assert chain.handle(1) == "helpdesk resolves it" - assert chain.handle(3) == "engineer resolves it" - assert chain.handle(5) == "management escalation" - - def test_falling_off_the_end(self) -> None: - assert naive.build_chain().handle(9) == "unhandled" - - -class TestPythonic: - def test_list_chain_matches_naive(self) -> None: - assert pythonic.handle(1) == "helpdesk resolves it" - assert pythonic.handle(3) == "engineer resolves it" - assert pythonic.handle(9) == "unhandled" - - def test_reordering_is_list_surgery(self) -> None: - reordered: list[pythonic.Handler] = [pythonic.management, pythonic.helpdesk] - assert pythonic.handle(1, reordered) == "management escalation" - - def test_empty_chain_is_explicitly_unhandled(self) -> None: - assert pythonic.handle(1, []) == "unhandled" - - -class TestRealWorld: - def test_record_propagates_to_ancestor_handler(self) -> None: - sink: list[str] = [] - real_world.chain_delivery(sink) - assert sink == ["cor_demo.web.requests: timeout on /api"] diff --git a/patterns/behavioral/chain_of_responsibility/tests/test_ticket_escalation.py b/patterns/behavioral/chain_of_responsibility/tests/test_ticket_escalation.py new file mode 100644 index 0000000..9e77d14 --- /dev/null +++ b/patterns/behavioral/chain_of_responsibility/tests/test_ticket_escalation.py @@ -0,0 +1,66 @@ +"""Behavioral tests for the ticket-escalation mini-project.""" + +from __future__ import annotations + +import pytest + +from patterns.behavioral.chain_of_responsibility.examples.ticket_escalation.handlers import ( + build_escalation_chain, + route, +) +from patterns.behavioral.chain_of_responsibility.examples.ticket_escalation.main import main +from patterns.behavioral.chain_of_responsibility.examples.ticket_escalation.models import Ticket + + +def ticket(severity: int, tags: frozenset[str] = frozenset(), id_: str = "T-1") -> Ticket: + return Ticket(id_, "subject", severity, tags) + + +class TestRouting: + def test_faq_topics_are_answered_by_the_bot(self) -> None: + resolution = route(ticket(1, frozenset({"password-reset"}))) + assert resolution.team == "bot" + assert "KB-101" in resolution.action + + def test_routine_tickets_go_to_helpdesk(self) -> None: + assert route(ticket(2)).team == "helpdesk" + + def test_defects_page_engineering(self) -> None: + assert route(ticket(4, frozenset({"bug"}))).team == "on-call" + + def test_outages_jump_the_queue_regardless_of_severity(self) -> None: + low_severity_outage = ticket(2, frozenset({"outage"})) + assert route(low_severity_outage).team == "incident" + + def test_severity_five_is_an_incident_without_any_tag(self) -> None: + assert route(ticket(5)).team == "incident" + + def test_unclaimed_tickets_fall_back_to_human_triage(self) -> None: + feature_idea = ticket(0) + resolution = route(feature_idea) + assert resolution.team == "triage" + assert resolution.ticket_id == feature_idea.id + + def test_faq_beats_outage_because_the_bot_is_first(self) -> None: + both = ticket(5, frozenset({"password-reset", "outage"})) + assert route(both).team == "bot" + + +class TestChainShape: + def test_the_policy_is_four_handlers_in_documented_order(self) -> None: + chain = build_escalation_chain() + assert [h.__name__ for h in chain] == [ + "auto_responder", + "incident_commander", + "helpdesk", + "engineering_on_call", + ] + + +class TestDemo: + def test_main_routes_the_sample_inbox(self, capsys: pytest.CaptureFixture[str]) -> None: + main() + out = capsys.readouterr().out + assert "T-1" in out and "bot" in out + assert "T-4" in out and "incident" in out + assert "T-5" in out and "triage" in out diff --git a/patterns/behavioral/command/README.md b/patterns/behavioral/command/README.md index 93c117d..64278b3 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.main +``` diff --git a/patterns/behavioral/command/__init__.py b/patterns/behavioral/command/__init__.py index 9f0d6a2..74046f6 100644 --- a/patterns/behavioral/command/__init__.py +++ b/patterns/behavioral/command/__init__.py @@ -1 +1,3 @@ -"""Command: reify a request so it can be queued, logged, or undone.""" +from .pattern.commands import Action as Action +from .pattern.commands import Undoable as Undoable +from .pattern.commands import UndoStack as UndoStack 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..da340fb --- /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.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/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/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..a6714cb --- /dev/null +++ b/patterns/behavioral/command/pattern/__init__.py @@ -0,0 +1,3 @@ +from .commands import Action as Action +from .commands import Undoable as Undoable +from .commands import UndoStack as UndoStack 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/__init__.py b/patterns/behavioral/command/tests/__init__.py deleted file mode 100644 index e69de29..0000000 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..8c392c4 --- /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.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 + + +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..3cee412 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.main +``` diff --git a/patterns/behavioral/interpreter/__init__.py b/patterns/behavioral/interpreter/__init__.py index 5362fe1..9cd063f 100644 --- a/patterns/behavioral/interpreter/__init__.py +++ b/patterns/behavioral/interpreter/__init__.py @@ -1 +1,7 @@ -"""Interpreter: grammar as data. Verdict: use Python own parsers first.""" +from .pattern.rules import MAX_DEPTH as MAX_DEPTH +from .pattern.rules import Expr as Expr +from .pattern.rules import Interpreter as Interpreter +from .pattern.rules import Operation as Operation +from .pattern.rules import Resolver as Resolver +from .pattern.rules import Value as Value +from .pattern.safe_eval import safe_eval as 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..3c9ac1d --- /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.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/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/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..64864a3 --- /dev/null +++ b/patterns/behavioral/interpreter/pattern/__init__.py @@ -0,0 +1,7 @@ +from .rules import MAX_DEPTH as MAX_DEPTH +from .rules import Expr as Expr +from .rules import Interpreter as Interpreter +from .rules import Operation as Operation +from .rules import Resolver as Resolver +from .rules import Value as Value +from .safe_eval import safe_eval as 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/__init__.py b/patterns/behavioral/interpreter/tests/__init__.py deleted file mode 100644 index e69de29..0000000 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..7864a2f --- /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.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 _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..fb6667b 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.main +``` diff --git a/patterns/behavioral/iterator/__init__.py b/patterns/behavioral/iterator/__init__.py index c910a88..b4d8623 100644 --- a/patterns/behavioral/iterator/__init__.py +++ b/patterns/behavioral/iterator/__init__.py @@ -1 +1,2 @@ -"""Iterator: traverse a container without exposing its storage.""" +from .pattern.paging import PageFetcher as PageFetcher +from .pattern.paging import iterate_pages as 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..74c7a4a --- /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.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/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/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..6130fd6 --- /dev/null +++ b/patterns/behavioral/iterator/pattern/__init__.py @@ -0,0 +1,2 @@ +from .paging import PageFetcher as PageFetcher +from .paging import iterate_pages as 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/__init__.py b/patterns/behavioral/iterator/tests/__init__.py deleted file mode 100644 index e69de29..0000000 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..4d257e1 --- /dev/null +++ b/patterns/behavioral/iterator/tests/test_paginated_client.py @@ -0,0 +1,39 @@ +"""Behavioral tests for the paginated-client mini-project.""" + +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 _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..17b496b 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.main +``` diff --git a/patterns/behavioral/mediator/__init__.py b/patterns/behavioral/mediator/__init__.py index 42a9583..f637691 100644 --- a/patterns/behavioral/mediator/__init__.py +++ b/patterns/behavioral/mediator/__init__.py @@ -1 +1,2 @@ -"""Mediator: interactions routed through one coordinator.""" +from .pattern.form import Field as Field +from .pattern.form import Form as 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..1caf90f --- /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.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/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/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..ce2cccd --- /dev/null +++ b/patterns/behavioral/mediator/pattern/__init__.py @@ -0,0 +1,2 @@ +from .form import Field as Field +from .form import Form as 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/__init__.py b/patterns/behavioral/mediator/tests/__init__.py deleted file mode 100644 index e69de29..0000000 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..769956e --- /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.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..253f2c2 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.main +``` diff --git a/patterns/behavioral/memento/__init__.py b/patterns/behavioral/memento/__init__.py index b957db8..b4d3623 100644 --- a/patterns/behavioral/memento/__init__.py +++ b/patterns/behavioral/memento/__init__.py @@ -1 +1,2 @@ -"""Memento: capture state for later restore.""" +from .pattern.history import History as History +from .pattern.history import NoSnapshotError as 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..d6938a9 --- /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.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/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/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..1e8cbc9 --- /dev/null +++ b/patterns/behavioral/memento/pattern/__init__.py @@ -0,0 +1,2 @@ +from .history import History as History +from .history import NoSnapshotError as 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/__init__.py b/patterns/behavioral/memento/tests/__init__.py deleted file mode 100644 index e69de29..0000000 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..7ac0032 --- /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.editor import ConfigEditor +from patterns.behavioral.memento.examples.config_checkpoints.main import main +from patterns.behavioral.memento.examples.config_checkpoints.models import ( + InvalidConfigError, + ServiceConfig, +) + + +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..85e55fc 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.main +``` diff --git a/patterns/behavioral/observer/__init__.py b/patterns/behavioral/observer/__init__.py index 942e3df..263b01f 100644 --- a/patterns/behavioral/observer/__init__.py +++ b/patterns/behavioral/observer/__init__.py @@ -1 +1,3 @@ -"""Observer: broadcast changes to subscribed callables.""" +from .pattern.signal import ErrorPolicy as ErrorPolicy +from .pattern.signal import Signal as Signal +from .pattern.signal import Subscriber as 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..dd377dc --- /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.main +``` 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..bbe8b6d --- /dev/null +++ b/patterns/behavioral/observer/pattern/__init__.py @@ -0,0 +1,3 @@ +from .signal import ErrorPolicy as ErrorPolicy +from .signal import Signal as Signal +from .signal import Subscriber as 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/__init__.py b/patterns/behavioral/observer/tests/__init__.py deleted file mode 100644 index e69de29..0000000 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..c3370eb --- /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.main import main +from patterns.behavioral.observer.examples.order_events.subscribers import ( + AuditLog, + EmailNotifier, + MetricsCounter, + OrderPipeline, + flaky_webhook, +) + + +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..65a460a 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.main +``` diff --git a/patterns/behavioral/state/__init__.py b/patterns/behavioral/state/__init__.py index 74e5afe..4a08d9b 100644 --- a/patterns/behavioral/state/__init__.py +++ b/patterns/behavioral/state/__init__.py @@ -1 +1,4 @@ -"""State: behavior that changes with internal state.""" +from .pattern.machine import Guard as Guard +from .pattern.machine import IllegalTransitionError as IllegalTransitionError +from .pattern.machine import StateMachine as StateMachine +from .pattern.machine import Step as 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..961600e --- /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.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/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/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..2c03778 --- /dev/null +++ b/patterns/behavioral/state/pattern/__init__.py @@ -0,0 +1,4 @@ +from .machine import Guard as Guard +from .machine import IllegalTransitionError as IllegalTransitionError +from .machine import StateMachine as StateMachine +from .machine import Step as 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/__init__.py b/patterns/behavioral/state/tests/__init__.py deleted file mode 100644 index e69de29..0000000 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..147a1c3 --- /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.lifecycle import build_lifecycle +from patterns.behavioral.state.examples.order_lifecycle.main import main +from patterns.behavioral.state.examples.order_lifecycle.models import ( + Order, + OrderAction, + OrderStatus, +) + + +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..c0d54d4 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.main +``` diff --git a/patterns/behavioral/strategy/__init__.py b/patterns/behavioral/strategy/__init__.py index 5989b89..ee3a67c 100644 --- a/patterns/behavioral/strategy/__init__.py +++ b/patterns/behavioral/strategy/__init__.py @@ -1 +1,2 @@ -"""Strategy: interchangeable algorithms. Verdict: pass a function.""" +from .pattern.registry import StrategyRegistry as StrategyRegistry +from .pattern.registry import UnknownStrategyError as 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..2cbda4d --- /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.main +``` 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..1b75203 --- /dev/null +++ b/patterns/behavioral/strategy/pattern/__init__.py @@ -0,0 +1,2 @@ +from .registry import StrategyRegistry as StrategyRegistry +from .registry import UnknownStrategyError as 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/__init__.py b/patterns/behavioral/strategy/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/patterns/behavioral/strategy/tests/test_promotions.py b/patterns/behavioral/strategy/tests/test_promotions.py new file mode 100644 index 0000000..2d74db4 --- /dev/null +++ b/patterns/behavioral/strategy/tests/test_promotions.py @@ -0,0 +1,83 @@ +"""Behavioral tests for the promotions mini-project.""" + +from __future__ import annotations + +import pytest + +from patterns.behavioral.strategy.examples.promotions.main import main +from patterns.behavioral.strategy.examples.promotions.models import LineItem, Order +from patterns.behavioral.strategy.examples.promotions.rules import best_promo, due, promotion +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..3187a47 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.main +``` diff --git a/patterns/behavioral/template_method/__init__.py b/patterns/behavioral/template_method/__init__.py index e928fcb..dadc1fa 100644 --- a/patterns/behavioral/template_method/__init__.py +++ b/patterns/behavioral/template_method/__init__.py @@ -1 +1,3 @@ -"""Template Method: fixed skeleton, variable steps. Verdict: pass the steps.""" +from .pattern.skeleton import Skeleton as Skeleton +from .pattern.skeleton import discard as discard +from .pattern.skeleton import keep_all as 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..af2b87c --- /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.main +``` 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..5918c34 --- /dev/null +++ b/patterns/behavioral/template_method/pattern/__init__.py @@ -0,0 +1,3 @@ +from .skeleton import Skeleton as Skeleton +from .skeleton import discard as discard +from .skeleton import keep_all as 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/__init__.py b/patterns/behavioral/template_method/tests/__init__.py deleted file mode 100644 index e69de29..0000000 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..efb7b4c --- /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.main import main +from patterns.behavioral.template_method.examples.report_pipeline.models import Sale +from patterns.behavioral.template_method.examples.report_pipeline.pipeline import ( + build_csv_report, + build_markdown_report, + csv_rows, + drop_refunds, + markdown_table, +) + + +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..dbef21d 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.main +``` diff --git a/patterns/behavioral/visitor/__init__.py b/patterns/behavioral/visitor/__init__.py index 2e01634..f818c34 100644 --- a/patterns/behavioral/visitor/__init__.py +++ b/patterns/behavioral/visitor/__init__.py @@ -1 +1,2 @@ -"""Visitor: new operations over a node family. Verdict: singledispatch.""" +from .pattern.dispatch import Operation as Operation +from .pattern.dispatch import UnhandledNodeError as 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..cec5345 --- /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.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/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/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..379d77c --- /dev/null +++ b/patterns/behavioral/visitor/pattern/__init__.py @@ -0,0 +1,2 @@ +from .dispatch import Operation as Operation +from .dispatch import UnhandledNodeError as 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/__init__.py b/patterns/behavioral/visitor/tests/__init__.py deleted file mode 100644 index e69de29..0000000 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..d4eb7df --- /dev/null +++ b/patterns/behavioral/visitor/tests/test_doc_exporters.py @@ -0,0 +1,88 @@ +"""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.exporters import ( + markdown, + plain_text, + word_count, +) +from patterns.behavioral.visitor.examples.doc_exporters.main import main, sample_document +from patterns.behavioral.visitor.examples.doc_exporters.nodes import ( + BulletList, + CodeBlock, + Document, + Paragraph, + Section, +) + + +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/patterns/creational/__init__.py b/patterns/creational/__init__.py deleted file mode 100644 index d423003..0000000 --- a/patterns/creational/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""creational patterns.""" diff --git a/patterns/creational/abstract_factory/README.md b/patterns/creational/abstract_factory/README.md index 1c02510..c7a8b2e 100644 --- a/patterns/creational/abstract_factory/README.md +++ b/patterns/creational/abstract_factory/README.md @@ -9,38 +9,24 @@ verdict: prefer-alternative caveats: - "The pattern exists because 1990s languages could not pass classes or functions as values — Python can, so a factory is usually just a callable argument." - "Reach for a factory *object* only when the family of factories is large enough that bundling them beats passing them individually." + - "The bundled HTML family is teaching code, not a sanitizer: content is interpolated unescaped, so escape untrusted text before rendering." stdlib_sightings: [json.load parse_float, decimal.Decimal, unittest.mock] --- # Abstract Factory -## Problem - -A JSON parser must build numbers, but which number type — `float`? -`Decimal`? The parsing code shouldn't hardcode the class, and callers should -be able to swap the whole family of built objects (numbers, lists, maps) at -once. - -## Naive solution - -`naive.py` is the book's shape: an abstract factory interface, one concrete -factory per family, and client code programmed against the interface. - -## Pythonic solution - -Classes and functions are first-class, so the guide's advice is: accept -*callables*. `pythonic.py` renders one sales report through interchangeable -document families (HTML for the web app, Markdown for the CLI) — each family -a dataclass of builder callables that belong together, no abstract base -required. - -## In the wild - -`json.load(fp, parse_float=Decimal)` is the exact pattern: the stdlib parser -accepts factory callables for every family member it builds. `unittest.mock` -is a factory for stand-ins of anything. - -## Verdict - -**Prefer an alternative:** pass callables. Bundle them in an object only when -the family is genuinely large. +Build families of related objects without naming their concrete classes. +**Verdict: prefer an alternative** — in Python a factory is a callable +argument; bundle callables into a family object only when they must stay +consistent with each other. + +| Where | What | +|---|---| +| [`pattern/`](pattern/) | The importable code: `DocumentFamily`, `HTML`, `MARKDOWN` | +| [`docs/`](docs/) | [Fundamentals](docs/fundamentals.md) · [Implementation guide](docs/implementation.md) · [External examples](docs/examples.md) | +| [`examples/report_renderer/`](examples/report_renderer/) | Mini-project: one quarterly report through two document families | +| [`tests/`](tests/) | Behavioral tests for the pattern and the mini-project | + +```bash +uv run python -m patterns.creational.abstract_factory.examples.report_renderer.main +``` diff --git a/patterns/creational/abstract_factory/__init__.py b/patterns/creational/abstract_factory/__init__.py index afc6b8d..9062a18 100644 --- a/patterns/creational/abstract_factory/__init__.py +++ b/patterns/creational/abstract_factory/__init__.py @@ -1 +1,3 @@ -"""Abstract Factory: build families of objects. Verdict: pass callables.""" +from .pattern import HTML as HTML +from .pattern import MARKDOWN as MARKDOWN +from .pattern import DocumentFamily as DocumentFamily diff --git a/patterns/creational/abstract_factory/docs/examples.md b/patterns/creational/abstract_factory/docs/examples.md new file mode 100644 index 0000000..bd60752 --- /dev/null +++ b/patterns/creational/abstract_factory/docs/examples.md @@ -0,0 +1,39 @@ +# Abstract Factory — where it lives outside this repo + +Cited, real implementations to study (or point an agent at) when designing or +reviewing family-of-factories code. + +## Python standard library + +- **`json.load(fp, parse_float=Decimal, parse_int=...)`.** The parser builds + every number through the callables you hand it — the collapsed, pass-a- + callable form of the pattern, straight from the stdlib. Swap `float` for + `Decimal` and the whole document changes family. + [docs.python.org/3/library/json.html](https://docs.python.org/3/library/json.html) +- **`unittest.mock`.** A factory for stand-ins of anything: patching swaps a + whole family of collaborators for consistent doubles during a test. + [docs.python.org/3/library/unittest.mock.html](https://docs.python.org/3/library/unittest.mock.html) + +## Major ecosystems + +- **Django database backends.** Each backend's `DatabaseWrapper` bundles a + consistent family — creation, operations, introspection, client classes — + so the ORM never names a vendor class. Swapping `ENGINE` swaps the family. + [github.com/django/django/tree/main/django/db/backends](https://github.com/django/django/tree/main/django/db/backends) +- **SQLAlchemy dialects.** A dialect is a family of compiler, type, and + execution classes that must agree with each other per database; the core + programs against the dialect interface only. + [docs.sqlalchemy.org/en/20/dialects/](https://docs.sqlalchemy.org/en/20/dialects/) + +## The guide chapter + +python-patterns.guide's treatment — why first-class callables dissolve the +class ceremony, and what a factory object is still for: +[python-patterns.guide/gang-of-four/abstract-factory/](https://python-patterns.guide/gang-of-four/abstract-factory/) + +## What to notice across all of them + +The bundle earns its place exactly when members must stay **consistent** +(Django's creation/introspection pair, a dialect's compiler/types). Where no +consistency is needed, real APIs pass callables individually (`parse_float=`). +When reviewing, ask which case you are in — the answer picks the shape. diff --git a/patterns/creational/abstract_factory/docs/fundamentals.md b/patterns/creational/abstract_factory/docs/fundamentals.md new file mode 100644 index 0000000..b87cd10 --- /dev/null +++ b/patterns/creational/abstract_factory/docs/fundamentals.md @@ -0,0 +1,80 @@ +# Abstract Factory — fundamentals + +## Intent + +Let code build *families* of related objects without naming their concrete +classes — so the whole family can be swapped at once, and members of +different families never get mixed. + +## Participants + +| Role | Classic (GoF) form | Python form | +|---|---|---| +| Abstract factory | Interface with one creation method per product | A frozen dataclass of callables — [`DocumentFamily`](../pattern/family.py) | +| Concrete factory | One subclass per family | One dataclass *instance* per family (`HTML`, `MARKDOWN`) | +| Products | Class hierarchies per product kind | Whatever the callables return | +| Client | Programs against the interface | Accepts the family as a parameter | + +## Mechanism + +1. Identify the objects that must stay **consistent with each other** — that + consistency is the only reason to bundle factories at all. +2. Bundle one callable per product kind in a frozen dataclass. +3. Client code accepts the bundle and builds everything through it, never + naming a concrete class or format. +4. Swapping the family — for a different output target, or for test stubs — + changes every product together and cannot change only some of them. + +## The classic form, and what Python absorbs + +The textbook shape is an abstract class with one abstract method per product, +subclassed once per family: + +```python +class NumberFactory(ABC): + @abstractmethod + def build_number(self, text: str) -> object: ... + + +class FloatFactory(NumberFactory): + def build_number(self, text: str) -> object: + return float(text) + + +class DecimalFactory(NumberFactory): + def build_number(self, text: str) -> object: + return Decimal(text) + + +def parse_numbers(texts: list[str], factory: NumberFactory) -> list[object]: + return [factory.build_number(t) for t in texts] +``` + +That ceremony exists because 1990s languages could not pass a class or a +function as a value. Python can: `parse_numbers(texts, float)` needs no +interface and no subclasses — the stdlib itself ships this collapse as +`json.load(fp, parse_float=Decimal)`. What survives is only the *bundle*: when +several factories must stay consistent, group them in a frozen dataclass. + +## When to use it + +- Several created objects must belong to the same family, and mixing families + is a bug you want the structure to prevent. +- Whole-family swap is a real requirement: output targets, storage backends, + test doubles for everything at once. + +Note: the bundled `HTML` family interpolates content unescaped — it is +teaching code, not a sanitizer. Escape untrusted text before rendering. + +## When not to use it + +- One factory would do → pass a single callable; no bundle, no pattern. +- The "family" never varies → construct directly and skip the indirection. +- Members do not actually need to be consistent → separate parameters. + +## Verdict: prefer an alternative + +Pass callables. Reach for a factory *object* — the frozen dataclass bundle — +only when the family is large enough that bundling beats passing them +individually. This module's `DocumentFamily` is that bundle at its smallest +honest size: three builders that must agree. diff --git a/patterns/creational/abstract_factory/docs/implementation.md b/patterns/creational/abstract_factory/docs/implementation.md new file mode 100644 index 0000000..6b6b189 --- /dev/null +++ b/patterns/creational/abstract_factory/docs/implementation.md @@ -0,0 +1,79 @@ +# Abstract Factory — putting it into a system + +## The smell it fixes + +Client code that branches on a format or backend every time it builds +something: + +```python +def render_report(report, fmt): + if fmt == "html": + out.append(f"

{report.title}

") + elif fmt == "md": + out.append(f"## {report.title}") + ... # repeated for every element, in every function +``` + +Every new format edits every branch, and nothing stops one function emitting +HTML headings above Markdown tables. The family bundle inverts it: the format +decision is made once, at the edge, and travels as a value. + +## Steps + +1. **List the products that must stay consistent.** If there is only one, + stop here and pass a single callable. +2. **Define the family as a frozen dataclass of callables**, one field per + product kind, precisely typed. Frozen matters: a family that can be + mutated field-by-field can drift into a mixed family. +3. **Make client code accept the family as a parameter.** The client builds + everything through it and never names a concrete class, format, or + backend. +4. **Create one family instance per variant** (`HTML`, `MARKDOWN`, a stub + family in tests) at module level — instances, not subclasses. +5. **Choose the family at the edge** (CLI flag, request content-type, config) + and hand it down. Inner code stays format-blind. + +```python +from patterns.creational.abstract_factory import HTML, MARKDOWN, DocumentFamily + + +def render(family: DocumentFamily, report: Report) -> str: + parts = [family.heading(report.title)] + ... + + +render(MARKDOWN if args.cli else HTML, report) +``` + +## Python idioms that keep it small + +- **Families are instances, not classes.** A new family is a new + `DocumentFamily(...)` literal — no subclass, no registration. +- **Test doubles are just another family**: builders that record calls or + return markers, swapped in with zero patching. +- **Derive variants with `dataclasses.replace`**: a family that only changes + one builder shares the rest — `replace(HTML, callout=plain_callout)`. +- **Lambdas are fine for one-liner builders**; promote to named functions + when a builder grows logic worth testing alone. + +## Pitfalls + +- **Bundling factories that never vary together.** If callers always override + members individually, the bundle is friction — pass callables separately + (the `json.load(parse_float=...)` shape). +- **Letting the client peek at the concrete family** (`if family is HTML`). + One branch reintroduces everything the pattern removed. +- **Mutable families.** Without `frozen=True` a family can be half-edited at + runtime into a mix no one designed. +- **Growing the family for one client's needs.** Every field must be used by + every client; optional products belong in a different bundle. + +## Worked example + +[`examples/report_renderer/`](../examples/report_renderer/) renders one +quarterly report through the `MARKDOWN` and `HTML` families — same client +code, both outputs: + +```bash +uv run python -m patterns.creational.abstract_factory.examples.report_renderer.main +``` diff --git a/patterns/creational/abstract_factory/examples/report_renderer/main.py b/patterns/creational/abstract_factory/examples/report_renderer/main.py new file mode 100644 index 0000000..7d959a6 --- /dev/null +++ b/patterns/creational/abstract_factory/examples/report_renderer/main.py @@ -0,0 +1,37 @@ +"""Demo: one quarterly report through two document families.""" + +from __future__ import annotations + +from patterns.creational.abstract_factory.examples.report_renderer.renderer import render +from patterns.creational.abstract_factory.examples.report_renderer.report import ( + Report, + Section, + Table, +) +from patterns.creational.abstract_factory.pattern import HTML, MARKDOWN + +Q3 = Report( + title="Q3 review", + sections=( + Section( + title="Sales by region", + table=Table(("region", "revenue"), (("west", "$12k"), ("east", "$9k"))), + note="Figures exclude refunds.", + ), + Section( + title="Support load", + table=Table(("tier", "tickets"), (("helpdesk", "214"), ("on-call", "37"))), + ), + ), +) + + +def main() -> None: + print("--- Markdown (CLI) ---") + print(render(MARKDOWN, Q3)) + print("--- HTML (web) ---") + print(render(HTML, Q3)) + + +if __name__ == "__main__": + main() diff --git a/patterns/creational/abstract_factory/examples/report_renderer/renderer.py b/patterns/creational/abstract_factory/examples/report_renderer/renderer.py new file mode 100644 index 0000000..0b09fb5 --- /dev/null +++ b/patterns/creational/abstract_factory/examples/report_renderer/renderer.py @@ -0,0 +1,22 @@ +"""The client: renders a whole report without ever naming a format. + +Everything format-specific comes from the ``DocumentFamily`` argument. Handing +in ``MARKDOWN`` or ``HTML`` (or a family of test stubs) changes every element +consistently — the renderer itself never branches on format. +""" + +from __future__ import annotations + +from patterns.creational.abstract_factory.examples.report_renderer.report import Report +from patterns.creational.abstract_factory.pattern import DocumentFamily + + +def render(family: DocumentFamily, report: Report) -> str: + """Build the document through the family's builders only.""" + parts: list[str] = [family.heading(report.title)] + for section in report.sections: + parts.append(family.heading(section.title)) + parts.append(family.table(section.table.headers, section.table.rows)) + if section.note is not None: + parts.append(family.callout(section.note)) + return "\n".join(parts) diff --git a/patterns/creational/abstract_factory/examples/report_renderer/report.py b/patterns/creational/abstract_factory/examples/report_renderer/report.py new file mode 100644 index 0000000..613f0f3 --- /dev/null +++ b/patterns/creational/abstract_factory/examples/report_renderer/report.py @@ -0,0 +1,30 @@ +"""Domain types for the report-renderer mini-project.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Table: + """Tabular data, format-agnostic.""" + + headers: tuple[str, ...] + rows: tuple[tuple[str, ...], ...] + + +@dataclass(frozen=True) +class Section: + """One titled block of the report, with an optional callout note.""" + + title: str + table: Table + note: str | None = None + + +@dataclass(frozen=True) +class Report: + """A whole report: a title and its sections.""" + + title: str + sections: tuple[Section, ...] diff --git a/patterns/creational/abstract_factory/naive.py b/patterns/creational/abstract_factory/naive.py deleted file mode 100644 index c1fddb7..0000000 --- a/patterns/creational/abstract_factory/naive.py +++ /dev/null @@ -1,42 +0,0 @@ -"""The Gang of Four Abstract Factory, translated literally. - -An abstract factory interface, one concrete factory per "family", and a -client that never names a concrete class. -""" - -from __future__ import annotations - -from abc import ABC, abstractmethod -from decimal import Decimal - - -class NumberFactory(ABC): - """The abstract factory: builds the number family.""" - - @abstractmethod - def build_number(self, text: str) -> object: ... - - -class FloatFactory(NumberFactory): - def build_number(self, text: str) -> object: - return float(text) - - -class DecimalFactory(NumberFactory): - def build_number(self, text: str) -> object: - return Decimal(text) - - -def parse_numbers(texts: list[str], factory: NumberFactory) -> list[object]: - """The client: programmed against the interface only.""" - return [factory.build_number(t) for t in texts] - - -def main() -> None: - texts = ["1.1", "2.2"] - print(parse_numbers(texts, FloatFactory())) - print(parse_numbers(texts, DecimalFactory())) - - -if __name__ == "__main__": - main() diff --git a/patterns/creational/abstract_factory/pattern/__init__.py b/patterns/creational/abstract_factory/pattern/__init__.py new file mode 100644 index 0000000..9a51727 --- /dev/null +++ b/patterns/creational/abstract_factory/pattern/__init__.py @@ -0,0 +1,3 @@ +from .family import HTML as HTML +from .family import MARKDOWN as MARKDOWN +from .family import DocumentFamily as DocumentFamily diff --git a/patterns/creational/abstract_factory/pattern/family.py b/patterns/creational/abstract_factory/pattern/family.py new file mode 100644 index 0000000..26ef1e7 --- /dev/null +++ b/patterns/creational/abstract_factory/pattern/family.py @@ -0,0 +1,54 @@ +"""Abstract Factory as Python actually keeps it: a family of callables. + +The classic pattern exists so client code can build related objects without +naming their classes. In Python, factories are just callables, and a *family* +of factories that must stay consistent with each other is a frozen dataclass +bundling them. ``DocumentFamily`` is that bundle for document rendering: +swap the family and every element the client builds changes format together. +""" + +from __future__ import annotations + +from collections.abc import Callable, Sequence +from dataclasses import dataclass + + +@dataclass(frozen=True) +class DocumentFamily: + """A consistent set of document builders — the whole abstract factory. + + Clients accept a ``DocumentFamily`` and never name a concrete format; + frozen so a family cannot drift into a mixed one after construction. + """ + + heading: Callable[[str], str] + table: Callable[[Sequence[str], Sequence[Sequence[str]]], str] + callout: Callable[[str], str] + + +def _html_table(headers: Sequence[str], rows: Sequence[Sequence[str]]) -> str: + head = "".join(f"{h}" for h in headers) + body = "".join("" + "".join(f"{c}" for c in row) + "" for row in rows) + return f"{head}{body}
" + + +def _md_table(headers: Sequence[str], rows: Sequence[Sequence[str]]) -> str: + lines = [ + "| " + " | ".join(headers) + " |", + "|" + "---|" * len(headers), + *("| " + " | ".join(row) + " |" for row in rows), + ] + return "\n".join(lines) + + +HTML = DocumentFamily( + heading=lambda text: f"

{text}

", + table=_html_table, + callout=lambda text: f'
{text}
', +) + +MARKDOWN = DocumentFamily( + heading=lambda text: f"## {text}", + table=_md_table, + callout=lambda text: f"> {text}", +) diff --git a/patterns/creational/abstract_factory/pythonic.py b/patterns/creational/abstract_factory/pythonic.py deleted file mode 100644 index d289909..0000000 --- a/patterns/creational/abstract_factory/pythonic.py +++ /dev/null @@ -1,71 +0,0 @@ -"""What to write instead: factories are callables, families are dataclasses. - -The real shape: a report renderer that must emit HTML for the web app and -Markdown for the CLI -- three builders that must stay consistent with each -other (heading, table, callout). Each family is a dataclass of callables; -the renderer never names a concrete format. -""" - -from __future__ import annotations - -from collections.abc import Callable -from dataclasses import dataclass - - -@dataclass(frozen=True) -class DocumentFamily: - """The 'complete' abstract factory: builders that belong together.""" - - heading: Callable[[str], str] - table: Callable[[list[str], list[list[str]]], str] - callout: Callable[[str], str] - - -def _html_table(headers: list[str], rows: list[list[str]]) -> str: - head = "".join(f"{h}" for h in headers) - body = "".join("" + "".join(f"{c}" for c in row) + "" for row in rows) - return f"{head}{body}
" - - -def _md_table(headers: list[str], rows: list[list[str]]) -> str: - lines = [ - "| " + " | ".join(headers) + " |", - "|" + "---|" * len(headers), - *("| " + " | ".join(row) + " |" for row in rows), - ] - return "\n".join(lines) - - -HTML = DocumentFamily( - heading=lambda text: f"

{text}

", - table=_html_table, - callout=lambda text: f'
{text}
', -) - -MARKDOWN = DocumentFamily( - heading=lambda text: f"## {text}", - table=_md_table, - callout=lambda text: f"> {text}", -) - - -def render_sales_report(family: DocumentFamily, rows: list[list[str]]) -> str: - """The client: builds a whole document without naming a format.""" - return "\n".join( - [ - family.heading("Sales by region"), - family.table(["region", "revenue"], rows), - family.callout("Figures exclude refunds."), - ] - ) - - -def main() -> None: - rows = [["west", "$12k"], ["east", "$9k"]] - print(render_sales_report(MARKDOWN, rows)) - print() - print(render_sales_report(HTML, rows)) - - -if __name__ == "__main__": - main() diff --git a/patterns/creational/abstract_factory/real_world.py b/patterns/creational/abstract_factory/real_world.py deleted file mode 100644 index 6d0c1c8..0000000 --- a/patterns/creational/abstract_factory/real_world.py +++ /dev/null @@ -1,28 +0,0 @@ -"""The stdlib's abstract factory: ``json.loads`` parse hooks. - -The parser builds every float through the callable you hand it -- swap -``float`` for ``Decimal`` and the whole document changes family. -""" - -from __future__ import annotations - -import json -from decimal import Decimal - - -def load_exact(document: str) -> object: - """Parse JSON with exact decimal arithmetic instead of binary floats.""" - return json.loads(document, parse_float=Decimal) - - -def main() -> None: - doc = '{"price": 0.1, "qty": 3}' - default = json.loads(doc) - exact = load_exact(doc) - assert isinstance(exact, dict) and isinstance(default, dict) - print(f"float family: {default['price']!r}") - print(f"Decimal family: {exact['price']!r}") - - -if __name__ == "__main__": - main() diff --git a/patterns/creational/abstract_factory/tests/__init__.py b/patterns/creational/abstract_factory/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/patterns/creational/abstract_factory/tests/test_abstract_factory.py b/patterns/creational/abstract_factory/tests/test_abstract_factory.py deleted file mode 100644 index f9e6751..0000000 --- a/patterns/creational/abstract_factory/tests/test_abstract_factory.py +++ /dev/null @@ -1,48 +0,0 @@ -"""Behavioral tests for all three abstract-factory variants.""" - -from decimal import Decimal -from typing import ClassVar - -from patterns.creational.abstract_factory import naive, pythonic, real_world - - -class TestNaive: - def test_client_builds_through_the_interface(self) -> None: - floats = naive.parse_numbers(["1.5"], naive.FloatFactory()) - exacts = naive.parse_numbers(["1.5"], naive.DecimalFactory()) - assert floats == [1.5] and isinstance(floats[0], float) - assert exacts == [Decimal("1.5")] and isinstance(exacts[0], Decimal) - - -class TestPythonic: - ROWS: ClassVar[list[list[str]]] = [["west", "$12k"], ["east", "$9k"]] - - def test_markdown_family_renders_consistently(self) -> None: - doc = pythonic.render_sales_report(pythonic.MARKDOWN, self.ROWS) - assert doc.startswith("## Sales by region") - assert "| west | $12k |" in doc - assert doc.endswith("> Figures exclude refunds.") - - def test_html_family_renders_consistently(self) -> None: - doc = pythonic.render_sales_report(pythonic.HTML, self.ROWS) - assert "

Sales by region

" in doc - assert "west" in doc - assert '
' in doc - - def test_client_is_format_blind(self) -> None: - # A brand-new family works without touching the renderer. - plain = pythonic.DocumentFamily( - heading=str.upper, - table=lambda headers, rows: "; ".join(",".join(r) for r in rows), - callout=lambda text: f"NB: {text}", - ) - doc = pythonic.render_sales_report(plain, self.ROWS) - assert doc.splitlines()[0] == "SALES BY REGION" - - -class TestRealWorld: - def test_parse_float_hook_changes_the_family(self) -> None: - doc = real_world.load_exact('{"x": 0.1}') - assert isinstance(doc, dict) - assert doc["x"] == Decimal("0.1") - assert isinstance(doc["x"], Decimal) diff --git a/patterns/creational/abstract_factory/tests/test_family.py b/patterns/creational/abstract_factory/tests/test_family.py new file mode 100644 index 0000000..274721e --- /dev/null +++ b/patterns/creational/abstract_factory/tests/test_family.py @@ -0,0 +1,71 @@ +"""Behavioral tests for the DocumentFamily building block.""" + +from __future__ import annotations + +import dataclasses +from collections.abc import Sequence + +import pytest + +from patterns.creational.abstract_factory import HTML, MARKDOWN, DocumentFamily + +HEADERS = ["region", "revenue"] +ROWS = [["west", "$12k"], ["east", "$9k"]] + + +class TestFamilies: + def test_markdown_family_agrees_with_itself(self) -> None: + assert MARKDOWN.heading("Sales") == "## Sales" + table = MARKDOWN.table(HEADERS, ROWS) + assert table.splitlines()[0] == "| region | revenue |" + assert "| west | $12k |" in table + assert MARKDOWN.callout("note") == "> note" + + def test_html_family_agrees_with_itself(self) -> None: + assert HTML.heading("Sales") == "

Sales

" + table = HTML.table(HEADERS, ROWS) + assert table.startswith("") + assert "" in table + assert HTML.callout("note") == '
note
' + + def test_every_row_survives_in_both_families(self) -> None: + for family in (MARKDOWN, HTML): + table = family.table(HEADERS, ROWS) + for cell in ("west", "$12k", "east", "$9k"): + assert cell in table + + +def make_recording_family(calls: list[str]) -> DocumentFamily: + """A stub family whose builders record what the client asks for.""" + + def heading(text: str) -> str: + calls.append("heading") + return f"H({text})" + + def table(headers: Sequence[str], rows: Sequence[Sequence[str]]) -> str: + calls.append("table") + return "T" + + def callout(text: str) -> str: + calls.append("callout") + return "C" + + return DocumentFamily(heading=heading, table=table, callout=callout) + + +class TestFamilyDiscipline: + def test_families_are_frozen(self) -> None: + with pytest.raises(dataclasses.FrozenInstanceError): + MARKDOWN.heading = HTML.heading # type: ignore[misc] + + def test_replace_derives_a_consistent_variant(self) -> None: + plain = dataclasses.replace(HTML, callout=lambda text: f"

{text}

") + assert plain.callout("note") == "

note

" + assert plain.heading("Sales") == HTML.heading("Sales") # rest shared + + def test_markdown_table_carries_the_separator_row(self) -> None: + table = MARKDOWN.table(["region", "total"], [["west", "1280"]]) + lines = table.splitlines() + assert lines[0] == "| region | total |" + assert lines[1] == "|---|---|" # without it, the table is not Markdown + assert lines[2] == "| west | 1280 |" diff --git a/patterns/creational/abstract_factory/tests/test_report_renderer.py b/patterns/creational/abstract_factory/tests/test_report_renderer.py new file mode 100644 index 0000000..29c6994 --- /dev/null +++ b/patterns/creational/abstract_factory/tests/test_report_renderer.py @@ -0,0 +1,53 @@ +"""Behavioral tests for the report-renderer mini-project.""" + +from __future__ import annotations + +from patterns.creational.abstract_factory.examples.report_renderer.main import Q3 +from patterns.creational.abstract_factory.examples.report_renderer.renderer import render +from patterns.creational.abstract_factory.examples.report_renderer.report import ( + Report, + Section, + Table, +) +from patterns.creational.abstract_factory.pattern import HTML, MARKDOWN +from patterns.creational.abstract_factory.tests.test_family import make_recording_family + +REPORT = Report( + title="Weekly", + sections=( + Section( + title="Sales", + table=Table(("region", "revenue"), (("west", "$12k"),)), + note="Excludes refunds.", + ), + ), +) + + +class TestRenderer: + def test_same_report_both_families_same_content(self) -> None: + md = render(MARKDOWN, REPORT) + html = render(HTML, REPORT) + for content in ("Weekly", "Sales", "west", "$12k", "Excludes refunds."): + assert content in md + assert content in html + + def test_family_controls_every_element_consistently(self) -> None: + html = render(HTML, REPORT) + assert "

Weekly

" in html + assert "" in html + assert '
Excludes refunds.
' in html + assert "##" not in html # no other family's markup leaks in + + def test_note_is_optional(self) -> None: + bare = Report("R", (Section("S", Table(("h",), (("v",),))),)) + assert "callout" not in render(HTML, bare) + + def test_client_is_family_agnostic(self) -> None: + """A recording stub family sees exactly the calls the report implies.""" + calls: list[str] = [] + render(make_recording_family(calls), Q3) + # Q3: report heading + 2 section headings, 2 tables, 1 callout + assert calls.count("heading") == 3 + assert calls.count("table") == 2 + assert calls.count("callout") == 1 diff --git a/patterns/creational/builder/README.md b/patterns/creational/builder/README.md index dc1ebcd..7af6b72 100644 --- a/patterns/creational/builder/README.md +++ b/patterns/creational/builder/README.md @@ -14,37 +14,18 @@ stdlib_sightings: [email.message.EmailMessage, configparser.ConfigParser] # Builder -## Problem - -Some objects are miserable to construct in one shot: many parts, ordering -constraints, optional pieces. In 1994 Java/C++ the answer was a separate -Builder class walked by a Director, so the same step sequence could produce -different representations. - -## Naive solution - -`naive.py` is the full ceremony: an abstract builder interface, two concrete -builders, and a director that walks the steps. Faithful to the book — and -visibly over-engineered for Python. - -## Pythonic solution - -Python removes the two problems the pattern solved. Keyword arguments with -defaults kill the telescoping constructor, and first-class classes mean "the -same process, different representation" is just passing a different callable. -What *survives* is the Builder-as-convenience: a friendly object that -accumulates settings and then emits the real, immutable product — -`pythonic.py` builds a frozen dataclass through one. - -## In the wild - -`email.message.EmailMessage` is a builder you mutate call by call -(`msg["To"] = ...`, `set_content(...)`) before serializing; -`configparser.ConfigParser` accumulates sections the same way. Matplotlib's -`pyplot` interface is the guide's own headline example. - -## Verdict - -**Use with care.** Reach for keyword arguments first. Write a builder when -construction is genuinely staged or when you want a mutable assembly surface -in front of an immutable product. +Assemble a complex object step by step, with validation at each step and an +immutable result. **Verdict: use with care** — keyword arguments already +solve one-shot construction; a builder earns its keep only when assembly is +genuinely staged. + +| Where | What | +|---|---| +| [`pattern/`](pattern/) | The importable code: `SelectBuilder` (mutable, fluent) → `Query` (frozen) | +| [`docs/`](docs/) | [Fundamentals](docs/fundamentals.md) · [Implementation guide](docs/implementation.md) · [External examples](docs/examples.md) | +| [`examples/sql_select_builder/`](examples/sql_select_builder/) | Mini-project: order analytics on sqlite, every query builder-staged | +| [`tests/`](tests/) | Behavioral tests for the pattern and the mini-project | + +```bash +uv run python -m patterns.creational.builder.examples.sql_select_builder.main +``` diff --git a/patterns/creational/builder/__init__.py b/patterns/creational/builder/__init__.py index 8681512..b024190 100644 --- a/patterns/creational/builder/__init__.py +++ b/patterns/creational/builder/__init__.py @@ -1 +1,2 @@ -"""Builder: staged assembly of complex objects. Verdict: kwargs first.""" +from .pattern import Query as Query +from .pattern import SelectBuilder as SelectBuilder diff --git a/patterns/creational/builder/docs/examples.md b/patterns/creational/builder/docs/examples.md new file mode 100644 index 0000000..5d237ab --- /dev/null +++ b/patterns/creational/builder/docs/examples.md @@ -0,0 +1,41 @@ +# Builder — where it lives outside this repo + +Cited, real implementations to study (or point an agent at) when designing or +reviewing staged-construction code. + +## Python standard library + +- **`email.message.EmailMessage`.** Assembled call by call — headers by item + assignment, body by `set_content` — and only serialized at the end: the + builder-as-convenience in the stdlib. + [docs.python.org/3/library/email.message.html](https://docs.python.org/3/library/email.message.html) +- **`configparser.ConfigParser`.** Accumulates sections and values through a + mutable surface, then writes the finished representation out. + [docs.python.org/3/library/configparser.html](https://docs.python.org/3/library/configparser.html) + +## Major ecosystems + +- **SQLAlchemy `select()`.** `select(...).where(...).order_by(...)` is a + *generative* builder: each step returns a new immutable statement rather + than mutating one — the same product-immutability discipline, taken one + step further. [docs.sqlalchemy.org/en/20/core/selectable.html](https://docs.sqlalchemy.org/en/20/core/selectable.html) +- **Django `QuerySet` chaining.** `.filter(...).exclude(...).order_by(...)` + refines an immutable, lazily-executed query per call. + [docs.djangoproject.com/en/5.0/ref/models/querysets/](https://docs.djangoproject.com/en/5.0/ref/models/querysets/) +- **matplotlib `pyplot`.** The guide's headline example: a figure assembled + through many convenience calls against implicit current state. + [python-patterns.guide/gang-of-four/builder/](https://python-patterns.guide/gang-of-four/builder/) + +## The guide chapter + +python-patterns.guide's treatment — why keyword arguments dissolve the +telescoping constructor, and which builder survives: +[python-patterns.guide/gang-of-four/builder/](https://python-patterns.guide/gang-of-four/builder/) + +## What to notice across all of them + +None ship a Director, and none expose a mutable product: the stdlib builders +mutate *themselves* then emit/serialize, while SQLAlchemy and Django make even +the builder immutable (each step a new value). When reviewing a builder, ask +where the mutable/immutable line sits — and whether plain keyword arguments +would erase the class entirely. diff --git a/patterns/creational/builder/docs/fundamentals.md b/patterns/creational/builder/docs/fundamentals.md new file mode 100644 index 0000000..cb4cb93 --- /dev/null +++ b/patterns/creational/builder/docs/fundamentals.md @@ -0,0 +1,78 @@ +# Builder — fundamentals + +## Intent + +Separate the construction of a complex object from its representation, so a +staged assembly process can be reused, validated step by step, and finished +into a product the caller cannot half-build. + +## Participants + +| Role | Classic (GoF) form | Python form | +|---|---|---| +| Builder contract | Abstract class of build steps | The builder's method surface — no interface needed | +| Concrete builder | One subclass per representation | A small mutable class — [`SelectBuilder`](../pattern/query.py) | +| Director | A class that walks the steps | The caller's own code (or a plain function) | +| Product | Whatever was accumulated | A frozen dataclass — `Query` | + +## Mechanism + +1. The builder starts from the one thing every product needs (here: a table). +2. Each step accumulates state, validates what a one-shot constructor could + not express (placeholder counts, positive limits), and returns the builder + for chaining. +3. `build()` snapshots the state into an immutable product. Mutating the + builder afterwards cannot touch products already built. + +## The classic form, and what Python absorbs + +The textbook shape is a four-part ceremony — abstract builder, concrete +builders, and a Director that walks the steps: + +```python +class HouseBuilder(ABC): + @abstractmethod + def build_walls(self) -> None: ... + @abstractmethod + def build_roof(self) -> None: ... + + +class StoneHouseBuilder(HouseBuilder): ... + + +class WoodHouseBuilder(HouseBuilder): ... + + +class Director: + def construct(self, builder: HouseBuilder) -> House: + builder.build_walls() + builder.build_roof() + return builder.house +``` + +Python dissolves most of it. Keyword arguments with defaults already kill the +telescoping constructor the pattern was invented for, and "same process, +different representation" is just passing a different callable — no abstract +interface, no Director class. What survives (the guide's own verdict) is the +**convenience builder**: a friendly mutable surface in front of an immutable +product, matplotlib's `pyplot` being the canonical ecosystem example. + +## When to use it + +- Construction is genuinely staged: parts arrive over time, or under + conditions (`if product is not None: builder.where(...)`). +- Steps need validation *as they happen*, with errors at the faulty call. +- You want a mutable assembly surface but an immutable product. + +## When not to use it + +- All arguments are known at once → keyword arguments with defaults. A + builder here is ceremony imported from another language. +- Different representations from the same steps → pass a different callable + or family (see the abstract_factory unit), not a Director. + +## Verdict: use with care + +Reach for keyword arguments first. Write a builder when assembly is staged +and validated — and always split the mutable builder from a frozen product, +so "under construction" and "finished" are different types. diff --git a/patterns/creational/builder/docs/implementation.md b/patterns/creational/builder/docs/implementation.md new file mode 100644 index 0000000..1ab1d54 --- /dev/null +++ b/patterns/creational/builder/docs/implementation.md @@ -0,0 +1,78 @@ +# Builder — putting it into a system + +## The smell it fixes + +A constructor call that keeps growing conditionals around it: + +```python +conditions, params = [], [] +if region: + conditions.append("region = ?") + params.append(region) +if product: + conditions.append("product = ?") + params.append(product) +sql = "SELECT ... " + (" AND ".join(conditions) if conditions else "") # and so on +``` + +Every call site re-implements the assembly rules — clause ordering, the +conditions/params zip, edge cases — and any of them can drift. The builder +owns those rules once. + +## Steps + +1. **Define the product as a frozen dataclass.** Immutability is the payoff: + a finished product cannot be half-edited later, and it is safely shareable. +2. **Give the builder the product's invariants as constructor arguments** — + what every product must have (the table). Everything optional becomes a + step. +3. **Write each step to validate, accumulate, and `return self`.** Validate + *in* the step, so an error points at the faulty call, not at `build()`. +4. **Make `build()` a snapshot**, converting accumulated lists to tuples. + The builder stays usable; products built earlier stay untouched. +5. **Keep the builder dumb about execution.** It emits a product; running it + (here: handing `sql()`/`params` to sqlite) is someone else's job. + +```python +from patterns.creational.builder import SelectBuilder + +builder = SelectBuilder("orders").columns("id", "amount") +if minimum is not None: + builder.where("amount >= ?", minimum) # staged: only when asked for +query = builder.order_by("id").build() +rows = conn.execute(query.sql(), query.params) +``` + +## Python idioms that keep it small + +- **Try keyword arguments first.** If every caller can supply everything in + one call, `Query(table=..., columns=...)` needs no builder at all. +- **`return self` chaining** reads fluently, but each step working as a + statement too (`builder.where(...)` on its own line) keeps conditional + assembly natural. +- **Frozen product, plain-list builder** — the two-type split is the whole + discipline; resist a `mutable=False` flag on one class. +- **Parameters ride with the query.** Bundling `sql()` and `params` in the + product keeps values out of the SQL text — injection discipline for free. + +## Pitfalls + +- **The half-built object escaping.** If code can grab the builder's state + before `build()`, the "finished" guarantee is gone — keep accumulators + private. +- **Validation hoarded in `build()`.** Failing there points at the wrong + line; validate in the step that received the bad input. +- **A Director class.** The caller's own code walking the steps *is* the + director; a class for it is imported ceremony. +- **Builder reuse surprises.** Decide whether the builder may keep growing + after `build()` (this one may) and pin it in a test either way. + +## Worked example + +[`examples/sql_select_builder/`](../examples/sql_select_builder/) stages +three analytics queries — including a conditionally-narrowed one — and runs +them against a real in-memory sqlite database: + +```bash +uv run python -m patterns.creational.builder.examples.sql_select_builder.main +``` diff --git a/patterns/creational/builder/examples/sql_select_builder/database.py b/patterns/creational/builder/examples/sql_select_builder/database.py new file mode 100644 index 0000000..bace661 --- /dev/null +++ b/patterns/creational/builder/examples/sql_select_builder/database.py @@ -0,0 +1,21 @@ +"""An in-memory orders table for the mini-project to query.""" + +from __future__ import annotations + +import sqlite3 + +ORDERS = [ + ("A-1", "west", "widgets", 1200), + ("A-2", "east", "gears", 450), + ("A-3", "west", "widgets", 80), + ("A-4", "north", "sprockets", 3100), + ("A-5", "east", "widgets", 950), +] + + +def seed_orders() -> sqlite3.Connection: + """A fresh in-memory database with the sample orders.""" + conn = sqlite3.connect(":memory:") + conn.execute("CREATE TABLE orders (id TEXT, region TEXT, product TEXT, amount INTEGER)") + conn.executemany("INSERT INTO orders VALUES (?, ?, ?, ?)", ORDERS) + return conn diff --git a/patterns/creational/builder/examples/sql_select_builder/main.py b/patterns/creational/builder/examples/sql_select_builder/main.py new file mode 100644 index 0000000..8b63b41 --- /dev/null +++ b/patterns/creational/builder/examples/sql_select_builder/main.py @@ -0,0 +1,21 @@ +"""Demo: order analytics with builder-assembled queries.""" + +from __future__ import annotations + +from patterns.creational.builder.examples.sql_select_builder.database import seed_orders +from patterns.creational.builder.examples.sql_select_builder.reports import ( + big_orders, + orders_in_region, + top_orders, +) + + +def main() -> None: + conn = seed_orders() + print("top 3 orders:", top_orders(conn, 3)) + print("orders >= $900:", big_orders(conn, 900)) + print("west widgets:", orders_in_region(conn, "west", "widgets")) + + +if __name__ == "__main__": + main() diff --git a/patterns/creational/builder/examples/sql_select_builder/reports.py b/patterns/creational/builder/examples/sql_select_builder/reports.py new file mode 100644 index 0000000..30e82ed --- /dev/null +++ b/patterns/creational/builder/examples/sql_select_builder/reports.py @@ -0,0 +1,49 @@ +"""Analytics queries, each staged through the builder and run for real. + +The builder assembles a frozen ``Query``; sqlite executes it with the +parameters kept separate from the SQL text — the same discipline as any +production database layer. +""" + +from __future__ import annotations + +import sqlite3 + +from patterns.creational.builder.pattern import SelectBuilder + + +def _run(conn: sqlite3.Connection, builder: SelectBuilder) -> list[tuple[object, ...]]: + query = builder.build() + return [tuple(row) for row in conn.execute(query.sql(), query.params)] + + +def top_orders(conn: sqlite3.Connection, count: int) -> list[tuple[object, ...]]: + """The biggest orders, largest first.""" + builder = SelectBuilder("orders").columns("id", "amount").order_by("amount DESC").limit(count) + return _run(conn, builder) + + +def big_orders(conn: sqlite3.Connection, minimum: int) -> list[tuple[object, ...]]: + """Orders at or above a spend threshold.""" + builder = ( + SelectBuilder("orders") + .columns("id", "region", "amount") + .where("amount >= ?", minimum) + .order_by("id") + ) + return _run(conn, builder) + + +def orders_in_region( + conn: sqlite3.Connection, region: str, product: str | None = None +) -> list[tuple[object, ...]]: + """Orders for a region — optionally narrowed to one product. + + The builder's win over a one-shot call: the second condition is added + only when the caller asked for it. + """ + builder = SelectBuilder("orders").columns("id", "product", "amount") + builder.where("region = ?", region) + if product is not None: + builder.where("product = ?", product) + return _run(conn, builder.order_by("id")) diff --git a/patterns/creational/builder/naive.py b/patterns/creational/builder/naive.py deleted file mode 100644 index e6895a3..0000000 --- a/patterns/creational/builder/naive.py +++ /dev/null @@ -1,68 +0,0 @@ -"""The Gang of Four Builder, translated literally. - -Abstract builder interface + concrete builders + a Director that walks the -steps. The point of studying it: in Python, every one of these moving parts -except the concrete build steps is ceremony. -""" - -from __future__ import annotations - -from abc import ABC, abstractmethod - - -class House: - """The product under construction.""" - - def __init__(self) -> None: - self.parts: list[str] = [] - - def describe(self) -> str: - return " + ".join(self.parts) - - -class HouseBuilder(ABC): - """The abstract builder interface the Director programs against.""" - - def __init__(self) -> None: - self.house = House() - - @abstractmethod - def build_walls(self) -> None: ... - - @abstractmethod - def build_roof(self) -> None: ... - - -class StoneHouseBuilder(HouseBuilder): - def build_walls(self) -> None: - self.house.parts.append("stone walls") - - def build_roof(self) -> None: - self.house.parts.append("slate roof") - - -class WoodHouseBuilder(HouseBuilder): - def build_walls(self) -> None: - self.house.parts.append("timber walls") - - def build_roof(self) -> None: - self.house.parts.append("shingle roof") - - -class Director: - """Walks the build steps in order; knows nothing about representations.""" - - def construct(self, builder: HouseBuilder) -> House: - builder.build_walls() - builder.build_roof() - return builder.house - - -def main() -> None: - director = Director() - print(director.construct(StoneHouseBuilder()).describe()) - print(director.construct(WoodHouseBuilder()).describe()) - - -if __name__ == "__main__": - main() diff --git a/patterns/creational/builder/pattern/__init__.py b/patterns/creational/builder/pattern/__init__.py new file mode 100644 index 0000000..25e32ec --- /dev/null +++ b/patterns/creational/builder/pattern/__init__.py @@ -0,0 +1,2 @@ +from .query import Query as Query +from .query import SelectBuilder as SelectBuilder diff --git a/patterns/creational/builder/pattern/query.py b/patterns/creational/builder/pattern/query.py new file mode 100644 index 0000000..2851f1c --- /dev/null +++ b/patterns/creational/builder/pattern/query.py @@ -0,0 +1,92 @@ +"""What survives of the Builder in Python: staged assembly, frozen product. + +Keyword arguments already solve the telescoping constructor. A builder still +earns its keep when construction is genuinely staged and validated — here, a +fluent ``SelectBuilder`` accumulating clauses, emitting an immutable ``Query`` +(parameterized SQL, ``?`` placeholders) that mutating the builder afterwards +cannot touch. +""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Query: + """The immutable product: a parameterized SELECT statement.""" + + table: str + columns: tuple[str, ...] = ("*",) + conditions: tuple[str, ...] = () + params: tuple[object, ...] = () + order: tuple[str, ...] = () + limit_count: int | None = None + + def sql(self) -> str: + """Render the statement; values stay in ``params``, never in the text.""" + clauses = [f"SELECT {', '.join(self.columns)} FROM {self.table}"] + if self.conditions: + clauses.append("WHERE " + " AND ".join(self.conditions)) + if self.order: + clauses.append("ORDER BY " + ", ".join(self.order)) + if self.limit_count is not None: + clauses.append(f"LIMIT {self.limit_count}") + return " ".join(clauses) + + +class SelectBuilder: + """The mutable assembly surface in front of the frozen ``Query``. + + Every step returns ``self`` for chaining and validates what a one-shot + constructor could not express: placeholder counts, positive limits. + """ + + def __init__(self, table: str) -> None: + if not table: + raise ValueError("a query needs a table") + self._table = table + self._columns: list[str] = [] + self._conditions: list[str] = [] + self._params: list[object] = [] + self._order: list[str] = [] + self._limit: int | None = None + + def columns(self, *names: str) -> SelectBuilder: + """Select these columns (default when never called: ``*``).""" + self._columns.extend(names) + return self + + def where(self, condition: str, *params: object) -> SelectBuilder: + """AND-append a condition; ``?`` placeholders must match ``params``.""" + if condition.count("?") != len(params): + raise ValueError( + f"condition {condition!r} has {condition.count('?')} placeholder(s) " + f"but {len(params)} parameter(s)" + ) + self._conditions.append(condition) + self._params.extend(params) + return self + + def order_by(self, *terms: str) -> SelectBuilder: + """Append ORDER BY terms (e.g. ``"amount DESC"``).""" + self._order.extend(terms) + return self + + def limit(self, count: int) -> SelectBuilder: + """Cap the row count; must be positive.""" + if count < 1: + raise ValueError(f"limit must be positive, got {count}") + self._limit = count + return self + + def build(self) -> Query: + """Emit the frozen product; the builder may keep being used after.""" + return Query( + table=self._table, + columns=tuple(self._columns) or ("*",), + conditions=tuple(self._conditions), + params=tuple(self._params), + order=tuple(self._order), + limit_count=self._limit, + ) diff --git a/patterns/creational/builder/pythonic.py b/patterns/creational/builder/pythonic.py deleted file mode 100644 index 6b0f65d..0000000 --- a/patterns/creational/builder/pythonic.py +++ /dev/null @@ -1,48 +0,0 @@ -"""What survives of the Builder in Python. - -First: keyword arguments with defaults already solve the telescoping -constructor, so most "builders" should just be a call. Second: when assembly -really is staged, a small mutable builder in front of a frozen product keeps -the product immutable while giving callers a friendly surface. -""" - -from __future__ import annotations - -from dataclasses import dataclass, field - - -@dataclass(frozen=True) -class Pizza: - """The immutable product.""" - - size: str - toppings: tuple[str, ...] = () - - -def order_pizza(size: str = "medium", *toppings: str) -> Pizza: - """The kwargs 'builder': one readable call, no ceremony.""" - return Pizza(size=size, toppings=toppings) - - -@dataclass -class PizzaBuilder: - """The staged builder: mutate freely, then emit the frozen product.""" - - size: str = "medium" - _toppings: list[str] = field(default_factory=list) - - def topped_with(self, *toppings: str) -> PizzaBuilder: - self._toppings.extend(toppings) - return self # chainable - - def build(self) -> Pizza: - return Pizza(size=self.size, toppings=tuple(self._toppings)) - - -def main() -> None: - print(order_pizza("large", "basil", "mozzarella")) - print(PizzaBuilder(size="small").topped_with("olive").topped_with("caper").build()) - - -if __name__ == "__main__": - main() diff --git a/patterns/creational/builder/real_world.py b/patterns/creational/builder/real_world.py deleted file mode 100644 index bf4e733..0000000 --- a/patterns/creational/builder/real_world.py +++ /dev/null @@ -1,29 +0,0 @@ -"""The stdlib's builders. - -``email.message.EmailMessage`` is assembled call by call -- headers by -item assignment, body by ``set_content`` -- and only serialized at the end. -That is the Builder-as-convenience the guide describes. -""" - -from __future__ import annotations - -from email.message import EmailMessage - - -def build_email(sender: str, to: str, subject: str, body: str) -> EmailMessage: - """Staged assembly of an RFC 5322 message.""" - msg = EmailMessage() - msg["From"] = sender - msg["To"] = to - msg["Subject"] = subject - msg.set_content(body) - return msg - - -def main() -> None: - msg = build_email("a@example.com", "b@example.com", "hi", "Builder in the stdlib.\n") - print(msg.as_string()) - - -if __name__ == "__main__": - main() diff --git a/patterns/creational/builder/tests/__init__.py b/patterns/creational/builder/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/patterns/creational/builder/tests/test_builder.py b/patterns/creational/builder/tests/test_builder.py deleted file mode 100644 index df64010..0000000 --- a/patterns/creational/builder/tests/test_builder.py +++ /dev/null @@ -1,43 +0,0 @@ -"""Behavioral tests for all three builder variants.""" - -from patterns.creational.builder import naive, pythonic, real_world - - -class TestNaive: - def test_director_reuses_steps_across_representations(self) -> None: - director = naive.Director() - stone = director.construct(naive.StoneHouseBuilder()) - wood = director.construct(naive.WoodHouseBuilder()) - assert stone.describe() == "stone walls + slate roof" - assert wood.describe() == "timber walls + shingle roof" - - def test_each_construct_yields_a_fresh_product(self) -> None: - director = naive.Director() - assert director.construct(naive.StoneHouseBuilder()) is not director.construct( - naive.StoneHouseBuilder() - ) - - -class TestPythonic: - def test_kwargs_builder(self) -> None: - pizza = pythonic.order_pizza("large", "basil") - assert (pizza.size, pizza.toppings) == ("large", ("basil",)) - - def test_staged_builder_chains_and_freezes(self) -> None: - pizza = pythonic.PizzaBuilder(size="small").topped_with("olive", "caper").build() - assert pizza == pythonic.Pizza(size="small", toppings=("olive", "caper")) - - def test_product_is_immutable(self) -> None: - import dataclasses - - import pytest - - with pytest.raises(dataclasses.FrozenInstanceError): - pythonic.Pizza("medium").size = "large" # type: ignore[misc] - - -class TestRealWorld: - def test_email_assembles_headers_and_body(self) -> None: - msg = real_world.build_email("a@x.com", "b@x.com", "s", "body\n") - assert msg["To"] == "b@x.com" - assert msg.get_content() == "body\n" diff --git a/patterns/creational/builder/tests/test_query.py b/patterns/creational/builder/tests/test_query.py new file mode 100644 index 0000000..47167c3 --- /dev/null +++ b/patterns/creational/builder/tests/test_query.py @@ -0,0 +1,69 @@ +"""Behavioral tests for the SelectBuilder / Query building block.""" + +from __future__ import annotations + +import dataclasses + +import pytest + +from patterns.creational.builder import Query, SelectBuilder + + +class TestAssembly: + def test_minimal_query_defaults_to_star(self) -> None: + query = SelectBuilder("orders").build() + assert query.sql() == "SELECT * FROM orders" + assert query.params == () + + def test_full_query_renders_clauses_in_sql_order(self) -> None: + query = ( + SelectBuilder("orders") + .columns("id", "amount") + .where("region = ?", "west") + .where("amount >= ?", 100) + .order_by("amount DESC") + .limit(5) + .build() + ) + assert query.sql() == ( + "SELECT id, amount FROM orders " + "WHERE region = ? AND amount >= ? " + "ORDER BY amount DESC LIMIT 5" + ) + assert query.params == ("west", 100) + + def test_steps_work_as_statements_for_conditional_assembly(self) -> None: + builder = SelectBuilder("orders") + builder.where("region = ?", "east") + query = builder.build() + assert "WHERE region = ?" in query.sql() + + +class TestStagedValidation: + def test_empty_table_rejected_at_start(self) -> None: + with pytest.raises(ValueError, match="needs a table"): + SelectBuilder("") + + def test_placeholder_count_mismatch_fails_at_the_faulty_step(self) -> None: + with pytest.raises(ValueError, match="1 placeholder"): + SelectBuilder("orders").where("region = ?") + + def test_non_positive_limit_rejected(self) -> None: + with pytest.raises(ValueError, match="positive"): + SelectBuilder("orders").limit(0) + + +class TestProductImmutability: + def test_product_is_frozen(self) -> None: + query = SelectBuilder("orders").build() + with pytest.raises(dataclasses.FrozenInstanceError): + query.table = "other" # type: ignore[misc] + + def test_mutating_the_builder_after_build_leaves_products_alone(self) -> None: + builder = SelectBuilder("orders").columns("id") + first = builder.build() + builder.where("amount >= ?", 100).limit(1) + second = builder.build() + assert first.sql() == "SELECT id FROM orders" # untouched + assert first != second + assert isinstance(second, Query) diff --git a/patterns/creational/builder/tests/test_sql_select_builder.py b/patterns/creational/builder/tests/test_sql_select_builder.py new file mode 100644 index 0000000..708a1cd --- /dev/null +++ b/patterns/creational/builder/tests/test_sql_select_builder.py @@ -0,0 +1,35 @@ +"""Behavioral tests for the sql_select_builder mini-project — real sqlite rows.""" + +from __future__ import annotations + +from patterns.creational.builder.examples.sql_select_builder.database import seed_orders +from patterns.creational.builder.examples.sql_select_builder.reports import ( + big_orders, + orders_in_region, + top_orders, +) + + +class TestReports: + def test_top_orders_come_largest_first(self) -> None: + conn = seed_orders() + assert top_orders(conn, 3) == [("A-4", 3100), ("A-1", 1200), ("A-5", 950)] + + def test_big_orders_filters_by_threshold(self) -> None: + conn = seed_orders() + rows = big_orders(conn, 900) + assert [row[0] for row in rows] == ["A-1", "A-4", "A-5"] + assert all(isinstance(row[2], int) and row[2] >= 900 for row in rows) + + def test_region_report_narrows_conditionally(self) -> None: + conn = seed_orders() + east_all = orders_in_region(conn, "east") + east_widgets = orders_in_region(conn, "east", "widgets") + assert [row[0] for row in east_all] == ["A-2", "A-5"] + # east carries two products, so the product filter must actually narrow. + assert [row[0] for row in east_widgets] == ["A-5"] + assert orders_in_region(conn, "east", "gears") == [("A-2", "gears", 450)] + + def test_no_rows_is_an_empty_list_not_an_error(self) -> None: + conn = seed_orders() + assert orders_in_region(conn, "south") == [] diff --git a/patterns/creational/factory_method/README.md b/patterns/creational/factory_method/README.md index 4df10d4..c7881ae 100644 --- a/patterns/creational/factory_method/README.md +++ b/patterns/creational/factory_method/README.md @@ -14,34 +14,18 @@ stdlib_sightings: [http.client.HTTPConnection.response_class, json.JSONDecoder] # Factory Method -## Problem - -A class needs a helper object mid-work — an HTTP connection needs a response -object — and users must be able to substitute their own helper class without -rewriting the containing class. - -## Naive solution - -`naive.py` is the book's: an abstract creator with an abstract -`factory_method()`, and one subclass per helper choice. Note the cost — a -subclass per configuration, just to change one constructor call. - -## Pythonic solution - -The guide's ranking, in `pythonic.py`: (1) **dependency injection** — just -pass the helper in; (2) a **class attribute factory** — creation stays -internal, but overriding is assignment or a one-line subclass, and *any* -callable is accepted; (3) an **instance attribute factory** for per-object -overrides without any subclass at all. - -## In the wild - -`http.client.HTTPConnection.response_class` is the canonical class attribute -factory: subclass, point it at your response type, done. `json.JSONDecoder` -does the same with its parse hooks. - -## Verdict - -**Prefer an alternative:** inject the dependency; failing that, a class -attribute factory. The abstract-method form is Java with the serial numbers -filed off. +Let a class defer which helper it constructs, so subclasses, callers, or tests +substitute another. **Verdict: prefer an alternative** — inject the object, or +make the constructor call a class-attribute slot; the abstract-method form is +Java with the serial numbers filed off. + +| Where | What | +|---|---| +| [`pattern/`](pattern/) | The importable code: `factory_slot` (trap-safe class-attribute factories) and the `Factory` alias; the three dodges — injection, class-attribute slot, instance override — documented best first | +| [`docs/`](docs/) | [Fundamentals](docs/fundamentals.md) · [Implementation guide](docs/implementation.md) · [External examples](docs/examples.md) | +| [`examples/feed_client/`](examples/feed_client/) | Mini-project: a feed-client framework with a `response_class` slot | +| [`tests/`](tests/) | Behavioral tests for the pattern and the mini-project | + +```bash +uv run python -m patterns.creational.factory_method.examples.feed_client.main +``` diff --git a/patterns/creational/factory_method/__init__.py b/patterns/creational/factory_method/__init__.py index 089799e..94d7203 100644 --- a/patterns/creational/factory_method/__init__.py +++ b/patterns/creational/factory_method/__init__.py @@ -1 +1,2 @@ -"""Factory Method: defer which helper gets built. Verdict: inject, or class attribute.""" +from .pattern import Factory as Factory +from .pattern import factory_slot as factory_slot diff --git a/patterns/creational/factory_method/docs/examples.md b/patterns/creational/factory_method/docs/examples.md new file mode 100644 index 0000000..5f6cf1f --- /dev/null +++ b/patterns/creational/factory_method/docs/examples.md @@ -0,0 +1,35 @@ +# Factory Method — where it lives outside this repo + +Cited, real implementations to study (or point an agent at) when designing or +reviewing factory-slot code. + +## Python standard library + +- **`http.client.HTTPConnection.response_class`.** The canonical + class-attribute factory: the connection builds its response objects through + the attribute, so a one-line subclass swaps in a custom response type. + [docs.python.org/3/library/http.client.html](https://docs.python.org/3/library/http.client.html) +- **`json.JSONDecoder(object_hook=..., parse_float=...)`.** Instance-attribute + factories: each decoder instance carries the callables it will use to build + numbers and objects. + [docs.python.org/3/library/json.html](https://docs.python.org/3/library/json.html) +- **`asyncio.loop.set_task_factory`.** A pluggable creation hook on the event + loop — the factory is set at runtime, not baked into a subclass. + [docs.python.org/3/library/asyncio-eventloop.html](https://docs.python.org/3/library/asyncio-eventloop.html) + +## Major ecosystems + +- **Flask's `Flask.response_class` and `test_client_class`.** An application + subclass points these attributes at its own types and the framework builds + them everywhere. + [flask.palletsprojects.com/en/stable/api/](https://flask.palletsprojects.com/en/stable/api/) +- **The guide's chapter** ranks the dodges this unit implements and shows the + history of the pattern in Python. + [python-patterns.guide/gang-of-four/factory-method/](https://python-patterns.guide/gang-of-four/factory-method/) + +## What to notice across all of them + +None of these ship an abstract `factory_method()` — every one is an attribute +holding a callable. The variation point is *data on the class*, which is why +overriding takes one line and why tests can substitute doubles without +touching a hierarchy. diff --git a/patterns/creational/factory_method/docs/fundamentals.md b/patterns/creational/factory_method/docs/fundamentals.md new file mode 100644 index 0000000..e74194b --- /dev/null +++ b/patterns/creational/factory_method/docs/fundamentals.md @@ -0,0 +1,83 @@ +# Factory Method — fundamentals + +## Intent + +A class needs a helper object mid-work — an HTTP connection needs a response +object — but which helper class is the right one must stay open: subclasses, +configuration, or tests substitute their own without rewriting the containing +class. + +## Participants + +| Role | Classic (GoF) form | Python form | +|---|---|---| +| Creator | Abstract class with an abstract `factory_method()` | The class that needs the helper — creation is a **class attribute** holding any callable | +| Concrete creators | One subclass per helper choice | A one-line subclass, or a constructor argument per instance. Class-level slots hold a class bare, or any other callable via `factory_slot` — a bare *function* in a class body binds `self` and raises `TypeError` when called | +| Product | Abstract product interface | Whatever the factory callable returns | +| Concrete products | Subclasses of the product | Any objects; no shared base required | + +## Mechanism + +1. The creator does its work and, at the moment it needs a helper, calls its + factory instead of naming a class. +2. Who decides what the factory builds is the variation point: the class + default, a subclass, an instance, or the caller. +3. In Python any callable is a factory — a class *is* one, so is a function or + a `functools.partial`. + +## The classic form, and what Python absorbs + +The textbook version makes the variation point an abstract method, which costs +a subclass per configuration: + +```python +class Store(ABC): + @abstractmethod + def make_shipment(self) -> Shipment: ... # the deferred decision + + def ship(self) -> str: + return f"shipping via {self.make_shipment().kind}" + + +class ExpressStore(Store): # one subclass... + def make_shipment(self) -> Shipment: + return Express() + + +class StandardStore(Store): # ...per choice + def make_shipment(self) -> Shipment: + return Standard() +``` + +The design exists because 1994 languages could not pass a class or function as +a value. Python can, so the guide's dodges rank ahead of it, best first — the +[`examples/feed_client/`](../examples/feed_client/) mini-project shows all +three on one framework class: + +1. **Dependency injection** (`FeedClient(transport)`) — if the helper can + exist up front, pass the object and skip the factory entirely. +2. **Class-attribute factory** (`FeedClient.response_class`) — creation stays + inside the class; a subclass overrides the slot in one line. A class is + safe to assign bare; wrap any other callable in `factory_slot` (from + [`pattern/`](../pattern/)) so it does not bind as a method. +3. **Instance-attribute factory** — a constructor argument shadows the class + attribute for one object; tests love this. + +## When to use it + +- A framework class must build objects the application is allowed to replace + (`response_class`-style hooks). +- Creation must happen *inside* the worker (mid-protocol, in a loop), so you + cannot simply pass the finished object in. + +## When not to use it + +- The helper can be built before the worker starts → inject the object. +- The choice is data, not code → a `dict[str, Factory]` lookup. +- One abstract method + parallel subclass trees are growing → you are paying + Java's cost without Java's constraint. + +## Verdict: prefer an alternative + +Inject the dependency; failing that, a class-attribute factory. The +abstract-method form survives here only as the classic listing above. diff --git a/patterns/creational/factory_method/docs/implementation.md b/patterns/creational/factory_method/docs/implementation.md new file mode 100644 index 0000000..9889677 --- /dev/null +++ b/patterns/creational/factory_method/docs/implementation.md @@ -0,0 +1,79 @@ +# Factory Method — putting it into a system + +## The smell it fixes + +A class that hard-codes a constructor call deep inside its work: + +```python +class FeedClient: + def fetch(self, url): + raw = self._transport(url) + return FeedResponse(raw) # nobody can substitute their own type +``` + +Every consumer who needs a different response type must fork or wrap the +class. The fix is not an abstract creator hierarchy — it is making that one +constructor call a *slot*. + +## Steps + +1. **Find the buried constructor call** — the `SomeClass(...)` inside a method + that callers wish they could change. +2. **Ask first: can the object be passed in?** If the helper can exist before + the work starts, add a constructor parameter and inject it. Done — no + factory needed. +3. **Otherwise, lift the call into a class attribute**: + `response_class: Callable[[str], FeedResponse] = FeedResponse`. The method + body becomes `self.response_class(raw)`. +4. **Type the slot with `Callable`, not a class.** `type[FeedResponse]` rejects + functions and partials; `Callable[[str], FeedResponse]` accepts every + factory shape mypy can hold. +5. **Add the per-instance override** — an optional constructor argument that + assigns over the class attribute. Tests then swap doubles in without + subclassing. + +```python +from patterns.creational.factory_method import factory_slot +from patterns.creational.factory_method.examples.feed_client import ( + FeedClient, + parse_strictly, +) + +FeedClient(transport, response_class=parse_strictly) # per-instance + + +class StrictClient(FeedClient): # or per-subclass; factory_slot because + response_class = factory_slot(parse_strictly) # a bare function would bind +``` + +## Python idioms that keep it small + +- **`factory_slot` (a `staticmethod` wrapper) around non-class defaults** on + the class attribute — without it, Python would bind a plain function as a + method and pass `self`. +- **`functools.partial` is a configured factory**: `partial(FeedResponse, ...)` + slots in wherever the factory shape is expected — wrapped in `factory_slot` + when assigned in a class body. +- Class attributes are inherited: a subclass overrides *only* the factory and + inherits the whole workflow — that is the entire GoF promise, one line long. + +## Pitfalls + +- **Forgetting `staticmethod`** on a function-valued class attribute — the + classic surprise `TypeError` when `self` sneaks into the call. +- **Typing the slot as a concrete class** shuts out functions, partials, and + lambdas — the flexibility was the point. +- **Deferring what never varies.** A slot nobody overrides is indirection + debt; inline it until a second builder actually exists. +- **Doing real work in the factory.** Factories build; if the slot starts + validating or fetching, it has become a strategy — name it as one. + +## Worked example + +[`examples/feed_client/`](../examples/feed_client/) is a miniature +`http.client`: a framework class whose `response_class` slot is overridden by +subclass, by instance, and by a test double — run it with: + +```bash +uv run python -m patterns.creational.factory_method.examples.feed_client.main +``` diff --git a/patterns/creational/factory_method/examples/feed_client/client.py b/patterns/creational/factory_method/examples/feed_client/client.py new file mode 100644 index 0000000..3dc0ce0 --- /dev/null +++ b/patterns/creational/factory_method/examples/feed_client/client.py @@ -0,0 +1,92 @@ +"""A tiny feed-client framework whose response type is a class-attribute factory. + +The framework (``FeedClient``) must build a response object mid-work, exactly +like ``http.client.HTTPConnection`` building its ``HTTPResponse``. Instead of +an abstract ``factory_method()`` and a subclass per choice, the factory is the +class attribute ``response_class`` — apps override it with their own class in +a subclass, tests override it per instance, and the transport is injected +outright (the best dodge of all: pass the object). + +Built on this unit's ``pattern`` package: ``factory_slot`` guards the one trap +(a plain *function* in a class body binds ``self``; classes are safe bare). +""" + +from __future__ import annotations + +from collections.abc import Callable + +from patterns.creational.factory_method.examples.feed_client.models import Article +from patterns.creational.factory_method.pattern import factory_slot + +#: A transport fetches raw feed text for a URL — injected, so no real network. +Transport = Callable[[str], str] + + +class FeedResponse: + """Parses the wire format (``title|body`` lines) into articles. + + Lenient by policy: a line with no ``|`` becomes an ``Article`` with an + empty body (feeds in the wild often carry title-only entries). Use + ``StrictClient`` when malformed lines should fail loudly instead. + """ + + def __init__(self, raw: str) -> None: + self.articles = [ + Article(title, body) + for line in raw.splitlines() + if line.strip() + for title, _, body in [line.partition("|")] + ] + + def titles(self) -> list[str]: + return [a.title for a in self.articles] + + +class DigestResponse(FeedResponse): + """An app's own response type: same parse, plus a one-line digest.""" + + def digest(self) -> str: + return "; ".join(f"{a.title} ({len(a.body.split())}w)" for a in self.articles) + + +def parse_strictly(raw: str) -> FeedResponse: + """A plain-function factory: rejects any line missing the ``|`` separator.""" + for line in raw.splitlines(): + if line.strip() and "|" not in line: + raise ValueError(f"malformed feed line (no '|'): {line!r}") + return FeedResponse(raw) + + +class FeedClient: + """The framework class. ``response_class`` is the factory-method slot.""" + + response_class: Callable[[str], FeedResponse] = FeedResponse + + def __init__( + self, + transport: Transport, + response_class: Callable[[str], FeedResponse] | None = None, + ) -> None: + self._transport = transport + # Per-instance override — no subclass needed (e.g. a test double). + if response_class is not None: + self.response_class = response_class + + def fetch(self, url: str) -> FeedResponse: + return self.response_class(self._transport(url)) + + +class DigestClient(FeedClient): + """An app subclass: one line swaps what the framework builds.""" + + response_class = DigestResponse + + +class StrictClient(FeedClient): + """A subclass slotting in a plain *function* — hence ``factory_slot``. + + Bare assignment here would bind the function as a method and every fetch + would raise ``TypeError``; the wrapper from ``pattern/`` prevents that. + """ + + response_class = factory_slot(parse_strictly) diff --git a/patterns/creational/factory_method/examples/feed_client/main.py b/patterns/creational/factory_method/examples/feed_client/main.py new file mode 100644 index 0000000..6754f3d --- /dev/null +++ b/patterns/creational/factory_method/examples/feed_client/main.py @@ -0,0 +1,40 @@ +"""Demo: one client framework, three ways to swap what it builds.""" + +from __future__ import annotations + +from patterns.creational.factory_method.examples.feed_client.client import ( + DigestClient, + FeedClient, + FeedResponse, + StrictClient, +) + +FEED = "Storm warning|Heavy rain expected tonight\nNew library opens|Doors open at nine" + + +def canned_transport(url: str) -> str: + return FEED + + +def main() -> None: + stock = FeedClient(canned_transport) + print(f"stock response: {stock.fetch('news://local').titles()}") + + digest = DigestClient(canned_transport) + response = digest.fetch("news://local") + print(f"subclass override: {type(response).__name__}") + + class UpperResponse(FeedResponse): + def titles(self) -> list[str]: + return [t.upper() for t in super().titles()] + + per_instance = FeedClient(canned_transport, response_class=UpperResponse) + print(f"instance override: {per_instance.fetch('news://local').titles()}") + + strict = StrictClient(canned_transport) + count = len(strict.fetch("news://local").articles) + print(f"function slot: {count} articles parsed strictly") + + +if __name__ == "__main__": + main() diff --git a/patterns/creational/factory_method/examples/feed_client/models.py b/patterns/creational/factory_method/examples/feed_client/models.py new file mode 100644 index 0000000..61362fb --- /dev/null +++ b/patterns/creational/factory_method/examples/feed_client/models.py @@ -0,0 +1,13 @@ +"""Domain types for the feed-client mini-project.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Article: + """One entry in a news feed.""" + + title: str + body: str diff --git a/patterns/creational/factory_method/naive.py b/patterns/creational/factory_method/naive.py deleted file mode 100644 index 33f0d91..0000000 --- a/patterns/creational/factory_method/naive.py +++ /dev/null @@ -1,53 +0,0 @@ -"""The Gang of Four Factory Method, translated literally. - -An abstract creator defers one construction decision to an abstract method; -each choice of helper costs a subclass. -""" - -from __future__ import annotations - -from abc import ABC, abstractmethod - - -class Shipment: - def __init__(self, kind: str) -> None: - self.kind = kind - - -class Express(Shipment): - def __init__(self) -> None: - super().__init__("express") - - -class Standard(Shipment): - def __init__(self) -> None: - super().__init__("standard") - - -class Store(ABC): - """The creator: works with shipments, defers building them.""" - - @abstractmethod - def make_shipment(self) -> Shipment: ... - - def ship(self) -> str: - return f"shipping via {self.make_shipment().kind}" - - -class ExpressStore(Store): - def make_shipment(self) -> Shipment: - return Express() - - -class StandardStore(Store): - def make_shipment(self) -> Shipment: - return Standard() - - -def main() -> None: - print(ExpressStore().ship()) - print(StandardStore().ship()) - - -if __name__ == "__main__": - main() diff --git a/patterns/creational/factory_method/pattern/__init__.py b/patterns/creational/factory_method/pattern/__init__.py new file mode 100644 index 0000000..3ddaf4a --- /dev/null +++ b/patterns/creational/factory_method/pattern/__init__.py @@ -0,0 +1,2 @@ +from .dodges import Factory as Factory +from .dodges import factory_slot as factory_slot diff --git a/patterns/creational/factory_method/pattern/dodges.py b/patterns/creational/factory_method/pattern/dodges.py new file mode 100644 index 0000000..b49a090 --- /dev/null +++ b/patterns/creational/factory_method/pattern/dodges.py @@ -0,0 +1,37 @@ +"""Factory Method and its Python dodges, importable as library code. + +The guide's ranking, best first: (1) dependency injection — if you can build +the helper up front, pass the object; (2) a class-attribute factory slot — +creation stays inside the class, overridden by a subclass or assignment; +(3) an instance-attribute factory for per-object overrides with no subclass. + +The one trap (see docs/implementation.md): a plain function assigned in a +class body becomes a method and binds ``self``, so calling the slot raises +``TypeError``. Classes are safe (they are not descriptors); for any other +callable, wrap it with ``factory_slot``. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import ParamSpec, TypeVar + +T = TypeVar("T") +P = ParamSpec("P") + +#: A type alias (not one of the dodges): any zero-argument callable +#: building one T. Handy for annotating injected or slotted factories. +Factory = Callable[[], T] + + +def factory_slot(factory: Callable[P, T]) -> staticmethod[P, T]: + """Wrap any callable for safe assignment as a class-attribute factory. + + Class-body assignment of a plain function turns it into a method — the + call then receives ``self`` as an unwanted first argument. Wrapping in + ``staticmethod`` keeps the callable's own signature, whatever it is: + + >>> class Client: + ... make_response = factory_slot(lambda raw: raw.upper()) + """ + return staticmethod(factory) diff --git a/patterns/creational/factory_method/pythonic.py b/patterns/creational/factory_method/pythonic.py deleted file mode 100644 index 30b05b8..0000000 --- a/patterns/creational/factory_method/pythonic.py +++ /dev/null @@ -1,62 +0,0 @@ -"""The guide's alternatives, best first. - -1. Dependency Injection: if you can build the helper up front, pass it in. -2. Class attribute factory: creation stays internal, overriding is trivial. -3. Instance attribute factory: per-object override, no subclass at all. -""" - -from __future__ import annotations - -from collections.abc import Callable - - -class Shipment: - def __init__(self, kind: str) -> None: - self.kind = kind - - -def express() -> Shipment: - return Shipment("express") - - -def standard() -> Shipment: - return Shipment("standard") - - -class InjectedStore: - """1. The dodge: don't defer creation -- receive the object.""" - - def __init__(self, shipment: Shipment) -> None: - self.shipment = shipment - - def ship(self) -> str: - return f"shipping via {self.shipment.kind}" - - -class Store: - """2. Class attribute factory: any callable; override by subclass or assignment.""" - - shipment_factory: Callable[[], Shipment] = staticmethod(standard) - - def __init__(self, shipment_factory: Callable[[], Shipment] | None = None) -> None: - # 3. Instance attribute overrides the class attribute per object. - if shipment_factory is not None: - self.shipment_factory = shipment_factory - - def ship(self) -> str: - return f"shipping via {self.shipment_factory().kind}" - - -class ExpressStore(Store): - shipment_factory = staticmethod(express) - - -def main() -> None: - print(InjectedStore(express()).ship()) - print(Store().ship()) - print(ExpressStore().ship()) - print(Store(shipment_factory=express).ship()) - - -if __name__ == "__main__": - main() diff --git a/patterns/creational/factory_method/real_world.py b/patterns/creational/factory_method/real_world.py deleted file mode 100644 index d27d806..0000000 --- a/patterns/creational/factory_method/real_world.py +++ /dev/null @@ -1,33 +0,0 @@ -"""The canonical class attribute factory: ``HTTPConnection.response_class``. - -The connection builds its response objects through a class attribute, so a -one-line subclass swaps in your own response type -- no network needed to -see the wiring. -""" - -from __future__ import annotations - -from http.client import HTTPConnection, HTTPResponse - - -class LoggedResponse(HTTPResponse): - """A custom response type the connection should build instead.""" - - -class LoggedConnection(HTTPConnection): - response_class = LoggedResponse - - -def factory_of(cls: type[HTTPConnection]) -> type[HTTPResponse]: - result = cls.response_class - assert isinstance(result, type) - return result - - -def main() -> None: - print(f"stock factory: {factory_of(HTTPConnection).__name__}") - print(f"overridden factory: {factory_of(LoggedConnection).__name__}") - - -if __name__ == "__main__": - main() diff --git a/patterns/creational/factory_method/tests/__init__.py b/patterns/creational/factory_method/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/patterns/creational/factory_method/tests/test_dodges.py b/patterns/creational/factory_method/tests/test_dodges.py new file mode 100644 index 0000000..5191c9f --- /dev/null +++ b/patterns/creational/factory_method/tests/test_dodges.py @@ -0,0 +1,67 @@ +"""Behavioral tests for the factory-slot mechanics in ``pattern/``.""" + +from __future__ import annotations + +import pytest + +from patterns.creational.factory_method.pattern import Factory, factory_slot + + +class Widget: + def __init__(self, kind: str = "plain") -> None: + self.kind = kind + + +def make_fancy() -> Widget: + return Widget("fancy") + + +class TestFactorySlot: + def test_plain_function_bare_in_class_body_is_the_trap(self) -> None: + class Shop: + build = make_fancy # bound as a method: the documented mistake + + with pytest.raises(TypeError): + # mypy flags the very mistake this test demonstrates at runtime. + Shop().build() # type: ignore[misc] + + def test_factory_slot_makes_the_same_assignment_safe(self) -> None: + class Shop: + build = factory_slot(make_fancy) + + assert Shop().build().kind == "fancy" + + def test_classes_are_safe_bare(self) -> None: + class Shop: + build = Widget # classes are not descriptors: no binding + + assert Shop().build().kind == "plain" + + def test_instance_override_accepts_any_callable_unwrapped(self) -> None: + class Shop: + build = factory_slot(make_fancy) + + def __init__(self, build: Factory[Widget] | None = None) -> None: + if build is not None: + self.build = build # instance attributes never bind + + assert Shop(build=lambda: Widget("custom")).build().kind == "custom" + + def test_subclass_overrides_only_the_slot(self) -> None: + class Shop: + build = factory_slot(make_fancy) + + def describe(self) -> str: + return f"selling {self.build().kind}" + + class PlainShop(Shop): + build = factory_slot(Widget) + + assert Shop().describe() == "selling fancy" + assert PlainShop().describe() == "selling plain" + + def test_slot_keeps_the_callables_signature(self) -> None: + class Shop: + build = factory_slot(Widget) + + assert Shop().build("bespoke").kind == "bespoke" diff --git a/patterns/creational/factory_method/tests/test_factory_method.py b/patterns/creational/factory_method/tests/test_factory_method.py deleted file mode 100644 index 29e4d77..0000000 --- a/patterns/creational/factory_method/tests/test_factory_method.py +++ /dev/null @@ -1,33 +0,0 @@ -"""Behavioral tests for all three factory-method variants.""" - -from http.client import HTTPConnection, HTTPResponse - -from patterns.creational.factory_method import naive, pythonic, real_world - - -class TestNaive: - def test_each_subclass_builds_its_helper(self) -> None: - assert naive.ExpressStore().ship() == "shipping via express" - assert naive.StandardStore().ship() == "shipping via standard" - - -class TestPythonic: - def test_dependency_injection(self) -> None: - assert pythonic.InjectedStore(pythonic.express()).ship() == "shipping via express" - - def test_class_attribute_default(self) -> None: - assert pythonic.Store().ship() == "shipping via standard" - - def test_subclass_overrides_class_attribute(self) -> None: - assert pythonic.ExpressStore().ship() == "shipping via express" - - def test_instance_attribute_beats_class_attribute(self) -> None: - assert pythonic.Store(shipment_factory=pythonic.express).ship() == "shipping via express" - - -class TestRealWorld: - def test_stock_connection_builds_httpresponse(self) -> None: - assert real_world.factory_of(HTTPConnection) is HTTPResponse - - def test_subclass_swaps_the_response_factory(self) -> None: - assert real_world.factory_of(real_world.LoggedConnection) is real_world.LoggedResponse diff --git a/patterns/creational/factory_method/tests/test_feed_client.py b/patterns/creational/factory_method/tests/test_feed_client.py new file mode 100644 index 0000000..8d59242 --- /dev/null +++ b/patterns/creational/factory_method/tests/test_feed_client.py @@ -0,0 +1,61 @@ +"""Behavioral tests for the feed-client mini-project.""" + +import pytest + +from patterns.creational.factory_method.examples.feed_client.client import ( + DigestClient, + DigestResponse, + FeedClient, + FeedResponse, + StrictClient, +) + +FEED = "Storm warning|Heavy rain expected tonight\nNew library opens|Doors open at nine" + + +def canned(url: str) -> str: + return FEED + + +class TestFrameworkSlot: + def test_stock_client_builds_stock_responses(self) -> None: + response = FeedClient(canned).fetch("news://x") + assert type(response) is FeedResponse + assert response.titles() == ["Storm warning", "New library opens"] + + def test_subclass_swaps_the_response_type(self) -> None: + response = DigestClient(canned).fetch("news://x") + assert isinstance(response, DigestResponse) + assert response.digest() == "Storm warning (4w); New library opens (4w)" + + def test_instance_override_without_subclassing(self) -> None: + class Canary(FeedResponse): + pass + + client = FeedClient(canned, response_class=Canary) + assert isinstance(client.fetch("news://x"), Canary) + # ...and the framework default is untouched. + assert type(FeedClient(canned).fetch("news://x")) is FeedResponse + + def test_transport_is_injected_not_built(self) -> None: + calls: list[str] = [] + + def spying(url: str) -> str: + calls.append(url) + return "A|b" + + FeedClient(spying).fetch("news://spied") + assert calls == ["news://spied"] + + def test_lenient_parse_keeps_title_only_lines(self) -> None: + # Documented policy: no '|' means an article with an empty body. + response = FeedClient(lambda url: "Bare headline").fetch("news://x") + assert [(a.title, a.body) for a in response.articles] == [("Bare headline", "")] + + def test_strict_client_slots_a_plain_function_via_factory_slot(self) -> None: + assert StrictClient(canned).fetch("news://x").titles() == [ + "Storm warning", + "New library opens", + ] + with pytest.raises(ValueError, match="malformed feed line"): + StrictClient(lambda url: "Bare headline").fetch("news://x") diff --git a/patterns/creational/prototype/README.md b/patterns/creational/prototype/README.md index 304e9c8..9f5bb82 100644 --- a/patterns/creational/prototype/README.md +++ b/patterns/creational/prototype/README.md @@ -14,33 +14,17 @@ stdlib_sightings: [copy.copy, copy.deepcopy, functools.partial] # Prototype -## Problem - -A framework needs to stamp out new objects without knowing how to construct -them — the classic case is a menu of pre-configured instances the user picks -from. The GoF answer: store an exemplar ("prototype") and `clone()` it. - -## Naive solution - -`naive.py` follows the book: an abstract `clone()` method, concrete prototypes, -and a registry mapping names to exemplars that get cloned on demand. - -## Pythonic solution - -Python doesn't need the interface, because *callables* are the interface. -`pythonic.py` shows the guide's recommendation on a real shape — a scheduler -stamping out report jobs from `functools.partial` templates, with per-run -tweaks via `dataclasses.replace` on the frozen product. No `clone()` -anywhere. - -## In the wild - -`copy.copy` and `copy.deepcopy` are the stdlib's clone operation, complete -with the `__copy__`/`__deepcopy__` protocol for classes that need custom -cloning — that protocol *is* the Prototype pattern, absorbed into the language. - -## Verdict - -**Prefer an alternative.** Store callables, not exemplars. Reach for -`copy.deepcopy` only when instances are genuinely expensive or awkward to -rebuild from arguments. +Stamp out new objects from named, pre-configured starting points. **Verdict: +prefer an alternative** — store callables (`functools.partial`), not exemplars +with a `clone()` protocol; tweak frozen products with `dataclasses.replace`. + +| Where | What | +|---|---| +| [`pattern/`](pattern/) | The importable code: `TemplateRegistry`, `Template` | +| [`docs/`](docs/) | [Fundamentals](docs/fundamentals.md) · [Implementation guide](docs/implementation.md) · [External examples](docs/examples.md) | +| [`examples/report_job_templates/`](examples/report_job_templates/) | Mini-project: a report scheduler stamping jobs from a template menu | +| [`tests/`](tests/) | Behavioral tests for the pattern and the mini-project | + +```bash +uv run python -m patterns.creational.prototype.examples.report_job_templates.main +``` diff --git a/patterns/creational/prototype/__init__.py b/patterns/creational/prototype/__init__.py index 4c2c58a..d24886e 100644 --- a/patterns/creational/prototype/__init__.py +++ b/patterns/creational/prototype/__init__.py @@ -1 +1,2 @@ -"""Prototype: new instances by cloning an exemplar. Verdict: store callables instead.""" +from .pattern import Template as Template +from .pattern import TemplateRegistry as TemplateRegistry diff --git a/patterns/creational/prototype/docs/examples.md b/patterns/creational/prototype/docs/examples.md new file mode 100644 index 0000000..05bd847 --- /dev/null +++ b/patterns/creational/prototype/docs/examples.md @@ -0,0 +1,38 @@ +# Prototype — where it lives outside this repo + +Cited, real implementations to study (or point an agent at) when designing or +reviewing template/clone-shaped code. + +## Python standard library + +- **The `copy` module.** `copy.copy` (shallow — nested mutables shared) and + `copy.deepcopy` (whole object graph), plus the `__copy__`/`__deepcopy__` + customization protocol: the Prototype pattern absorbed into the language. + [docs.python.org/3/library/copy.html](https://docs.python.org/3/library/copy.html) +- **`dataclasses.replace`.** The stdlib's copy-with-changes — per-use + customization of a frozen product in one expression. + [docs.python.org/3/library/dataclasses.html#dataclasses.replace](https://docs.python.org/3/library/dataclasses.html#dataclasses.replace) +- **`functools.partial`.** A pre-configured constructor: the exemplar as a + recipe rather than an instance. + [docs.python.org/3/library/functools.html#functools.partial](https://docs.python.org/3/library/functools.html#functools.partial) + +## Major ecosystems + +- **Django forms.** Declared fields are deep-copied onto every form instance + (`fields = copy.deepcopy(base_fields)`) — live prototypes, cloned per use so + one form's mutation can't leak into the class. *(unverified source link)* + [github.com/django/django/blob/main/django/forms/forms.py](https://github.com/django/django/blob/main/django/forms/forms.py) +- **pydantic `model_copy(update=...)`.** Copy-with-tweaks as a public API on + every model — `replace` generalized to validation-aware models. *(unverified + source link)* + [docs.pydantic.dev/latest/concepts/models/](https://docs.pydantic.dev/latest/concepts/models/#model-copy) +- **The guide's chapter** on why the pattern targets languages without + first-class classes. + [python-patterns.guide/gang-of-four/prototype/](https://python-patterns.guide/gang-of-four/prototype/) + +## What to notice across all of them + +The stdlib keeps *copying* (the mechanism) and leaves *the menu of exemplars* +(the pattern's structure) to you — and everything modern expresses "start from +this, change that" as an expression returning a new object, never as mutation +of a shared template. diff --git a/patterns/creational/prototype/docs/fundamentals.md b/patterns/creational/prototype/docs/fundamentals.md new file mode 100644 index 0000000..9857375 --- /dev/null +++ b/patterns/creational/prototype/docs/fundamentals.md @@ -0,0 +1,76 @@ +# Prototype — fundamentals + +## Intent + +Create new objects by copying a pre-configured exemplar instead of +constructing from scratch — classically, a framework offers a menu of +prototypes the user picks from, and each pick is cloned so the exemplar stays +pristine. + +## Participants + +| Role | Classic (GoF) form | Python form | +|---|---|---| +| Prototype contract | Abstract class with a `clone()` method | Any zero-argument callable that builds a product — `Template` in [`pattern/templates.py`](../pattern/templates.py) | +| Concrete prototypes | Instances implementing `clone()` (usually via deep copy) | `functools.partial(Product, ...)` freezing the configuration | +| Registry / client | Maps names to exemplars, clones on request | `TemplateRegistry` maps names to callables, *calls* on request | +| Per-use customization | Mutate the clone after copying | `dataclasses.replace` on a frozen product | + +## Mechanism + +1. Each configuration worth naming becomes a template. +2. A request for a named template builds a **fresh** product — never a shared + one, so callers can't corrupt the menu. +3. Per-request tweaks produce another new object; the template is immutable + from the caller's point of view. + +## The classic form, and what Python absorbs + +The textbook version stores instances and copies them through a `clone()` +protocol: + +```python +class Shape(ABC): + @abstractmethod + def clone(self) -> Self: ... # the pattern's whole surface + + +class Circle(Shape): + def clone(self) -> Self: + return copy.deepcopy(self) # copying IS the construction + + +class PrototypeRegistry: + def register(self, name: str, prototype: Shape) -> None: + self._prototypes[name] = prototype # stores a live exemplar + + def create(self, name: str) -> Shape: + return self._prototypes[name].clone() +``` + +The pattern targets a 1990s constraint: classes weren't values, so the only +way to hand a framework "how to make one of these" was a pre-made instance to +copy. Python callables *are* values — store the recipe, not a cooked meal. +`copy.copy`/`copy.deepcopy` (with the `__copy__`/`__deepcopy__` hooks) remain +for objects genuinely cheaper to copy than rebuild, and shallow-vs-deep is the +caveat to respect: `copy.copy` shares nested mutable state. + +## When to use it + +- A menu of named, pre-configured starting points (report templates, document + boilerplates, game archetypes). +- Construction is expensive or awkward and instances are cheap to copy — + that's the residual case for `copy.deepcopy`. + +## When not to use it + +- One-off construction with known arguments → just call the class. +- The "template" varies per call in every field → it's not a template, pass + arguments. +- You reached for `clone()` to dodge `__init__` — fix the constructor instead. + +## Verdict: prefer an alternative + +Store callables, not exemplars: `partial` + `dataclasses.replace` do the whole +job with no protocol. Reach for `copy.deepcopy` only when instances are +genuinely expensive or awkward to rebuild from arguments. diff --git a/patterns/creational/prototype/docs/implementation.md b/patterns/creational/prototype/docs/implementation.md new file mode 100644 index 0000000..c64cfa6 --- /dev/null +++ b/patterns/creational/prototype/docs/implementation.md @@ -0,0 +1,74 @@ +# Prototype — putting it into a system + +## The smell it fixes + +Construction calls repeating the same configuration, or a "template" object +that everyone mutates before use: + +```python +# The pre-pattern shape: a plain mutable class, before anyone froze it. +job = MutableReportJob( + name="nightly-sales", + query="SELECT * FROM sales WHERE day = today()", # copied everywhere + recipients=("sales-leads@example.com",), + filters=("exclude-test-accounts",), +) +job.fmt = "csv" # ...and sometimes someone edits the shared one. Which one? +``` + +Named starting points want to live in exactly one place, and "start from X, +tweak Y" must never mutate X. + +## Steps + +1. **Freeze the product.** Make it a frozen dataclass; per-use variation then + *has* to build a new object, which is the safety the pattern promises. +2. **Turn each named configuration into a template callable** — + `functools.partial(ReportJob, name=..., query=...)`. The recipe is data; + nothing is instantiated until asked. +3. **Put templates in a registry** keyed by name + (`TemplateRegistry[ReportJob]`), so the menu is one readable structure and + unknown names fail with the menu attached. +4. **Route per-use tweaks through `create(name, **overrides)`** — which is + `dataclasses.replace` under the hood: a new product each time, template + untouched. +5. **Reach for `copy.deepcopy` only if construction is the expensive part** — + then the template really is an instance, and the `__deepcopy__` hook is the + place to control what copying means. + +```python +from patterns.creational.prototype import TemplateRegistry + +menu: TemplateRegistry[ReportJob] = TemplateRegistry() +menu.register("nightly-sales", partial(ReportJob, name="nightly-sales", ...)) +rush = menu.create("nightly-sales", fmt="csv") # fresh, tweaked, template safe +``` + +## Python idioms that keep it small + +- **`functools.partial` is a pre-configured constructor** — the exemplar + without the copying. +- **`dataclasses.replace` is copy-with-changes** as a single expression; on a + frozen dataclass it is also the *only* way, which is the point. +- **`register` returns its argument**, so a zero-argument factory function can + be registered where a `partial` is too cramped. + +## Pitfalls + +- **Know your copy depth** if you do copy: `copy.copy` shares nested mutable + state between "independent" clones — the classic aliasing bug. +- **Mutable defaults inside templates** (a list shared by every product) + reintroduce aliasing through the back door; freeze collections into tuples. +- **A registry of live instances handed out un-copied** is the worst of both + worlds — every caller edits the menu. +- **Overrides on a non-dataclass product** have no general safe form; + `TemplateRegistry.create` refuses rather than guessing. + +## Worked example + +[`examples/report_job_templates/`](../examples/report_job_templates/) is the +scheduler shape above, end to end — run it with: + +```bash +uv run python -m patterns.creational.prototype.examples.report_job_templates.main +``` diff --git a/patterns/creational/prototype/examples/report_job_templates/main.py b/patterns/creational/prototype/examples/report_job_templates/main.py new file mode 100644 index 0000000..631bda9 --- /dev/null +++ b/patterns/creational/prototype/examples/report_job_templates/main.py @@ -0,0 +1,20 @@ +"""Demo: a night's report runs stamped from the template menu.""" + +from __future__ import annotations + +from patterns.creational.prototype.examples.report_job_templates.scheduler import Scheduler + + +def main() -> None: + scheduler = Scheduler() + scheduler.enqueue("nightly-sales") + rush = scheduler.enqueue("weekly-audit", fmt="csv", recipients=("cfo@example.com",)) + + print(f"menu: {scheduler.menu.names()}") + print(f"queued: {[job.name for job in scheduler.queue]}") + print(f"per-run override: {rush.fmt}") + print(f"template untouched: {scheduler.menu.create('weekly-audit').fmt}") + + +if __name__ == "__main__": + main() diff --git a/patterns/creational/prototype/examples/report_job_templates/models.py b/patterns/creational/prototype/examples/report_job_templates/models.py new file mode 100644 index 0000000..7bcf9b5 --- /dev/null +++ b/patterns/creational/prototype/examples/report_job_templates/models.py @@ -0,0 +1,16 @@ +"""Domain types for the report-job mini-project.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class ReportJob: + """One scheduled report run. Frozen: per-run tweaks build a new job.""" + + name: str + query: str + recipients: tuple[str, ...] + fmt: str = "pdf" + filters: tuple[str, ...] = () diff --git a/patterns/creational/prototype/examples/report_job_templates/scheduler.py b/patterns/creational/prototype/examples/report_job_templates/scheduler.py new file mode 100644 index 0000000..e52e87c --- /dev/null +++ b/patterns/creational/prototype/examples/report_job_templates/scheduler.py @@ -0,0 +1,53 @@ +"""A scheduler stamping out report jobs from preconfigured templates. + +``functools.partial`` freezes each template's settings into a zero-argument +factory registered on a ``TemplateRegistry``; per-run tweaks come from the +registry's ``create(**overrides)`` (``dataclasses.replace`` underneath), so a +rushed run never touches the template it came from. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from functools import partial + +from patterns.creational.prototype.examples.report_job_templates.models import ReportJob +from patterns.creational.prototype.pattern import TemplateRegistry + + +def build_template_menu() -> TemplateRegistry[ReportJob]: + menu: TemplateRegistry[ReportJob] = TemplateRegistry() + menu.register( + "nightly-sales", + partial( + ReportJob, + name="nightly-sales", + query="SELECT * FROM sales WHERE day = today()", + recipients=("sales-leads@example.com",), + filters=("exclude-test-accounts",), + ), + ) + menu.register( + "weekly-audit", + partial( + ReportJob, + name="weekly-audit", + query="SELECT * FROM ledger WHERE week = this_week()", + recipients=("finance@example.com", "cfo@example.com"), + fmt="xlsx", + ), + ) + return menu + + +@dataclass +class Scheduler: + """Queues fresh jobs stamped from the menu.""" + + menu: TemplateRegistry[ReportJob] = field(default_factory=build_template_menu) + queue: list[ReportJob] = field(default_factory=list) + + def enqueue(self, template: str, **overrides: object) -> ReportJob: + job = self.menu.create(template, **overrides) + self.queue.append(job) + return job diff --git a/patterns/creational/prototype/naive.py b/patterns/creational/prototype/naive.py deleted file mode 100644 index f07d669..0000000 --- a/patterns/creational/prototype/naive.py +++ /dev/null @@ -1,53 +0,0 @@ -"""The Gang of Four Prototype, translated literally. - -An abstract ``clone()`` interface, concrete prototypes, and a registry of -exemplars that are copied -- never handed out directly -- on request. -""" - -from __future__ import annotations - -import copy -from abc import ABC, abstractmethod -from typing import Self - - -class Shape(ABC): - """The prototype interface.""" - - @abstractmethod - def clone(self) -> Self: ... - - -class Circle(Shape): - def __init__(self, radius: int, color: str) -> None: - self.radius = radius - self.color = color - - def clone(self) -> Self: - return copy.deepcopy(self) - - -class PrototypeRegistry: - """Menu of pre-configured exemplars; every request gets a private copy.""" - - def __init__(self) -> None: - self._prototypes: dict[str, Shape] = {} - - def register(self, name: str, prototype: Shape) -> None: - self._prototypes[name] = prototype - - def create(self, name: str) -> Shape: - return self._prototypes[name].clone() - - -def main() -> None: - registry = PrototypeRegistry() - registry.register("small-red", Circle(radius=1, color="red")) - - a = registry.create("small-red") - b = registry.create("small-red") - print(f"independent copies: {a is not b}") - - -if __name__ == "__main__": - main() diff --git a/patterns/creational/prototype/pattern/__init__.py b/patterns/creational/prototype/pattern/__init__.py new file mode 100644 index 0000000..3df3362 --- /dev/null +++ b/patterns/creational/prototype/pattern/__init__.py @@ -0,0 +1,2 @@ +from .templates import Template as Template +from .templates import TemplateRegistry as TemplateRegistry diff --git a/patterns/creational/prototype/pattern/templates.py b/patterns/creational/prototype/pattern/templates.py new file mode 100644 index 0000000..0774c55 --- /dev/null +++ b/patterns/creational/prototype/pattern/templates.py @@ -0,0 +1,57 @@ +"""Prototype without ``clone()``: a registry of template callables. + +The GoF pattern stores pre-configured *instances* and copies them on demand. +In Python the exemplar can simply be a callable that builds the product — +``functools.partial`` freezes the configuration — and per-request tweaks are +``dataclasses.replace`` on a frozen product. Same menu-of-templates shape, no +copy protocol. +""" + +from __future__ import annotations + +import dataclasses +from collections.abc import Callable +from typing import Generic, TypeVar, cast + +T = TypeVar("T") + +#: A template is any zero-argument callable producing one fresh product. +Template = Callable[[], T] + + +class TemplateRegistry(Generic[T]): + """A menu of named templates; every ``create`` builds a fresh product.""" + + def __init__(self) -> None: + self._templates: dict[str, Template[T]] = {} + + def register(self, name: str, template: Template[T], *, replace: bool = False) -> Template[T]: + """Add a template under ``name``; returns it, so it can wrap a def. + + A duplicate ``name`` is an error unless ``replace=True`` — silently + losing a template is how menus drift. + """ + if name in self._templates and not replace: + raise ValueError(f"template {name!r} already registered (pass replace=True)") + self._templates[name] = template + return template + + def names(self) -> list[str]: + return sorted(self._templates) + + def create(self, name: str, **overrides: object) -> T: + """Build a fresh product; overrides customize this one product only. + + Overrides use ``dataclasses.replace``, so they require the product to + be a dataclass instance (frozen ones work — that is the point). + """ + try: + template = self._templates[name] + except KeyError: + raise ValueError(f"unknown template {name!r} (has: {self.names()})") from None + product = template() + if not overrides: + return product + if not dataclasses.is_dataclass(product) or isinstance(product, type): + raise TypeError(f"overrides need a dataclass product, got {type(product).__name__}") + return cast("T", dataclasses.replace(product, **overrides)) diff --git a/patterns/creational/prototype/pythonic.py b/patterns/creational/prototype/pythonic.py deleted file mode 100644 index e1b7b10..0000000 --- a/patterns/creational/prototype/pythonic.py +++ /dev/null @@ -1,68 +0,0 @@ -"""What to write instead: a registry of callables. - -The real shape: a scheduler stamping out report jobs from preconfigured -templates. ``functools.partial`` freezes each template's settings into a -zero-argument factory; per-run tweaks come from ``dataclasses.replace`` on -the frozen product -- no clone() protocol anywhere. -""" - -from __future__ import annotations - -from collections.abc import Callable -from dataclasses import dataclass, field, replace -from functools import partial - - -@dataclass(frozen=True) -class ReportJob: - name: str - query: str - recipients: tuple[str, ...] - fmt: str = "pdf" - filters: tuple[str, ...] = () - - -TEMPLATES: dict[str, Callable[[], ReportJob]] = { - "nightly-sales": partial( - ReportJob, - name="nightly-sales", - query="SELECT * FROM sales WHERE day = today()", - recipients=("sales-leads@example.com",), - filters=("exclude-test-accounts",), - ), - "weekly-audit": partial( - ReportJob, - name="weekly-audit", - query="SELECT * FROM ledger WHERE week = this_week()", - recipients=("finance@example.com", "cfo@example.com"), - fmt="xlsx", - ), -} - - -def schedule(template: str, **overrides: object) -> ReportJob: - """A fresh, independently-owned job; overrides customize this run only.""" - job = TEMPLATES[template]() - return replace(job, **overrides) if overrides else job # type: ignore[arg-type] - - -@dataclass -class Scheduler: - queue: list[ReportJob] = field(default_factory=list) - - def enqueue(self, template: str, **overrides: object) -> ReportJob: - job = schedule(template, **overrides) - self.queue.append(job) - return job - - -def main() -> None: - scheduler = Scheduler() - scheduler.enqueue("nightly-sales") - rush = scheduler.enqueue("weekly-audit", fmt="csv") - print(f"queued: {[j.name for j in scheduler.queue]}") - print(f"per-run override, template untouched: {rush.fmt} vs {schedule('weekly-audit').fmt}") - - -if __name__ == "__main__": - main() diff --git a/patterns/creational/prototype/real_world.py b/patterns/creational/prototype/real_world.py deleted file mode 100644 index d146ea1..0000000 --- a/patterns/creational/prototype/real_world.py +++ /dev/null @@ -1,39 +0,0 @@ -"""The stdlib's clone operation: the ``copy`` module. - -``copy.copy`` is a shallow clone (nested mutables are shared); -``copy.deepcopy`` clones the whole object graph. Classes customize both via -the ``__copy__`` / ``__deepcopy__`` protocol -- the Prototype pattern as a -language protocol. -""" - -from __future__ import annotations - -import copy -from dataclasses import dataclass, field - - -@dataclass -class Board: - name: str - tiles: list[list[int]] = field(default_factory=lambda: [[0, 0], [0, 0]]) - - -def shallow_shares_nested_state(template: Board) -> bool: - clone = copy.copy(template) - clone.tiles[0][0] = 9 - return template.tiles[0][0] == 9 # the nested list is shared! - - -def deep_is_independent(template: Board) -> bool: - clone = copy.deepcopy(template) - clone.tiles[0][0] = 9 - return template.tiles[0][0] == 0 - - -def main() -> None: - print(f"shallow copy shares nested state: {shallow_shares_nested_state(Board('a'))}") - print(f"deep copy is independent: {deep_is_independent(Board('b'))}") - - -if __name__ == "__main__": - main() diff --git a/patterns/creational/prototype/tests/__init__.py b/patterns/creational/prototype/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/patterns/creational/prototype/tests/test_prototype.py b/patterns/creational/prototype/tests/test_prototype.py deleted file mode 100644 index 9c5c9af..0000000 --- a/patterns/creational/prototype/tests/test_prototype.py +++ /dev/null @@ -1,49 +0,0 @@ -"""Behavioral tests for all three prototype variants.""" - -from patterns.creational.prototype import naive, pythonic, real_world - - -class TestNaive: - def test_registry_clones_are_independent(self) -> None: - registry = naive.PrototypeRegistry() - registry.register("c", naive.Circle(radius=2, color="green")) - a, b = registry.create("c"), registry.create("c") - assert a is not b - assert isinstance(a, naive.Circle) - assert (a.radius, a.color) == (2, "green") - - def test_mutating_a_clone_leaves_the_exemplar_alone(self) -> None: - registry = naive.PrototypeRegistry() - exemplar = naive.Circle(radius=2, color="green") - registry.register("c", exemplar) - clone = registry.create("c") - assert isinstance(clone, naive.Circle) - clone.radius = 99 - assert exemplar.radius == 2 - - -class TestPythonic: - def test_templates_stamp_out_fresh_equal_jobs(self) -> None: - a, b = pythonic.schedule("nightly-sales"), pythonic.schedule("nightly-sales") - assert a is not b and a == b - assert a.filters == ("exclude-test-accounts",) - - def test_per_run_overrides_leave_the_template_untouched(self) -> None: - rush = pythonic.schedule("weekly-audit", fmt="csv") - assert rush.fmt == "csv" - assert pythonic.schedule("weekly-audit").fmt == "xlsx" - - def test_scheduler_queues_customized_jobs(self) -> None: - scheduler = pythonic.Scheduler() - scheduler.enqueue("nightly-sales") - scheduler.enqueue("weekly-audit", recipients=("audit@x.com",)) - assert [j.name for j in scheduler.queue] == ["nightly-sales", "weekly-audit"] - assert scheduler.queue[1].recipients == ("audit@x.com",) - - -class TestRealWorld: - def test_shallow_copy_shares_nested_state(self) -> None: - assert real_world.shallow_shares_nested_state(real_world.Board("t")) - - def test_deepcopy_is_independent(self) -> None: - assert real_world.deep_is_independent(real_world.Board("t")) diff --git a/patterns/creational/prototype/tests/test_report_job_templates.py b/patterns/creational/prototype/tests/test_report_job_templates.py new file mode 100644 index 0000000..b5a5c7f --- /dev/null +++ b/patterns/creational/prototype/tests/test_report_job_templates.py @@ -0,0 +1,29 @@ +"""Behavioral tests for the report-job mini-project.""" + +from patterns.creational.prototype.examples.report_job_templates.scheduler import Scheduler + + +class TestScheduler: + def test_templates_stamp_out_fresh_equal_jobs(self) -> None: + scheduler = Scheduler() + a = scheduler.enqueue("nightly-sales") + b = scheduler.enqueue("nightly-sales") + assert a is not b + assert a == b + assert a.filters == ("exclude-test-accounts",) + + def test_per_run_overrides_leave_the_template_untouched(self) -> None: + scheduler = Scheduler() + rush = scheduler.enqueue("weekly-audit", fmt="csv") + assert rush.fmt == "csv" + assert scheduler.menu.create("weekly-audit").fmt == "xlsx" + + def test_queue_holds_customized_jobs_in_order(self) -> None: + scheduler = Scheduler() + scheduler.enqueue("nightly-sales") + scheduler.enqueue("weekly-audit", recipients=("audit@example.com",)) + assert [job.name for job in scheduler.queue] == ["nightly-sales", "weekly-audit"] + assert scheduler.queue[1].recipients == ("audit@example.com",) + + def test_menu_lists_its_templates(self) -> None: + assert Scheduler().menu.names() == ["nightly-sales", "weekly-audit"] diff --git a/patterns/creational/prototype/tests/test_templates.py b/patterns/creational/prototype/tests/test_templates.py new file mode 100644 index 0000000..1c392a8 --- /dev/null +++ b/patterns/creational/prototype/tests/test_templates.py @@ -0,0 +1,74 @@ +"""Behavioral tests for the template-registry pattern code.""" + +from dataclasses import dataclass +from functools import partial + +import pytest + +from patterns.creational.prototype.pattern import TemplateRegistry + + +@dataclass(frozen=True) +class Widget: + label: str + size: int = 1 + + +class TestTemplateRegistry: + def test_every_create_builds_a_fresh_product(self) -> None: + menu: TemplateRegistry[Widget] = TemplateRegistry() + menu.register("small", partial(Widget, label="small")) + a, b = menu.create("small"), menu.create("small") + assert a is not b + assert a == b + + def test_overrides_customize_one_product_only(self) -> None: + menu: TemplateRegistry[Widget] = TemplateRegistry() + menu.register("small", partial(Widget, label="small")) + big = menu.create("small", size=9) + assert big.size == 9 + assert menu.create("small").size == 1 # template untouched + + def test_unknown_template_names_the_menu(self) -> None: + menu: TemplateRegistry[Widget] = TemplateRegistry() + menu.register("small", partial(Widget, label="small")) + with pytest.raises(ValueError, match=r"unknown template 'huge' \(has: \['small'\]\)"): + menu.create("huge") + + def test_register_returns_the_template(self) -> None: + menu: TemplateRegistry[Widget] = TemplateRegistry() + + def blank() -> Widget: + return Widget(label="blank") + + assert menu.register("blank", blank) is blank + assert menu.names() == ["blank"] + + def test_names_come_back_sorted_regardless_of_registration_order(self) -> None: + menu: TemplateRegistry[Widget] = TemplateRegistry() + menu.register("zeta", partial(Widget, label="z")) + menu.register("alpha", partial(Widget, label="a")) + assert menu.names() == ["alpha", "zeta"] + + def test_duplicate_registration_is_refused_unless_replace(self) -> None: + menu: TemplateRegistry[Widget] = TemplateRegistry() + menu.register("small", partial(Widget, label="small")) + with pytest.raises(ValueError, match="already registered"): + menu.register("small", partial(Widget, label="other")) + menu.register("small", partial(Widget, label="other"), replace=True) + assert menu.create("small").label == "other" + + def test_overrides_refuse_a_class_valued_product(self) -> None: + # is_dataclass(SomeDataclass) is True for the class object itself — + # the isinstance(product, type) half of the guard rejects it. + menu: TemplateRegistry[type] = TemplateRegistry() + menu.register("the-class", lambda: Widget) + with pytest.raises(TypeError, match="dataclass"): + menu.create("the-class", label="nope") + + def test_overrides_refuse_non_dataclass_products(self) -> None: + menu: TemplateRegistry[str] = TemplateRegistry() + menu.register("greeting", lambda: "hello") + assert menu.create("greeting") == "hello" + with pytest.raises(TypeError, match="dataclass"): + menu.create("greeting", tone="loud") diff --git a/patterns/creational/singleton/README.md b/patterns/creational/singleton/README.md index 05fc2de..301415f 100644 --- a/patterns/creational/singleton/README.md +++ b/patterns/creational/singleton/README.md @@ -15,36 +15,18 @@ stdlib_sightings: [None, Ellipsis, NotImplemented] # Singleton -## Problem - -Some resources must exist exactly once: a configuration object, a connection -pool, a process-wide registry. The Gang of Four answer is a class that -intercepts construction and always hands back the same instance. - -## Naive solution - -`naive.py` is the classic implementation: override `__new__`, cache the -instance on the class. It works, but note what Python forces on you — callers -still *look* like they're constructing (`Logger()`), `__init__` re-runs on -every call unless you guard it, and subclassing gets weird fast. - -## Pythonic solution - -Python already has singletons: **modules**. A module is created once, cached in -`sys.modules`, and every `import` returns the same object. `pythonic.py` shows -the Global Object pattern — instantiate a plain class once at module level (or -lazily behind a function) and import that. No metaclass, no `__new__`, nothing -to explain in review. - -## In the wild - -`None`, `Ellipsis`, and `NotImplemented` are the interpreter's own singletons — -that's why `is` comparison against them is correct. Every imported module is -one too: `real_world.py` proves it. - -## Verdict - -**Prefer an alternative.** The naive form exists here for study; if you're -reaching for it, write a module-level instance instead. The exceptions are rare -enough that you'll know them when you hit them (lazy construction that must be -thread-safe, C-extension interop). +One instance for the whole process, reachable from anywhere. **Verdict: prefer +an alternative** — a module already is a singleton; write a module-level +instance, or a `Shared` accessor when construction must wait. Keep a reset +seam for tests. + +| Where | What | +|---|---| +| [`pattern/`](pattern/) | The importable code: `Shared` — lazy build, one instance, `reset()` seam | +| [`docs/`](docs/) | [Fundamentals](docs/fundamentals.md) · [Implementation guide](docs/implementation.md) · [External examples](docs/examples.md) | +| [`examples/app_config/`](examples/app_config/) | Mini-project: process-wide settings behind `get_settings()` | +| [`tests/`](tests/) | Behavioral tests for the pattern and the mini-project | + +```bash +uv run python -m patterns.creational.singleton.examples.app_config.main +``` diff --git a/patterns/creational/singleton/__init__.py b/patterns/creational/singleton/__init__.py index 3a3757f..ffe491c 100644 --- a/patterns/creational/singleton/__init__.py +++ b/patterns/creational/singleton/__init__.py @@ -1 +1 @@ -"""Singleton: one instance, program-wide access. Verdict: prefer the Global Object pattern.""" +from .pattern import Shared as Shared diff --git a/patterns/creational/singleton/docs/examples.md b/patterns/creational/singleton/docs/examples.md new file mode 100644 index 0000000..d484401 --- /dev/null +++ b/patterns/creational/singleton/docs/examples.md @@ -0,0 +1,38 @@ +# Singleton — where it lives outside this repo + +Cited, real implementations to study (or point an agent at) when designing or +reviewing shared-instance code. + +## Python standard library + +- **`None`, `Ellipsis`, `NotImplemented`.** Interpreter-level singletons — + each has exactly one instance, which is why `is` comparison against them is + the correct idiom. + [docs.python.org/3/library/constants.html](https://docs.python.org/3/library/constants.html) +- **Modules themselves.** `import` consults `sys.modules` and returns the + cached module object; every module is a built-once, process-wide instance. + That cache is what the Global Object pattern rides. + [docs.python.org/3/reference/import.html#the-module-cache](https://docs.python.org/3/reference/import.html#the-module-cache) +- **`logging.getLogger(name)`.** One logger per name, cached by a hidden + manager — the accessor form of the pattern, shipped in the stdlib. + [docs.python.org/3/library/logging.html#logging.getLogger](https://docs.python.org/3/library/logging.html#logging.getLogger) + +## Major ecosystems + +- **`django.conf.settings`.** A lazily-built global object behind a module + attribute — Django needs configure-then-build ordering, exactly the case + for the accessor/lazy form rather than import-time construction. + *(unverified source link)* + [docs.djangoproject.com/en/stable/topics/settings/](https://docs.djangoproject.com/en/stable/topics/settings/) +- **The guide's chapter** on the pattern's history and why Python rarely + needs the class-based form. + [python-patterns.guide/gang-of-four/singleton/](https://python-patterns.guide/gang-of-four/singleton/) + +## What to notice across all of them + +Nothing in production Python intercepts `__new__` to enforce oneness. The +stdlib and Django both reach for *a cache plus an accessor* — uniqueness is a +property of where the object is stored, not of its class. And each one has an +answer to test isolation (logging's per-name registry, Django's +`override_settings`) — when reviewing shared-instance code, ask where the +reset seam is. diff --git a/patterns/creational/singleton/docs/fundamentals.md b/patterns/creational/singleton/docs/fundamentals.md new file mode 100644 index 0000000..6063857 --- /dev/null +++ b/patterns/creational/singleton/docs/fundamentals.md @@ -0,0 +1,75 @@ +# Singleton — fundamentals + +## Intent + +Guarantee a class has exactly one instance and give the whole program access +to it — a configuration object, a connection pool, a process-wide registry. + +## Participants + +| Role | Classic (GoF) form | Python form | +|---|---|---| +| The single instance | Cached on the class by an overridden `__new__` | An ordinary object at module level, or behind `Shared` in [`pattern/shared.py`](../pattern/shared.py) | +| Global access point | Calling the constructor (`Logger()`) — which lies | `import` the object, or call a small accessor (`get_settings()`) | +| Laziness | The `__new__` cache check | The accessor builds on first call | +| Test isolation | None — the hidden instance leaks between tests | An explicit `reset()` seam | + +## Mechanism + +1. The instance lives in exactly one place the process agrees on. +2. Everyone reaches it the same way — import or accessor — instead of + constructing their own. +3. Python already runs this mechanism for you: a module is created once, + cached in `sys.modules`, and every `import` returns the same object. The + Global Object pattern rides that. + +## The classic form, and what Python absorbs + +The textbook version intercepts construction: + +```python +class Logger: + _instance: ClassVar[Self | None] = None + + def __new__(cls) -> Self: + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __init__(self) -> None: + # __init__ still runs on EVERY Logger() call — without this + # guard, a second call wipes the state. + if not hasattr(self, "lines"): + self.lines: list[str] = [] +``` + +Note the two warts Python forces on it: callers still *look* like they're +constructing, and `__init__` re-runs per call, so the class must defend its +own state. All of that machinery buys what a module-level assignment already +has: + +```python +logger = Logger() # the Global Object: built once, import it +``` + +## When to use it + +- One process-wide resource genuinely wanted by everything (settings, a + metrics sink) → module global or `Shared` accessor. +- Construction must be deferred (reads env/files, needs configuration first) + → the accessor form, which is also where a lock goes if threads race. + +## When not to use it + +- The "global" is only shared by a few collaborators → pass it (dependency + injection); globals are a convenience, not an architecture. +- You want swappable implementations in tests → inject, or at minimum keep + the reset seam; a hidden class-cached instance makes tests order-dependent. +- Interpreter-level uniqueness (`None`-style sentinels) → see the + sentinel_object unit; that's a different job. + +## Verdict: prefer an alternative + +A module is already a singleton. Write a module-level instance, or `Shared` +when construction must wait — and keep the reset seam, because the classic +form's real cost lands in your test suite. diff --git a/patterns/creational/singleton/docs/implementation.md b/patterns/creational/singleton/docs/implementation.md new file mode 100644 index 0000000..b1ec8f6 --- /dev/null +++ b/patterns/creational/singleton/docs/implementation.md @@ -0,0 +1,78 @@ +# Singleton — putting it into a system + +## The smell it fixes + +Every module constructing its own copy of a process-wide resource — or the +opposite failure, a class enforcing oneness through `__new__` gymnastics that +break in review and in tests: + +```python +class Config: + _instance = None + + def __new__(cls): # clever, hidden, test-hostile + ... +``` + +## Steps + +1. **Write the class as if it were ordinary.** Nothing about `Settings` + should know it will be shared — that keeps it constructible in tests. +2. **Choose eager or lazy.** Cheap and configuration-free → build it at + module level (`logger = Logger()`) and import it; done. Reads env/files or + must be configured first → step 3. +3. **Put the instance behind `Shared(factory)`** and export a small accessor + (`get_settings()`); the factory runs on first use only, keeping import + side-effect-free. +4. **Export the reset seam** (`reset_settings()`), and call it in test + setup/teardown — shared state between tests is the pattern's real tax. +5. **Keep construction injectable**: the factory reads from a *mapping + parameter* defaulting to `os.environ`, so tests build `Settings` from a + dict without patching globals. + +```python +from patterns.creational.singleton import Shared + +_shared: Shared[Settings] = Shared(load_settings) + + +def get_settings() -> Settings: + return _shared.get() + + +def reset_settings() -> None: + _shared.reset() +``` + +## Python idioms that keep it small + +- **The module is the singleton.** `sys.modules` is the instance cache you + were about to write. +- **A frozen dataclass as the shared object** removes the "who mutated the + global?" class of bug outright. +- **`functools.partial(load_settings, canned_env)`** makes a `Shared` for + tests without touching the real one. + +## Pitfalls + +- **Thread races on first build.** `Shared.get` is not locked; two threads + can each run the factory once. Fine for value objects — wrap a lock around + construction that opens sockets or writes files. +- **The `__new__` dance's hidden cost**: `__init__` still runs on every call, + so state needs a guard — and everyone forgets the guard. +- **Import-time construction that does I/O** turns every importer into a + side effect; laziness (step 3) is the fix, not deeper caching. +- **No reset seam** makes test order matter; the accessor pattern without + `reset()` is only half the pattern. +- **Reaching for a global at all** when only two collaborators share the + object — pass it as an argument and skip this page. + +## Worked example + +[`examples/app_config/`](../examples/app_config/) is process-wide settings +with lazy build, cached reads, env re-read after reset, and an injected +mapping for tests — run it with: + +```bash +uv run python -m patterns.creational.singleton.examples.app_config.main +``` diff --git a/patterns/creational/singleton/examples/app_config/main.py b/patterns/creational/singleton/examples/app_config/main.py new file mode 100644 index 0000000..5ab62f8 --- /dev/null +++ b/patterns/creational/singleton/examples/app_config/main.py @@ -0,0 +1,36 @@ +"""Demo: one settings object for the process, and the test-reset seam.""" + +from __future__ import annotations + +import os + +from patterns.creational.singleton.examples.app_config.settings import ( + get_settings, + load_settings, + reset_settings, +) + + +def main() -> None: + first = get_settings() + print(f"settings: env={first.env} workers={first.max_workers}") + print(f"same object twice: {get_settings() is first}") + + reset_settings() + print(f"fresh after reset: {get_settings() is not first}") + + os.environ["APP_MAX_WORKERS"] = "16" + try: + print(f"still cached: workers={get_settings().max_workers}") + reset_settings() + print(f"reset re-reads: workers={get_settings().max_workers}") + finally: + del os.environ["APP_MAX_WORKERS"] + reset_settings() + + canned = load_settings({"APP_ENV": "test", "APP_DEBUG": "1"}) + print(f"injected mapping: env={canned.env} debug={canned.debug}") + + +if __name__ == "__main__": + main() diff --git a/patterns/creational/singleton/examples/app_config/settings.py b/patterns/creational/singleton/examples/app_config/settings.py new file mode 100644 index 0000000..449e099 --- /dev/null +++ b/patterns/creational/singleton/examples/app_config/settings.py @@ -0,0 +1,56 @@ +"""Application settings built once, shared everywhere, resettable in tests. + +The exact job Singleton is always reached for — one configuration object for +the whole process — done the Python way: a frozen ``Settings`` dataclass, a +loader that reads an environment *mapping* (injected, so tests never touch +the real ``os.environ``), and one ``Shared`` accessor giving lazy build, +process-wide sharing, and a reset seam. No ``__new__``, nothing hidden. +""" + +from __future__ import annotations + +import os +from collections.abc import Mapping +from dataclasses import dataclass + +from patterns.creational.singleton.pattern import Shared + + +@dataclass(frozen=True) +class Settings: + """Everything the app needs to know about its environment.""" + + env: str + database_url: str + debug: bool + max_workers: int + + +def load_settings(source: Mapping[str, str] | None = None) -> Settings: + """Parse settings from an env-style mapping (``os.environ`` by default). + + Raises ``ValueError`` if ``APP_MAX_WORKERS`` is not an integer — through + ``get_settings()`` that surfaces on first use, which is the honest place + for a misconfigured environment to fail. + """ + env = os.environ if source is None else source + return Settings( + env=env.get("APP_ENV", "dev"), + database_url=env.get("APP_DATABASE_URL", "sqlite:///dev.db"), + debug=env.get("APP_DEBUG", "0") == "1", + max_workers=int(env.get("APP_MAX_WORKERS", "4")), + ) + + +#: The one process-wide slot. Nothing is read until the first get_settings(). +_shared: Shared[Settings] = Shared(load_settings) + + +def get_settings() -> Settings: + """The app-wide accessor: same ``Settings`` object on every call.""" + return _shared.get() + + +def reset_settings() -> None: + """Test seam: drop the cached instance so the next call re-reads the env.""" + _shared.reset() diff --git a/patterns/creational/singleton/naive.py b/patterns/creational/singleton/naive.py deleted file mode 100644 index c27364c..0000000 --- a/patterns/creational/singleton/naive.py +++ /dev/null @@ -1,42 +0,0 @@ -"""The Gang of Four Singleton, translated literally. - -The class intercepts construction in ``__new__`` and caches the sole instance. -Note the wart this forces in Python: ``__init__`` runs on *every* call, so it -must guard against re-initialization itself. -""" - -from __future__ import annotations - -from typing import ClassVar, Self - - -class Logger: - """A classic GoF singleton: every construction returns the same instance.""" - - _instance: ClassVar[Self | None] = None - - def __new__(cls) -> Self: - if cls._instance is None: - cls._instance = super().__new__(cls) - return cls._instance - - def __init__(self) -> None: - # Without this guard, a second Logger() call would wipe the log. - if not hasattr(self, "lines"): - self.lines: list[str] = [] - - def log(self, message: str) -> None: - self.lines.append(message) - - -def main() -> None: - a = Logger() - b = Logger() - a.log("first") - b.log("second") - print(f"a is b: {a is b}") - print(f"log seen by both: {a.lines}") - - -if __name__ == "__main__": - main() diff --git a/patterns/creational/singleton/pattern/__init__.py b/patterns/creational/singleton/pattern/__init__.py new file mode 100644 index 0000000..21667fc --- /dev/null +++ b/patterns/creational/singleton/pattern/__init__.py @@ -0,0 +1 @@ +from .shared import Shared as Shared diff --git a/patterns/creational/singleton/pattern/shared.py b/patterns/creational/singleton/pattern/shared.py new file mode 100644 index 0000000..78a9c88 --- /dev/null +++ b/patterns/creational/singleton/pattern/shared.py @@ -0,0 +1,44 @@ +"""What to write instead of a Singleton class: a shared-instance accessor. + +A module is already a singleton — created once, cached in ``sys.modules`` — +so the simplest form is an ordinary object built at module level (the Global +Object pattern). When construction is expensive or needs configuration first, +``Shared`` wraps the remaining bookkeeping: build on first use, hand back the +same instance after, and — the part the classic form always forgets — an +explicit ``reset()`` seam so tests don't leak state into each other. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Generic, TypeVar + +T = TypeVar("T") + + +class Shared(Generic[T]): + """One lazily-built instance behind an accessor, with a test-reset seam. + + Not thread-safe: two threads racing the first ``get`` can each build an + instance (one wins the slot). Harmless for cheap objects; wrap ``get`` in + a ``threading.Lock`` if construction has side effects. + """ + + def __init__(self, factory: Callable[[], T]) -> None: + self._factory = factory + self._instance: T | None = None + + def get(self) -> T: + """Build the instance on first call, then keep handing it back.""" + if self._instance is None: + self._instance = self._factory() + return self._instance + + def reset(self) -> None: + """Drop the instance so the next ``get`` builds fresh — for tests.""" + self._instance = None + + @property + def built(self) -> bool: + """Whether the instance exists yet (laziness is observable).""" + return self._instance is not None diff --git a/patterns/creational/singleton/pythonic.py b/patterns/creational/singleton/pythonic.py deleted file mode 100644 index 0d2d01b..0000000 --- a/patterns/creational/singleton/pythonic.py +++ /dev/null @@ -1,52 +0,0 @@ -"""What to write instead: the Global Object pattern. - -A module is itself a singleton -- created once, cached in ``sys.modules``. -So the pythonic "Singleton" is a perfectly ordinary class instantiated once -at module level. Callers ``import`` the instance instead of constructing it. - -For construction that is expensive or needs configuration first, hide the -instance behind a small accessor function instead (shown below). -""" - -from __future__ import annotations - - -class Logger: - """An ordinary class -- nothing about it knows it will be shared.""" - - def __init__(self) -> None: - self.lines: list[str] = [] - - def log(self, message: str) -> None: - self.lines.append(message) - - -#: The Global Object: built once, at import time. This is the whole pattern. -logger = Logger() - - -# Lazy variant, for when construction must wait until first use: -_lazy_instance: Logger | None = None - - -def get_logger() -> Logger: - """Build the shared instance on first call, then keep handing it back. - - Not thread-safe: two threads racing the first call can each build a - Logger (one wins the slot). Harmless for a cheap object; guard with a - threading.Lock if construction has side effects. - """ - global _lazy_instance - if _lazy_instance is None: - _lazy_instance = Logger() - return _lazy_instance - - -def main() -> None: - logger.log("hello") - print(f"module global is shared: {logger.lines}") - print(f"lazy accessor is stable: {get_logger() is get_logger()}") - - -if __name__ == "__main__": - main() diff --git a/patterns/creational/singleton/real_world.py b/patterns/creational/singleton/real_world.py deleted file mode 100644 index 7a161d1..0000000 --- a/patterns/creational/singleton/real_world.py +++ /dev/null @@ -1,37 +0,0 @@ -"""Singletons the interpreter already ships. - -``None``, ``Ellipsis``, and ``NotImplemented`` each have exactly one instance, -which is why identity comparison (``is``) against them is the correct idiom. -And every module is a singleton: ``import`` consults ``sys.modules`` and -returns the cached object rather than building a new one. -""" - -from __future__ import annotations - -import sys -import types - - -def none_is_a_singleton() -> bool: - """All ``None`` values in a program are the very same object.""" - a: object | None = None - b: object | None = None - # Every None in the process is literally the same object. - return a is b and a is None - - -def modules_are_singletons() -> bool: - """A second import returns the cached module object, not a copy.""" - first = __import__("json") - second = __import__("json") - return first is second and sys.modules["json"] is first - - -def main() -> None: - print(f"None is a singleton: {none_is_a_singleton()}") - print(f"modules are singletons: {modules_are_singletons()}") - print(f"a module's type: {types.ModuleType.__name__}") - - -if __name__ == "__main__": - main() diff --git a/patterns/creational/singleton/tests/__init__.py b/patterns/creational/singleton/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/patterns/creational/singleton/tests/test_app_config.py b/patterns/creational/singleton/tests/test_app_config.py new file mode 100644 index 0000000..0b1327e --- /dev/null +++ b/patterns/creational/singleton/tests/test_app_config.py @@ -0,0 +1,52 @@ +"""Behavioral tests for the app-config mini-project.""" + +import dataclasses +from collections.abc import Iterator + +import pytest + +from patterns.creational.singleton.examples.app_config.settings import ( + get_settings, + load_settings, + reset_settings, +) + + +@pytest.fixture(autouse=True) +def clean_slate() -> Iterator[None]: + reset_settings() # the seam under test is also what isolates these tests + yield + reset_settings() + + +class TestAppConfig: + def test_whole_process_shares_one_settings_object(self) -> None: + assert get_settings() is get_settings() + + def test_reset_seam_builds_fresh(self) -> None: + first = get_settings() + reset_settings() + assert get_settings() is not first + + def test_env_changes_invisible_until_reset(self, monkeypatch: pytest.MonkeyPatch) -> None: + before = get_settings().max_workers + monkeypatch.setenv("APP_MAX_WORKERS", str(before + 12)) + assert get_settings().max_workers == before # cached + reset_settings() + assert get_settings().max_workers == before + 12 # re-read + + def test_loader_takes_an_injected_mapping(self) -> None: + settings = load_settings({"APP_ENV": "test", "APP_DEBUG": "1"}) + assert settings.env == "test" + assert settings.debug is True + assert settings.max_workers == 4 # defaults still apply + + def test_settings_are_immutable(self) -> None: + with pytest.raises(dataclasses.FrozenInstanceError): + get_settings().env = "prod" # type: ignore[misc] + + def test_malformed_worker_count_fails_loudly(self) -> None: + # Documented: a non-integer APP_MAX_WORKERS raises at load time — + # through the accessor, that means on first use. + with pytest.raises(ValueError): + load_settings({"APP_MAX_WORKERS": "many"}) diff --git a/patterns/creational/singleton/tests/test_shared.py b/patterns/creational/singleton/tests/test_shared.py new file mode 100644 index 0000000..6b08710 --- /dev/null +++ b/patterns/creational/singleton/tests/test_shared.py @@ -0,0 +1,40 @@ +"""Behavioral tests for the shared-instance accessor.""" + +from patterns.creational.singleton.pattern import Shared + + +class Counter: + built = 0 + + def __init__(self) -> None: + type(self).built += 1 + + +class TestShared: + def setup_method(self) -> None: + Counter.built = 0 + + def test_same_instance_every_get(self) -> None: + shared = Shared(Counter) + assert shared.get() is shared.get() + + def test_factory_runs_once(self) -> None: + shared = Shared(Counter) + shared.get() + shared.get() + assert Counter.built == 1 + + def test_build_is_lazy(self) -> None: + shared = Shared(Counter) + assert not shared.built + assert Counter.built == 0 + shared.get() + assert shared.built + + def test_reset_builds_fresh_next_time(self) -> None: + shared = Shared(Counter) + first = shared.get() + shared.reset() + assert not shared.built + assert shared.get() is not first + assert Counter.built == 2 diff --git a/patterns/creational/singleton/tests/test_singleton.py b/patterns/creational/singleton/tests/test_singleton.py deleted file mode 100644 index c009161..0000000 --- a/patterns/creational/singleton/tests/test_singleton.py +++ /dev/null @@ -1,43 +0,0 @@ -"""Behavioral tests for all three singleton variants.""" - -from patterns.creational.singleton import naive, pythonic, real_world - - -class TestNaive: - def test_identity(self) -> None: - assert naive.Logger() is naive.Logger() - - def test_state_is_shared(self) -> None: - a = naive.Logger() - a.lines.clear() - naive.Logger().log("hi") - assert a.lines == ["hi"] - - def test_reinit_does_not_wipe_state(self) -> None: - a = naive.Logger() - a.lines.clear() - a.log("kept") - naive.Logger() # __init__ runs again; the guard must preserve state - assert a.lines == ["kept"] - - -class TestPythonic: - def test_module_global_is_stable(self) -> None: - assert pythonic.logger is pythonic.logger - - def test_lazy_accessor_returns_same_instance(self) -> None: - assert pythonic.get_logger() is pythonic.get_logger() - - def test_lazy_accessor_builds_a_real_logger(self) -> None: - log = pythonic.get_logger() - log.lines.clear() - log.log("x") - assert pythonic.get_logger().lines == ["x"] - - -class TestRealWorld: - def test_none_identity(self) -> None: - assert real_world.none_is_a_singleton() - - def test_module_identity(self) -> None: - assert real_world.modules_are_singletons() diff --git a/patterns/modern/__init__.py b/patterns/modern/__init__.py deleted file mode 100644 index e52f98d..0000000 --- a/patterns/modern/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Modern Python patterns beyond the Gang of Four.""" diff --git a/patterns/modern/async_producer_consumer/README.md b/patterns/modern/async_producer_consumer/README.md index 365aa39..9665a88 100644 --- a/patterns/modern/async_producer_consumer/README.md +++ b/patterns/modern/async_producer_consumer/README.md @@ -14,31 +14,17 @@ stdlib_sightings: [asyncio.Queue, asyncio.TaskGroup, queue.Queue] # Async Producer/Consumer -## Problem - -Producers generate work faster (or slower) than consumers process it. You -want N workers pulling from a shared source, bounded memory in between, and -a shutdown that neither drops items nor hangs. - -## Naive solution - -`naive.py` is the thread version: `threading.Thread` workers around a -`queue.Queue` with sentinels — fine, but each worker burns an OS thread and -coordination is manual. - -## Pythonic solution - -`asyncio.Queue` with `TaskGroup`-managed workers: `maxsize` gives -backpressure, `queue.join()` waits for completion, cancellation ends the -idle workers. All the coordination is in the queue. - -## In the wild - -This *is* the stdlib idiom — the asyncio docs' own queue example is this -pattern; `real_world.py` shapes it as a rate-limited fetch pipeline with -per-item results collected in completion order. - -## Verdict - -**Use with care.** The right tool for I/O-bound fan-out; get the shutdown -discipline right (and tested) or debug it forever. +Fan I/O-bound work out to N workers over a bounded queue — backpressure by +`maxsize`, shutdown as an explicit, tested choice. **Verdict: use with care** +— the right tool for async fan-out; the two caveats are where it bites. + +| Where | What | +|---|---| +| [`pattern/`](pattern/) | The importable code: `WorkerPool`, `Shutdown`, `process_all` | +| [`docs/`](docs/) | [Fundamentals](docs/fundamentals.md) · [Implementation guide](docs/implementation.md) · [External examples](docs/examples.md) | +| [`examples/feed_fetcher/`](examples/feed_fetcher/) | Mini-project: feed pipeline with per-item failure capture, both shutdown disciplines | +| [`tests/`](tests/) | Behavioral tests for the pool and the mini-project | + +```bash +uv run python -m patterns.modern.async_producer_consumer.examples.feed_fetcher.main +``` diff --git a/patterns/modern/async_producer_consumer/__init__.py b/patterns/modern/async_producer_consumer/__init__.py index 85e6399..dc4df9a 100644 --- a/patterns/modern/async_producer_consumer/__init__.py +++ b/patterns/modern/async_producer_consumer/__init__.py @@ -1 +1,4 @@ -"""Async Producer/Consumer: bounded queues between async workers.""" +from .pattern import Processor as Processor +from .pattern import Shutdown as Shutdown +from .pattern import WorkerPool as WorkerPool +from .pattern import process_all as process_all diff --git a/patterns/modern/async_producer_consumer/docs/examples.md b/patterns/modern/async_producer_consumer/docs/examples.md new file mode 100644 index 0000000..122a56c --- /dev/null +++ b/patterns/modern/async_producer_consumer/docs/examples.md @@ -0,0 +1,25 @@ +# Async Producer/Consumer — external examples + +Real embodiments of the pattern outside this repo, for deeper study. + +## Standard library + +- **`asyncio.Queue`** — the buffer itself; the docs include a worked + producer/consumer example that is this pattern verbatim. + +- **`asyncio.TaskGroup`** (3.11+) — structured lifetime for the worker + tasks; the reason the pool needs no manual join/cancel bookkeeping + beyond its shutdown discipline. + +- **`queue.Queue`** — the threaded flavor, with the same + `task_done()`/`join()` contract the JOIN_AND_CANCEL discipline uses. + +- **`concurrent.futures`** — the pool-shaped alternative when items are + independent and you want futures rather than a shared queue. + + +## Elsewhere + +- **aiohttp** client examples — crawler-style fan-out over a session is + this pattern with real HTTP in the processor seam. *(unverified)* + diff --git a/patterns/modern/async_producer_consumer/docs/fundamentals.md b/patterns/modern/async_producer_consumer/docs/fundamentals.md new file mode 100644 index 0000000..c9cc630 --- /dev/null +++ b/patterns/modern/async_producer_consumer/docs/fundamentals.md @@ -0,0 +1,86 @@ +# Async Producer/Consumer — fundamentals + +## Intent + +Decouple work *generation* from work *processing*: producers enqueue, N +consumers dequeue and process, and a bounded buffer between them keeps a fast +side from drowning a slow one. Under asyncio the pattern is how you fan +I/O-bound work out to concurrent workers with bounded memory and a shutdown +that neither drops items nor hangs. + +## Participants + +| Role | Classic (threaded) form | asyncio form | +|---|---|---| +| Buffer | `queue.Queue` + locks/conditions | `asyncio.Queue(maxsize=...)` — backpressure built in | +| Producers | Threads calling `put` | Any coroutine calling `await queue.put(item)` | +| Consumers | Worker threads in a `get` loop | N worker tasks — `WorkerPool` in [`pattern/pool.py`](../pattern/pool.py) | +| Worker lifetime | Manual `start`/`join` | `asyncio.TaskGroup` owns the tasks structurally | +| Shutdown discipline | Ad hoc, often forgotten | An explicit choice: `Shutdown.SENTINEL` or `Shutdown.JOIN_AND_CANCEL` | + +## Mechanism + +1. A bounded queue is created; `maxsize` is the memory budget *and* the + backpressure valve — `put` blocks when the buffer is full. +2. N workers start, each looping `get → process`. +3. Producers enqueue items; slow consumers automatically slow the producers. +4. Shutdown, the part first attempts get wrong, is one of two disciplines: + - **Sentinel** — after the last item, enqueue one end-marker per worker; + each worker exits on dequeuing one. + - **Join and cancel** — workers mark `task_done()`; the coordinator awaits + `queue.join()` (every item fetched *and* finished), then cancels the + now-idle workers. + +## The classic form, and what Python absorbs + +Before asyncio this was the thread pattern — an OS thread per worker, a lock +around shared results, and hand-rolled sentinel plumbing: + +```python +def process_all_threaded(items: list[str], worker_count: int = 2) -> list[str]: + channel: queue.Queue[str | None] = queue.Queue() + results: list[str] = [] + lock = threading.Lock() + + def worker() -> None: + while (item := channel.get()) is not None: + with lock: + results.append(item.upper()) + + workers = [threading.Thread(target=worker) for _ in range(worker_count)] + for w in workers: + w.start() + for item in items: + channel.put(item) + for _ in workers: + channel.put(None) # one sentinel per worker — forget one and it hangs + for w in workers: + w.join() + return sorted(results) +``` + +`asyncio.Queue` absorbs the locking entirely and `TaskGroup` absorbs the +lifetime bookkeeping. What Python does *not* absorb is the design: choosing +`maxsize`, and choosing — then testing — the shutdown discipline. That +remainder is the pattern. + +## When to use it + +- Many I/O-bound items (fetches, uploads, API calls) and you want bounded + concurrency rather than a task per item. +- Producers and consumers run at different, varying speeds and you need + memory to stay bounded in between. + +## When not to use it + +- CPU-bound work — an event loop serializes it; use a process pool. +- Independent items with no need to bound in-flight memory — + `asyncio.gather` (or `TaskGroup` alone) over per-item tasks is simpler. +- One item, one consumer — that is just an awaited call. + +## Verdict: use with care + +The right tool for I/O fan-out, with two sharp edges the caveats name: an +unbounded queue turns a slow consumer into a memory leak, and an untested +shutdown path is where these systems hang. `WorkerPool` makes both choices +explicit arguments so they cannot be forgotten — only wrong on purpose. diff --git a/patterns/modern/async_producer_consumer/docs/implementation.md b/patterns/modern/async_producer_consumer/docs/implementation.md new file mode 100644 index 0000000..52115e9 --- /dev/null +++ b/patterns/modern/async_producer_consumer/docs/implementation.md @@ -0,0 +1,48 @@ +# Async Producer/Consumer — implementation guide + +## The smell that calls for it + +An `async` code path does `for item in items: await do(item)` and the wall +clock shows it — sequential awaits over independent I/O. Or the opposite: +`gather` over ten thousand tasks and memory shows *that*. + +## Introducing it, step by step + +1. **Isolate the per-item coroutine.** One `async def process(item) -> result` + with no shared state. This is the seam everything else plugs into. +2. **Pick the memory budget.** `maxsize` is how many items may sit fetched- + but-unprocessed. Small (2–16) is almost always right; it exists to create + backpressure, not to be a cache. +3. **Pick the worker count.** Concurrency toward the slow resource — for + HTTP this is "how many connections is polite", not "how many items exist". +4. **Pick the shutdown discipline — and write the test the same day.** + - `Shutdown.JOIN_AND_CANCEL` when a coordinator knows the item set and + wants "everything finished" as a joinable event. + - `Shutdown.SENTINEL` when workers should drain the queue and stop: + the pool enqueues one end-marker per worker after the items are + exhausted. +5. **Decide the failure policy at the edge.** The pool is fail-fast (one bad + item cancels the run, surfacing as an `ExceptionGroup`). If a bad item + must not kill the batch, catch inside *your* processor and return an + outcome object — as the [feed_fetcher example](../examples/feed_fetcher/) + does with `FetchOutcome`. + +## Idioms + +- `async with asyncio.TaskGroup()` owns the workers; nothing outlives the + block, and worker exceptions propagate instead of vanishing. +- Results in completion order, ordering as the caller's last step — sorting + inside the pool would hide the concurrency it exists to provide. +- The same shape works threaded (`queue.Queue`, `concurrent.futures`) when + the work is blocking rather than async; the design choices carry over. + +## Pitfalls + +- **Unbounded queue.** `asyncio.Queue()` with no `maxsize` removes the + backpressure that is half the pattern's point. +- **Mixed disciplines.** `task_done()` bookkeeping *and* sentinels in the + same pool — each looks redundant, together they deadlock or exit early. +- **Sentinel miscounting.** Fewer end-markers than workers hangs the rest; + route all shutdown through one tested code path (the pool), not call sites. +- **CPU-bound processors.** The event loop runs one coroutine at a time; + workers give you interleaved I/O waits, never parallel computation. diff --git a/patterns/modern/async_producer_consumer/examples/feed_fetcher/fetcher.py b/patterns/modern/async_producer_consumer/examples/feed_fetcher/fetcher.py new file mode 100644 index 0000000..6632fce --- /dev/null +++ b/patterns/modern/async_producer_consumer/examples/feed_fetcher/fetcher.py @@ -0,0 +1,57 @@ +"""The pipeline: N workers pull feeds through a bounded queue. + +A fake network stands in for HTTP so the demo and tests run offline and +deterministically; swap ``fetch_entries`` for a real client and nothing +else changes. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import Iterable + +from patterns.modern.async_producer_consumer.examples.feed_fetcher.models import ( + Feed, + FetchOutcome, +) +from patterns.modern.async_producer_consumer.pattern import Shutdown, WorkerPool + + +async def fetch_entries(feed: Feed) -> int: + """Pretend to fetch and parse one feed; raise on a bad host.""" + await asyncio.sleep(0) # stand-in for real async I/O + if "unreachable" in feed.url: + raise ConnectionError(f"cannot reach {feed.url}") + return len(feed.name) # deterministic stand-in for "entries parsed" + + +async def fetch_all( + feeds: Iterable[Feed], + *, + workers: int = 3, + maxsize: int = 2, + shutdown: Shutdown = Shutdown.JOIN_AND_CANCEL, +) -> list[FetchOutcome]: + """Fetch every feed; failures become recorded outcomes, not crashes.""" + + async def capture(feed: Feed) -> FetchOutcome: + try: + return FetchOutcome(feed, entries=await fetch_entries(feed)) + except ConnectionError as exc: + return FetchOutcome(feed, error=str(exc)) + + pool: WorkerPool[Feed, FetchOutcome] = WorkerPool( + capture, workers=workers, maxsize=maxsize, shutdown=shutdown + ) + return await pool.run(feeds) + + +def summarize(outcomes: Iterable[FetchOutcome]) -> str: + """One line a human can read at the end of a run.""" + outcomes = list(outcomes) + fetched = sum(o.entries for o in outcomes if o.ok) + failed = [o.feed.name for o in outcomes if not o.ok] + line = f"{fetched} entries from {sum(o.ok for o in outcomes)} feeds" + if failed: + line += f"; failed: {', '.join(sorted(failed))}" + return line diff --git a/patterns/modern/async_producer_consumer/examples/feed_fetcher/main.py b/patterns/modern/async_producer_consumer/examples/feed_fetcher/main.py new file mode 100644 index 0000000..f561a4e --- /dev/null +++ b/patterns/modern/async_producer_consumer/examples/feed_fetcher/main.py @@ -0,0 +1,29 @@ +"""Demo: a batch of feeds through the pool, under both shutdown disciplines.""" + +from __future__ import annotations + +import asyncio + +from patterns.modern.async_producer_consumer.examples.feed_fetcher.fetcher import ( + fetch_all, + summarize, +) +from patterns.modern.async_producer_consumer.examples.feed_fetcher.models import Feed +from patterns.modern.async_producer_consumer.pattern import Shutdown + + +def main() -> None: + feeds = [ + Feed("python-insider", "https://feeds.example/python-insider"), + Feed("lwn", "https://feeds.example/lwn"), + Feed("hn", "https://feeds.example/hn"), + Feed("dead-blog", "https://unreachable.example/rss"), + Feed("release-notes", "https://feeds.example/releases"), + ] + for shutdown in Shutdown: + outcomes = asyncio.run(fetch_all(feeds, shutdown=shutdown)) + print(f"{shutdown.value}: {summarize(outcomes)}") + + +if __name__ == "__main__": + main() diff --git a/patterns/modern/async_producer_consumer/examples/feed_fetcher/models.py b/patterns/modern/async_producer_consumer/examples/feed_fetcher/models.py new file mode 100644 index 0000000..9d87ed0 --- /dev/null +++ b/patterns/modern/async_producer_consumer/examples/feed_fetcher/models.py @@ -0,0 +1,30 @@ +"""Domain objects for the feed-fetching pipeline.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Feed: + """A feed to fetch: a name and its URL.""" + + name: str + url: str + + +@dataclass(frozen=True) +class FetchOutcome: + """What happened to one feed — success with entries, or a recorded error. + + The pool itself is fail-fast; capturing per-feed failures is this + project's policy, applied inside its processor. + """ + + feed: Feed + entries: int = 0 + error: str | None = None + + @property + def ok(self) -> bool: + return self.error is None diff --git a/patterns/modern/async_producer_consumer/naive.py b/patterns/modern/async_producer_consumer/naive.py deleted file mode 100644 index 0b7b138..0000000 --- a/patterns/modern/async_producer_consumer/naive.py +++ /dev/null @@ -1,39 +0,0 @@ -"""The thread version: queue.Queue, sentinel-per-worker shutdown. - -Works, but every worker is an OS thread and the coordination is manual. -""" - -from __future__ import annotations - -import queue -import threading - - -def process_all(items: list[str], worker_count: int = 2) -> list[str]: - channel: queue.Queue[str | None] = queue.Queue() - results: list[str] = [] - lock = threading.Lock() - - def worker() -> None: - while (item := channel.get()) is not None: - with lock: - results.append(item.upper()) - - workers = [threading.Thread(target=worker) for _ in range(worker_count)] - for w in workers: - w.start() - for item in items: - channel.put(item) - for _ in workers: - channel.put(None) # one sentinel per worker - for w in workers: - w.join() - return sorted(results) - - -def main() -> None: - print(process_all(["a", "b", "c", "d"])) - - -if __name__ == "__main__": - main() diff --git a/patterns/modern/async_producer_consumer/pattern/__init__.py b/patterns/modern/async_producer_consumer/pattern/__init__.py new file mode 100644 index 0000000..751e351 --- /dev/null +++ b/patterns/modern/async_producer_consumer/pattern/__init__.py @@ -0,0 +1,4 @@ +from .pool import Processor as Processor +from .pool import Shutdown as Shutdown +from .pool import WorkerPool as WorkerPool +from .pool import process_all as process_all diff --git a/patterns/modern/async_producer_consumer/pattern/pool.py b/patterns/modern/async_producer_consumer/pattern/pool.py new file mode 100644 index 0000000..0372686 --- /dev/null +++ b/patterns/modern/async_producer_consumer/pattern/pool.py @@ -0,0 +1,131 @@ +"""Async producer/consumer as an importable, typed building block. + +``WorkerPool`` fans items out to N workers over a bounded ``asyncio.Queue``: +``maxsize`` gives backpressure, and the shutdown discipline — the part the +classic pattern leaves implicit — is an explicit, tested choice +(:class:`Shutdown`). Results are collected in completion order. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable, Iterable +from enum import Enum +from typing import Any, Generic, TypeVar + +Item = TypeVar("Item") +Result = TypeVar("Result") + +Processor = Callable[[Item], Awaitable[Result]] + + +class Shutdown(Enum): + """How the pool tells its workers the work is over. + + ``SENTINEL``: one end-marker per worker is enqueued after the items; + each worker exits when it dequeues one. ``JOIN_AND_CANCEL``: workers + loop forever; the pool awaits ``queue.join()`` then cancels them. + Pick one and test it — mixing disciplines is where shutdown bugs live. + """ + + SENTINEL = "sentinel" + JOIN_AND_CANCEL = "join-and-cancel" + + +class _End: + """Private end-of-work marker for the sentinel discipline.""" + + +_END = _End() + + +class WorkerPool(Generic[Item, Result]): + """N workers processing items from a bounded queue. + + The pool is fail-fast: an exception in ``process`` cancels the run and + surfaces as an ``ExceptionGroup`` (via ``TaskGroup``). Callers who want + per-item failure capture wrap it in their processor. + """ + + def __init__( + self, + process: Processor[Item, Result], + *, + workers: int = 4, + maxsize: int = 8, + shutdown: Shutdown = Shutdown.JOIN_AND_CANCEL, + ) -> None: + if workers < 1: + raise ValueError("a pool needs at least one worker") + self._process = process + self._workers = workers + self._maxsize = maxsize + self._shutdown = shutdown + + async def run(self, items: Iterable[Item]) -> list[Result]: + """Process every item; return results in completion order.""" + if self._shutdown is Shutdown.SENTINEL: + return await self._run_sentinel(items) + return await self._run_join_and_cancel(items) + + def _make_channel(self, maxsize: int) -> asyncio.Queue[Any]: + """Observability seam: tests substitute a recording queue here to + assert the shutdown *mechanism* (sentinel count, task_done + bookkeeping, backpressure bound), not just the results.""" + return asyncio.Queue(maxsize=maxsize) + + async def _run_join_and_cancel(self, items: Iterable[Item]) -> list[Result]: + channel: asyncio.Queue[Item] = self._make_channel(self._maxsize) + results: list[Result] = [] + + async def worker() -> None: + while True: + item = await channel.get() + try: + results.append(await self._process(item)) + finally: + channel.task_done() + + async with asyncio.TaskGroup() as group: + workers = [group.create_task(worker()) for _ in range(self._workers)] + for item in items: + await channel.put(item) # blocks when full: backpressure + await channel.join() # every item fetched AND task_done() + for w in workers: + w.cancel() # idle workers end; TaskGroup absorbs this + return results + + async def _run_sentinel(self, items: Iterable[Item]) -> list[Result]: + channel: asyncio.Queue[Item | _End] = self._make_channel(self._maxsize) + results: list[Result] = [] + + async def worker() -> None: + while True: + got = await channel.get() + if isinstance(got, _End): + return # a worker consumes exactly one sentinel + results.append(await self._process(got)) + + async with asyncio.TaskGroup() as group: + for _ in range(self._workers): + group.create_task(worker()) + for item in items: + await channel.put(item) + for _ in range(self._workers): + await channel.put(_END) # one per worker, after the items + return results + + +async def process_all( + items: Iterable[Item], + process: Processor[Item, Result], + *, + workers: int = 4, + maxsize: int = 8, + shutdown: Shutdown = Shutdown.JOIN_AND_CANCEL, +) -> list[Result]: + """One-shot convenience over :class:`WorkerPool`.""" + pool: WorkerPool[Item, Result] = WorkerPool( + process, workers=workers, maxsize=maxsize, shutdown=shutdown + ) + return await pool.run(items) diff --git a/patterns/modern/async_producer_consumer/pythonic.py b/patterns/modern/async_producer_consumer/pythonic.py deleted file mode 100644 index fe5e6cb..0000000 --- a/patterns/modern/async_producer_consumer/pythonic.py +++ /dev/null @@ -1,41 +0,0 @@ -"""asyncio.Queue + TaskGroup workers. - -maxsize bounds memory (backpressure), join() waits for all items to be -processed, cancellation ends the idle workers. -""" - -from __future__ import annotations - -import asyncio - - -async def process_all(items: list[str], worker_count: int = 3) -> list[str]: - channel: asyncio.Queue[str] = asyncio.Queue(maxsize=2) # backpressure - results: list[str] = [] - - async def worker() -> None: - while True: - item = await channel.get() - try: - await asyncio.sleep(0) # stand-in for real async I/O - results.append(item.upper()) - finally: - channel.task_done() - - async with asyncio.TaskGroup() as group: - workers = [group.create_task(worker()) for _ in range(worker_count)] - for item in items: - await channel.put(item) # blocks when the queue is full - await channel.join() # all items fetched AND task_done() - for w in workers: - w.cancel() # idle workers end; TaskGroup absorbs the cancellation - - return sorted(results) - - -def main() -> None: - print(asyncio.run(process_all(["a", "b", "c", "d", "e"]))) - - -if __name__ == "__main__": - main() diff --git a/patterns/modern/async_producer_consumer/real_world.py b/patterns/modern/async_producer_consumer/real_world.py deleted file mode 100644 index d52c3cf..0000000 --- a/patterns/modern/async_producer_consumer/real_world.py +++ /dev/null @@ -1,49 +0,0 @@ -"""The idiom shaped as a pipeline: N workers, bounded queue, ordered results. - -A fake fetcher stands in for HTTP so the demo and tests run offline; swap it -for a real client and nothing else changes. -""" - -from __future__ import annotations - -import asyncio -from collections.abc import Awaitable, Callable - -Fetcher = Callable[[str], Awaitable[str]] - - -async def fake_fetch(url: str) -> str: - await asyncio.sleep(0) - return f"body-of-{url}" - - -async def crawl(urls: list[str], fetch: Fetcher = fake_fetch, workers: int = 4) -> dict[str, str]: - """Fan URLs out to workers; collect {url: body} whatever the finish order.""" - channel: asyncio.Queue[str] = asyncio.Queue(maxsize=8) - pages: dict[str, str] = {} - - async def worker() -> None: - while True: - url = await channel.get() - try: - pages[url] = await fetch(url) - finally: - channel.task_done() - - async with asyncio.TaskGroup() as group: - tasks = [group.create_task(worker()) for _ in range(workers)] - for url in urls: - await channel.put(url) - await channel.join() - for t in tasks: - t.cancel() - return pages - - -def main() -> None: - urls = [f"https://example.com/{n}" for n in range(3)] - print(asyncio.run(crawl(urls))) - - -if __name__ == "__main__": - main() diff --git a/patterns/modern/async_producer_consumer/tests/__init__.py b/patterns/modern/async_producer_consumer/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/patterns/modern/async_producer_consumer/tests/test_async_producer_consumer.py b/patterns/modern/async_producer_consumer/tests/test_async_producer_consumer.py deleted file mode 100644 index feffcf6..0000000 --- a/patterns/modern/async_producer_consumer/tests/test_async_producer_consumer.py +++ /dev/null @@ -1,36 +0,0 @@ -"""Behavioral tests for all three producer/consumer variants.""" - -from patterns.modern.async_producer_consumer import naive, pythonic, real_world - - -class TestNaive: - def test_thread_pool_processes_everything(self) -> None: - assert naive.process_all(["a", "b", "c", "d"]) == ["A", "B", "C", "D"] - - def test_zero_items(self) -> None: - assert naive.process_all([]) == [] - - -class TestPythonic: - async def test_all_items_processed_despite_backpressure(self) -> None: - items = [chr(ord("a") + n) for n in range(10)] # more items than maxsize - assert await pythonic.process_all(items) == [c.upper() for c in items] - - async def test_more_workers_than_items(self) -> None: - assert await pythonic.process_all(["x"], worker_count=5) == ["X"] - - async def test_zero_items_shuts_down_cleanly(self) -> None: - assert await pythonic.process_all([]) == [] - - -class TestRealWorld: - async def test_crawl_collects_every_url(self) -> None: - urls = [f"u{n}" for n in range(9)] - pages = await real_world.crawl(urls, workers=3) - assert pages == {u: f"body-of-{u}" for u in urls} - - async def test_injected_fetcher(self) -> None: - async def fetch(url: str) -> str: - return url[::-1] - - assert await real_world.crawl(["abc"], fetch=fetch) == {"abc": "cba"} diff --git a/patterns/modern/async_producer_consumer/tests/test_feed_fetcher.py b/patterns/modern/async_producer_consumer/tests/test_feed_fetcher.py new file mode 100644 index 0000000..baffca4 --- /dev/null +++ b/patterns/modern/async_producer_consumer/tests/test_feed_fetcher.py @@ -0,0 +1,54 @@ +"""Behavioral tests for the feed_fetcher mini-project.""" + +from __future__ import annotations + +import pytest + +from patterns.modern.async_producer_consumer.examples.feed_fetcher.fetcher import ( + fetch_all, + summarize, +) +from patterns.modern.async_producer_consumer.examples.feed_fetcher.main import main +from patterns.modern.async_producer_consumer.examples.feed_fetcher.models import Feed +from patterns.modern.async_producer_consumer.pattern import Shutdown + +FEEDS = [ + Feed("alpha", "https://feeds.example/alpha"), + Feed("beta", "https://feeds.example/beta"), + Feed("dead", "https://unreachable.example/rss"), +] + + +class TestFetchAll: + @pytest.mark.parametrize("shutdown", list(Shutdown)) + async def test_failures_are_captured_not_raised(self, shutdown: Shutdown) -> None: + outcomes = {o.feed.name: o for o in await fetch_all(FEEDS, shutdown=shutdown)} + assert len(outcomes) == 3 + assert outcomes["alpha"].ok and outcomes["alpha"].entries == len("alpha") + assert not outcomes["dead"].ok + assert outcomes["dead"].error is not None + assert "unreachable" in outcomes["dead"].error + + async def test_both_disciplines_agree_on_outcomes(self) -> None: + by_discipline = [ + sorted((o.feed.name, o.ok) for o in await fetch_all(FEEDS, shutdown=s)) + for s in Shutdown + ] + assert by_discipline[0] == by_discipline[1] + + +class TestSummarize: + async def test_reports_totals_and_failures(self) -> None: + line = summarize(await fetch_all(FEEDS)) + assert "2 feeds" in line + assert f"{len('alpha') + len('beta')} entries" in line + assert "failed: dead" in line + + +class TestDemo: + def test_demo_prints_one_line_per_discipline(self, capsys: pytest.CaptureFixture[str]) -> None: + main() + out = capsys.readouterr().out.strip().splitlines() + assert len(out) == len(Shutdown) + assert any(line.startswith("sentinel:") for line in out) + assert all("failed: dead-blog" in line for line in out) diff --git a/patterns/modern/async_producer_consumer/tests/test_pool.py b/patterns/modern/async_producer_consumer/tests/test_pool.py new file mode 100644 index 0000000..f54343d --- /dev/null +++ b/patterns/modern/async_producer_consumer/tests/test_pool.py @@ -0,0 +1,150 @@ +"""Behavioral tests for the WorkerPool building block.""" + +from __future__ import annotations + +import asyncio +from typing import Any + +import pytest + +from patterns.modern.async_producer_consumer.pattern import ( + Shutdown, + WorkerPool, + process_all, +) +from patterns.modern.async_producer_consumer.pattern.pool import _End + + +async def upper(item: str) -> str: + await asyncio.sleep(0) + return item.upper() + + +class TestBothDisciplines: + @pytest.mark.parametrize("shutdown", list(Shutdown)) + async def test_processes_every_item_despite_backpressure(self, shutdown: Shutdown) -> None: + items = [chr(ord("a") + n) for n in range(10)] # far more than maxsize + results = await process_all(items, upper, maxsize=2, shutdown=shutdown) + assert sorted(results) == [c.upper() for c in items] + + @pytest.mark.parametrize("shutdown", list(Shutdown)) + async def test_zero_items(self, shutdown: Shutdown) -> None: + assert await process_all([], upper, shutdown=shutdown) == [] + + @pytest.mark.parametrize("shutdown", list(Shutdown)) + async def test_more_workers_than_items(self, shutdown: Shutdown) -> None: + assert await process_all(["x"], upper, workers=5, shutdown=shutdown) == ["X"] + + @pytest.mark.parametrize("shutdown", list(Shutdown)) + async def test_processor_error_fails_fast_as_exception_group(self, shutdown: Shutdown) -> None: + async def explode(item: str) -> str: + raise ValueError(f"bad item {item}") + + pool: WorkerPool[str, str] = WorkerPool(explode, shutdown=shutdown) + with pytest.raises(ExceptionGroup) as excinfo: + await pool.run(["a"]) + assert excinfo.group_contains(ValueError) + + +class TestConcurrencyContract: + async def test_in_flight_work_is_bounded_by_worker_count(self) -> None: + in_flight = 0 + seen_max = 0 + + async def track(item: int) -> int: + nonlocal in_flight, seen_max + in_flight += 1 + seen_max = max(seen_max, in_flight) + await asyncio.sleep(0) # yield so other workers get a turn + in_flight -= 1 + return item + + await process_all(range(20), track, workers=3, maxsize=2) + assert seen_max <= 3 + + async def test_results_are_not_sorted_by_the_pool(self) -> None: + async def slow_first(item: int) -> int: + await asyncio.sleep(0.02 if item == 0 else 0) + return item + + results = await process_all([0, 1, 2, 3], slow_first, workers=4) + assert sorted(results) == [0, 1, 2, 3] + assert results[-1] == 0 # the slow item finishes last, and stays last + + def test_pool_requires_at_least_one_worker(self) -> None: + with pytest.raises(ValueError): + WorkerPool(upper, workers=0) + + +class RecordingQueue(asyncio.Queue[Any]): + """An asyncio.Queue that logs puts, task_done calls, and peak backlog.""" + + def __init__(self, maxsize: int = 0) -> None: + super().__init__(maxsize) + self.created_maxsize = maxsize + self.put_log: list[Any] = [] + self.max_backlog = 0 + self.task_done_calls = 0 + + async def put(self, item: Any) -> None: + await super().put(item) + self.put_log.append(item) + self.max_backlog = max(self.max_backlog, self.qsize()) + + def task_done(self) -> None: + self.task_done_calls += 1 + super().task_done() + + +class ObservablePool(WorkerPool[str, str]): + """WorkerPool with the channel seam swapped for a RecordingQueue.""" + + channel: RecordingQueue + + def _make_channel(self, maxsize: int) -> asyncio.Queue[Any]: + self.channel = RecordingQueue(maxsize) + return self.channel + + +class TestShutdownMechanism: + """The disciplines must differ observably — not just agree on results. + + Collapsing ``run``'s switch to either branch fails one of these tests, + so the switch itself is pinned, not merely the outcomes. + """ + + async def test_sentinel_enqueues_one_marker_per_worker_and_drains(self) -> None: + pool = ObservablePool(upper, workers=3, shutdown=Shutdown.SENTINEL) + await pool.run(["a", "b", "c", "d", "e"]) + markers = [x for x in pool.channel.put_log if isinstance(x, _End)] + assert len(markers) == 3 # exactly one per worker, no orphans + assert pool.channel.qsize() == 0 # every marker consumed: clean drain + assert pool.channel.task_done_calls == 0 # no join bookkeeping here + + async def test_join_and_cancel_uses_task_done_and_no_sentinels(self) -> None: + pool = ObservablePool(upper, workers=3, shutdown=Shutdown.JOIN_AND_CANCEL) + await pool.run(["a", "b", "c", "d", "e"]) + assert pool.channel.task_done_calls == 5 # join() waits on these + assert not any(isinstance(x, _End) for x in pool.channel.put_log) + assert pool.channel.qsize() == 0 + + async def test_backpressure_bounds_the_queue(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Observes the REAL pool's queue (not the test seam), so making the + pool construct an unbounded queue fails here.""" + created: list[RecordingQueue] = [] + + class TrackingQueue(RecordingQueue): + def __init__(self, maxsize: int = 0) -> None: + super().__init__(maxsize) + created.append(self) + + monkeypatch.setattr(asyncio, "Queue", TrackingQueue) + + async def slow(item: str) -> str: + await asyncio.sleep(0.001) + return item.upper() + + await process_all([chr(ord("a") + n) for n in range(10)], slow, workers=2, maxsize=2) + (channel,) = created + assert channel.created_maxsize == 2 # the bound is actually passed through + assert channel.max_backlog <= 2 # and never exceeded during the run diff --git a/patterns/modern/context_manager/README.md b/patterns/modern/context_manager/README.md index 62e3b91..e87871b 100644 --- a/patterns/modern/context_manager/README.md +++ b/patterns/modern/context_manager/README.md @@ -14,31 +14,16 @@ stdlib_sightings: [open, contextlib.contextmanager, contextlib.ExitStack, tempfi # Context Manager -## Problem - -Every acquired resource — file, lock, connection, temporary state — must be -released on *every* exit path. Hand-written `try/finally` scattered through a -codebase is where cleanup bugs live. - -## Naive solution - -`naive.py` is the try/finally discipline done by hand, including the nested -two-resource version that shows why it doesn't scale. - -## Pythonic solution - -The `with` statement makes the pairing structural: `pythonic.py` implements -the protocol both ways — a class with `__enter__`/`__exit__`, and the -generator form via `@contextmanager` where the `yield` splits acquire from -release. - -## In the wild - -`open`, locks, and sqlite transactions are all context managers; -`contextlib.ExitStack` manages a *dynamic* number of them, unwinding in -reverse on the way out — shown in `real_world.py`. - -## Verdict - -**Pythonic.** Python's own RAII; any acquire/release pair you write twice -deserves one. +Pair acquire with release on every exit path, structurally — Python's RAII. +**Verdict: pythonic** — any acquire/release pair you write twice deserves one. + +| Where | What | +|---|---| +| [`pattern/`](pattern/) | The importable code: `AtomicWrite` (protocol form), `temporarily` (generator form) | +| [`docs/`](docs/) | [Fundamentals](docs/fundamentals.md) · [Implementation guide](docs/implementation.md) · [External examples](docs/examples.md) | +| [`examples/atomic_deploy/`](examples/atomic_deploy/) | Mini-project: all-or-nothing config deployment via `ExitStack` | +| [`tests/`](tests/) | Behavioral tests for both managers and the mini-project | + +```bash +uv run python -m patterns.modern.context_manager.examples.atomic_deploy.main +``` diff --git a/patterns/modern/context_manager/__init__.py b/patterns/modern/context_manager/__init__.py index 85b6773..3812cc0 100644 --- a/patterns/modern/context_manager/__init__.py +++ b/patterns/modern/context_manager/__init__.py @@ -1 +1,2 @@ -"""Context Manager: structural acquire/release pairing.""" +from .pattern import AtomicWrite as AtomicWrite +from .pattern import temporarily as temporarily diff --git a/patterns/modern/context_manager/docs/examples.md b/patterns/modern/context_manager/docs/examples.md new file mode 100644 index 0000000..799f045 --- /dev/null +++ b/patterns/modern/context_manager/docs/examples.md @@ -0,0 +1,28 @@ +# Context Manager — external examples + +Real embodiments of the pattern outside this repo, for deeper study. + +## Origin + +- **PEP 343 — the `with` statement** — rationale and full semantics; + the pattern's founding document. + +## Standard library + +- **`contextlib`** — `contextmanager`, `ExitStack`, `suppress`, `closing`, + `ContextDecorator`: every construction form in one module. + +- **`open`, locks, `tempfile.TemporaryDirectory`** — the everyday managers; + a `with open(...)` is the pattern most Python code meets first. +- **`sqlite3.Connection`** — commit on clean exit, rollback on exception: + the branching-exit shape `AtomicWrite` mirrors. + + +## Elsewhere + +- **pytest yield fixtures** — setup/teardown expressed exactly as the + generator form: code before the `yield` is setup, after is teardown. + *(unverified)* +- **Django `transaction.atomic`** — one transaction seam usable as context + manager or decorator. *(unverified)* + diff --git a/patterns/modern/context_manager/docs/fundamentals.md b/patterns/modern/context_manager/docs/fundamentals.md new file mode 100644 index 0000000..ef4b890 --- /dev/null +++ b/patterns/modern/context_manager/docs/fundamentals.md @@ -0,0 +1,83 @@ +# Context Manager — fundamentals + +## Intent + +Guarantee that acquire and release are paired around a block of code on +*every* exit path — normal return, early return, exception. The `with` +statement (PEP 343) makes the pairing structural instead of disciplinary: +cleanup lives with the acquisition, written once, not re-written correctly +at every call site. + +## Participants + +| Role | Form | Where | +|---|---|---| +| The protocol | `__enter__` / `__exit__` on a class | `AtomicWrite` in [`pattern/managers.py`](../pattern/managers.py) | +| The generator form | `@contextlib.contextmanager` around a `yield` | `temporarily` in the same module | +| Composition | `contextlib.ExitStack` — a dynamic pile of managers | the [atomic_deploy example](../examples/atomic_deploy/) | +| Client | `with manager as value:` | any block needing the guarantee | + +## Mechanism + +1. `with` calls `__enter__`; its return value binds to `as`. +2. The body runs. +3. `__exit__` runs *no matter how the body ended*, receiving the exception + triple (or three `None`s). Returning falsy re-raises; returning `True` + swallows the exception — do that only on purpose. +4. In the generator form, the `yield` is the seam: code before it is + `__enter__`, code after it is `__exit__` — which is why the `yield` must + sit inside `try/finally`, or an exception in the body skips the cleanup. + +## The classic form, and what Python absorbs + +Before `with`, the guarantee was hand-written `try/finally` at every call +site — correct, and unscalable: + +```python +class Resource: + def __init__(self, name: str, log: list[str]) -> None: + self.name, self.log = name, log + + def close(self) -> None: + self.log.append(f"closed {self.name}") + + +def use_two(log: list[str]) -> None: + first = Resource("a", log) + try: + second = Resource("b", log) # every extra resource nests a level + try: + log.append("work") + finally: + second.close() + finally: + first.close() +``` + +The `with` statement absorbs the nesting and the discipline; `contextlib` +absorbs the boilerplate of writing managers; `ExitStack` absorbs the +"unknown number of resources" case. What remains — the pattern — is spotting +the acquire/release pair and choosing the right construction form for it. + +## Choosing the form + +- **Protocol class** when exit logic branches (commit vs discard, like + `AtomicWrite`), when the manager has state worth naming, or when it must + be re-entered. +- **Generator form** when cleanup is one unconditional restore + (`temporarily`) — three lines instead of a class. +- **`ExitStack`** when how many managers you need is a runtime fact, or + when you want callbacks-as-cleanup with `pop_all()` as the commit. + +## When not to use it + +- No release side exists — a plain function is enough. +- The "cleanup" must survive the process (a saga, a queued compensation) — + that is workflow logic, not block scoping. + +## Verdict: pythonic + +This *is* Python's RAII, made explicit. Any acquire/release pair you write +twice deserves a context manager; the two caveats (yield inside +`try/finally`; returning `True` from `__exit__` swallows) are the only +sharp edges. diff --git a/patterns/modern/context_manager/docs/implementation.md b/patterns/modern/context_manager/docs/implementation.md new file mode 100644 index 0000000..469a2a3 --- /dev/null +++ b/patterns/modern/context_manager/docs/implementation.md @@ -0,0 +1,49 @@ +# Context Manager — implementation guide + +## The smell that calls for it + +The same `try/finally` shape appears at more than one call site; a code +review comment says "don't forget to close/unlock/restore this"; a bug +report shows cleanup skipped on the exception path. + +## Introducing it, step by step + +1. **Name the pair.** What exactly is acquired, and what must run on exit? + If you cannot state the release in one sentence, the block is doing too + much to manage. +2. **Pick the form** (see [fundamentals](fundamentals.md)): branching exit + logic → protocol class; one unconditional cleanup → generator form; + a runtime-sized set of cleanups → `ExitStack`. +3. **Write the exception path first.** The manager exists for the failure + case: decide what a mid-block exception means (discard? restore? both?) + and test that before the happy path. +4. **Keep `__enter__` cheap and `__exit__` unconditional.** Acquisition + failures should raise *before* the body runs; release must not depend on + how far the body got. +5. **Replace the call sites** with `with`, deleting their hand-rolled + `try/finally`. The diff should only remove lines. + +## Idioms + +- Generator form: the `yield` inside `try/finally`, always — the unit's + first caveat exists because the failure is silent otherwise. +- `ExitStack.callback(undo, ...)` per step, then `pop_all()` on success: + transactional multi-step work where the commit is "don't run the undos" + (shown in [atomic_deploy](../examples/atomic_deploy/deploy.py)). +- A context manager that is also a decorator: subclass + `contextlib.ContextDecorator`, or stack `@contextmanager` functions. +- `contextlib.suppress(SomeError)` instead of `try/except: pass` — the + intent gets a name. + +## Pitfalls + +- **`yield` outside `try/finally`** in a generator manager: cleanup runs + only on the success path. The most common real-world defect in this + pattern. +- **Returning `True` from `__exit__`** (or swallowing in `finally`): + exceptions vanish. Only `contextlib.suppress`-style managers should ever + do it, and loudly. +- **Doing work in `__init__`.** Acquire in `__enter__`, or the manager + cannot be reused and fails before the `with` can protect it. +- **One giant manager** for several unrelated resources — compose small + ones with `ExitStack` instead; each stays testable alone. diff --git a/patterns/modern/context_manager/examples/atomic_deploy/deploy.py b/patterns/modern/context_manager/examples/atomic_deploy/deploy.py new file mode 100644 index 0000000..0972981 --- /dev/null +++ b/patterns/modern/context_manager/examples/atomic_deploy/deploy.py @@ -0,0 +1,62 @@ +"""All-or-nothing config deployment, composed from context managers. + +Each file is written with ``AtomicWrite`` (old-or-new, never half). The +release as a whole is made transactional with ``ExitStack``: every written +file pushes a rollback callback, and only a fully validated release pops +them off uncalled — the commit *is* ``pop_all()``. Any exception on the way +unwinds the stack, restoring every file already touched. (Each rollback +targets its own file, so the LIFO unwind order is deliberately not +load-bearing here; what matters is that every callback runs.) +""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping +from contextlib import ExitStack +from pathlib import Path + +from patterns.modern.context_manager.pattern import AtomicWrite + +Validator = Callable[[str, str], None] + + +class ReleaseError(RuntimeError): + """A file in the release failed validation; nothing was deployed.""" + + +def _restore(path: Path, previous: str | None) -> None: + if previous is None: + path.unlink() # the file did not exist before this release + else: + path.write_text(previous, encoding="utf-8") + + +def no_validation(name: str, content: str) -> None: + """The default validator: accept everything.""" + + +def require_nonempty(name: str, content: str) -> None: + """A realistic validator: an empty config file is a broken release.""" + if not content.strip(): + raise ReleaseError(f"{name} is empty") + + +def deploy( + release: Mapping[str, str], + target: Path, + *, + validate: Validator = no_validation, +) -> list[Path]: + """Write every file in ``release`` into ``target``, or none of them.""" + written: list[Path] = [] + with ExitStack() as rollback: + for name, content in sorted(release.items()): + validate(name, content) + path = target / name + previous = path.read_text(encoding="utf-8") if path.exists() else None + with AtomicWrite(path) as handle: + handle.write(content) + rollback.callback(_restore, path, previous) # undo, if we unwind + written.append(path) + rollback.pop_all() # every file landed: cancel the rollbacks + return written diff --git a/patterns/modern/context_manager/examples/atomic_deploy/main.py b/patterns/modern/context_manager/examples/atomic_deploy/main.py new file mode 100644 index 0000000..4f148de --- /dev/null +++ b/patterns/modern/context_manager/examples/atomic_deploy/main.py @@ -0,0 +1,30 @@ +"""Demo: a good release deploys; a bad one rolls back completely.""" + +from __future__ import annotations + +import tempfile +from pathlib import Path + +from patterns.modern.context_manager.examples.atomic_deploy.deploy import ( + ReleaseError, + deploy, + require_nonempty, +) + + +def main() -> None: + with tempfile.TemporaryDirectory() as tmp: + target = Path(tmp) + deploy({"app.toml": "retries = 3\n", "logging.toml": "level = 'info'\n"}, target) + print(f"v1 deployed: {sorted(p.name for p in target.iterdir())}") + + bad_release = {"app.toml": "retries = 5\n", "logging.toml": " "} + try: + deploy(bad_release, target, validate=require_nonempty) + except ReleaseError as exc: + print(f"v2 rejected ({exc})") + print(f"app.toml still reads: {(target / 'app.toml').read_text().strip()}") + + +if __name__ == "__main__": + main() diff --git a/patterns/modern/context_manager/naive.py b/patterns/modern/context_manager/naive.py deleted file mode 100644 index dbebb88..0000000 --- a/patterns/modern/context_manager/naive.py +++ /dev/null @@ -1,55 +0,0 @@ -"""Cleanup by hand: try/finally on every exit path. - -Correct -- and it must be re-written correctly at every call site. -The nested version shows why the discipline doesn't scale. -""" - -from __future__ import annotations - - -class Resource: - def __init__(self, name: str, log: list[str]) -> None: - self.name = name - self.log = log - self.log.append(f"open {name}") - - def close(self) -> None: - self.log.append(f"close {self.name}") - - -def use_one(log: list[str], *, explode: bool = False) -> None: - resource = Resource("a", log) - try: - log.append("work") - if explode: - raise RuntimeError("boom") - finally: - resource.close() - - -def use_two(log: list[str]) -> None: - first = Resource("a", log) - try: - second = Resource("b", log) # every extra resource nests another level - try: - log.append("work") - finally: - second.close() - finally: - first.close() - - -def main() -> None: - import contextlib - - log: list[str] = [] - with contextlib.suppress(RuntimeError): # itself a context manager! - use_one(log, explode=True) - print(f"cleanup survived the exception: {log}") - log.clear() - use_two(log) - print(f"nested by hand: {log}") - - -if __name__ == "__main__": - main() diff --git a/patterns/modern/context_manager/pattern/__init__.py b/patterns/modern/context_manager/pattern/__init__.py new file mode 100644 index 0000000..a20a8ee --- /dev/null +++ b/patterns/modern/context_manager/pattern/__init__.py @@ -0,0 +1,2 @@ +from .managers import AtomicWrite as AtomicWrite +from .managers import temporarily as temporarily diff --git a/patterns/modern/context_manager/pattern/managers.py b/patterns/modern/context_manager/pattern/managers.py new file mode 100644 index 0000000..87d80dc --- /dev/null +++ b/patterns/modern/context_manager/pattern/managers.py @@ -0,0 +1,67 @@ +"""Context managers as importable, typed building blocks. + +Two general-purpose managers, one per construction form the pattern offers: +``AtomicWrite`` implements the protocol (``__enter__``/``__exit__``) because +its exit logic branches on the exception; ``temporarily`` uses the generator +form because its cleanup is one unconditional restore. Choosing the form to +fit the cleanup is itself part of the pattern. +""" + +from __future__ import annotations + +import os +import tempfile +from collections.abc import Iterator +from contextlib import contextmanager +from pathlib import Path +from types import TracebackType +from typing import IO, Any + + +class AtomicWrite: + """Write a file so readers see the old content or the new — never half. + + Text is written to a temp file beside ``path``; a clean exit renames it + over ``path`` (atomic on POSIX), an exception discards it and leaves any + previous content untouched. + """ + + def __init__(self, path: Path, *, encoding: str = "utf-8") -> None: + self._path = path + self._encoding = encoding + self._handle: IO[str] | None = None + self._tmp_name = "" + + def __enter__(self) -> IO[str]: + fd, self._tmp_name = tempfile.mkstemp(dir=self._path.parent, prefix=f".{self._path.name}.") + self._handle = os.fdopen(fd, "w", encoding=self._encoding) + return self._handle + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: + # Returning None (falsy): never swallow the body's exception. + if self._handle is not None: + self._handle.close() + if exc_type is None: + os.replace(self._tmp_name, self._path) # the atomic commit + else: + os.unlink(self._tmp_name) # discard; the old file stays intact + + +@contextmanager +def temporarily(obj: Any, attribute: str, value: object) -> Iterator[None]: + """Set ``obj.attribute = value`` for the block; restore on any exit. + + The ``yield`` sits inside ``try/finally`` — without that, an exception + in the body would skip the restore (the unit's first caveat). + """ + previous = getattr(obj, attribute) + setattr(obj, attribute, value) + try: + yield + finally: + setattr(obj, attribute, previous) diff --git a/patterns/modern/context_manager/pythonic.py b/patterns/modern/context_manager/pythonic.py deleted file mode 100644 index 516c048..0000000 --- a/patterns/modern/context_manager/pythonic.py +++ /dev/null @@ -1,52 +0,0 @@ -"""The protocol, both ways. - -A class with __enter__/__exit__, and the generator form where the yield is -the seam between acquire and release. Note the try/finally around the yield: -without it, an exception in the body skips cleanup. -""" - -from __future__ import annotations - -from collections.abc import Iterator -from contextlib import contextmanager -from types import TracebackType - - -class Managed: - def __init__(self, name: str, log: list[str]) -> None: - self.name = name - self.log = log - - def __enter__(self) -> Managed: - self.log.append(f"open {self.name}") - return self - - def __exit__( - self, - exc_type: type[BaseException] | None, - exc: BaseException | None, - tb: TracebackType | None, - ) -> None: - self.log.append(f"close {self.name}") # returning None: never swallow - - -@contextmanager -def managed(name: str, log: list[str]) -> Iterator[str]: - log.append(f"open {name}") - try: - yield name - finally: - log.append(f"close {name}") - - -def main() -> None: - log: list[str] = [] - with Managed("a", log): - log.append("work") - with managed("b", log): - log.append("more work") - print(log) - - -if __name__ == "__main__": - main() diff --git a/patterns/modern/context_manager/real_world.py b/patterns/modern/context_manager/real_world.py deleted file mode 100644 index 0b88142..0000000 --- a/patterns/modern/context_manager/real_world.py +++ /dev/null @@ -1,32 +0,0 @@ -"""``contextlib.ExitStack``: a dynamic pile of context managers. - -Open N resources decided at runtime; the stack unwinds them all, in -reverse, on any exit. -""" - -from __future__ import annotations - -import tempfile -from contextlib import ExitStack -from pathlib import Path - - -def concatenate(paths: list[Path]) -> str: - """Open however many files there are; every handle closes on exit.""" - with ExitStack() as stack: - handles = [stack.enter_context(p.open()) for p in paths] - return "".join(h.read() for h in handles) - - -def main() -> None: - with tempfile.TemporaryDirectory() as tmp: # itself a context manager - paths = [] - for i, text in enumerate(["one ", "two ", "three"]): - path = Path(tmp) / f"{i}.txt" - path.write_text(text) - paths.append(path) - print(concatenate(paths)) - - -if __name__ == "__main__": - main() diff --git a/patterns/modern/context_manager/tests/__init__.py b/patterns/modern/context_manager/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/patterns/modern/context_manager/tests/test_atomic_deploy.py b/patterns/modern/context_manager/tests/test_atomic_deploy.py new file mode 100644 index 0000000..6bd5e8c --- /dev/null +++ b/patterns/modern/context_manager/tests/test_atomic_deploy.py @@ -0,0 +1,47 @@ +"""Behavioral tests for the atomic_deploy mini-project.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from patterns.modern.context_manager.examples.atomic_deploy.deploy import ( + ReleaseError, + deploy, + require_nonempty, +) +from patterns.modern.context_manager.examples.atomic_deploy.main import main + +V1 = {"app.toml": "retries = 3\n", "logging.toml": "level = 'info'\n"} + + +class TestDeploy: + def test_good_release_writes_every_file(self, tmp_path: Path) -> None: + written = deploy(V1, tmp_path) + assert sorted(p.name for p in written) == ["app.toml", "logging.toml"] + assert (tmp_path / "app.toml").read_text() == V1["app.toml"] + + def test_failing_release_restores_previous_contents(self, tmp_path: Path) -> None: + deploy(V1, tmp_path) + bad_v2 = {"app.toml": "retries = 5\n", "logging.toml": " "} + with pytest.raises(ReleaseError, match=r"logging\.toml"): + deploy(bad_v2, tmp_path, validate=require_nonempty) + # app.toml sorts before logging.toml, so it WAS written — and rolled back. + assert (tmp_path / "app.toml").read_text() == V1["app.toml"] + assert (tmp_path / "logging.toml").read_text() == V1["logging.toml"] + + def test_failing_release_on_fresh_target_leaves_no_files(self, tmp_path: Path) -> None: + bad = {"a.toml": "ok = true\n", "z.toml": ""} + with pytest.raises(ReleaseError): + deploy(bad, tmp_path, validate=require_nonempty) + assert list(tmp_path.iterdir()) == [] # a.toml was created, then removed + + +class TestDemo: + def test_demo_shows_deploy_then_rollback(self, capsys: pytest.CaptureFixture[str]) -> None: + main() + out = capsys.readouterr().out + assert "v1 deployed: ['app.toml', 'logging.toml']" in out + assert "v2 rejected" in out + assert "app.toml still reads: retries = 3" in out diff --git a/patterns/modern/context_manager/tests/test_context_manager.py b/patterns/modern/context_manager/tests/test_context_manager.py deleted file mode 100644 index d122e90..0000000 --- a/patterns/modern/context_manager/tests/test_context_manager.py +++ /dev/null @@ -1,55 +0,0 @@ -"""Behavioral tests for all three context-manager variants.""" - -import tempfile -from pathlib import Path - -import pytest - -from patterns.modern.context_manager import naive, pythonic, real_world - - -class TestNaive: - def test_finally_cleans_up_on_exception(self) -> None: - log: list[str] = [] - with pytest.raises(RuntimeError): - naive.use_one(log, explode=True) - assert log == ["open a", "work", "close a"] - - def test_nested_resources_close_in_reverse(self) -> None: - log: list[str] = [] - naive.use_two(log) - assert log == ["open a", "open b", "work", "close b", "close a"] - - -class TestPythonic: - def test_class_form_pairs_enter_and_exit(self) -> None: - log: list[str] = [] - with pythonic.Managed("a", log): - log.append("work") - assert log == ["open a", "work", "close a"] - - def test_class_form_cleans_up_on_exception(self) -> None: - log: list[str] = [] - with pytest.raises(ValueError, match="boom"), pythonic.Managed("a", log): - raise ValueError("boom") - assert log == ["open a", "close a"] - - def test_generator_form_cleans_up_on_exception(self) -> None: - log: list[str] = [] - with pytest.raises(ValueError), pythonic.managed("g", log): - raise ValueError - assert log == ["open g", "close g"] - - -class TestRealWorld: - def test_exit_stack_handles_a_runtime_number_of_files(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - paths = [] - for i, text in enumerate(["x", "y"]): - p = Path(tmp) / f"{i}.txt" - p.write_text(text) - paths.append(p) - assert real_world.concatenate(paths) == "xy" - - def test_empty_stack_is_fine(self) -> None: - assert real_world.concatenate([]) == "" diff --git a/patterns/modern/context_manager/tests/test_managers.py b/patterns/modern/context_manager/tests/test_managers.py new file mode 100644 index 0000000..4aca9cc --- /dev/null +++ b/patterns/modern/context_manager/tests/test_managers.py @@ -0,0 +1,86 @@ +"""Behavioral tests for the AtomicWrite and temporarily managers.""" + +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +from patterns.modern.context_manager.pattern import AtomicWrite, temporarily + + +class TestAtomicWrite: + def test_clean_exit_commits(self, tmp_path: Path) -> None: + target = tmp_path / "config.toml" + with AtomicWrite(target) as handle: + handle.write("v2") + assert target.read_text() == "v2" + + def test_exception_discards_and_keeps_the_old_content(self, tmp_path: Path) -> None: + target = tmp_path / "config.toml" + target.write_text("v1") + with pytest.raises(RuntimeError, match="boom"), AtomicWrite(target) as handle: + handle.write("half-written v2") + raise RuntimeError("boom") + assert target.read_text() == "v1" # reader never sees the half-write + + def test_exception_on_a_fresh_path_leaves_nothing(self, tmp_path: Path) -> None: + target = tmp_path / "new.toml" + with pytest.raises(RuntimeError), AtomicWrite(target) as handle: + handle.write("partial") + raise RuntimeError("boom") + assert not target.exists() + assert list(tmp_path.iterdir()) == [] # no orphaned temp file either + + def test_commit_is_one_atomic_replace_from_the_same_directory( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The docstring's old-or-new promise rests on os.replace, and on the + temp file living beside the target (same filesystem, no EXDEV).""" + calls: list[tuple[str, Path]] = [] + real_replace = os.replace + + def spy(src: str | os.PathLike[str], dst: str | os.PathLike[str]) -> None: + calls.append((str(src), Path(dst))) + real_replace(src, dst) + + monkeypatch.setattr(os, "replace", spy) + target = tmp_path / "config.toml" + with AtomicWrite(target) as handle: + handle.write("v2") + assert target.read_text() == "v2" + assert len(calls) == 1 # exactly one atomic rename — never a rewrite + src, dst = calls[0] + assert dst == target + assert Path(src).parent == target.parent # beside the target, not /tmp + + def test_non_default_encoding_is_honored(self, tmp_path: Path) -> None: + target = tmp_path / "latin.txt" + with AtomicWrite(target, encoding="latin-1") as handle: + handle.write("café") + assert target.read_text(encoding="latin-1") == "café" + assert target.read_bytes() == b"caf\xe9" # actually latin-1, not utf-8 + + +class TestTemporarily: + class Settings: + retries = 3 + + def test_restores_after_the_block(self) -> None: + settings = self.Settings() + with temporarily(settings, "retries", 99): + assert settings.retries == 99 + assert settings.retries == 3 + + def test_restores_even_when_the_body_raises(self) -> None: + settings = self.Settings() + with pytest.raises(ValueError), temporarily(settings, "retries", 99): + raise ValueError("mid-block failure") + assert settings.retries == 3 + + def test_never_swallows_the_body_exception(self) -> None: + settings = self.Settings() + # The KeyError reaches pytest.raises: __exit__ returns falsy. + with pytest.raises(KeyError), temporarily(settings, "retries", 0): + raise KeyError("must propagate") diff --git a/patterns/modern/dependency_injection/README.md b/patterns/modern/dependency_injection/README.md index 4932b4e..04a7619 100644 --- a/patterns/modern/dependency_injection/README.md +++ b/patterns/modern/dependency_injection/README.md @@ -14,32 +14,17 @@ stdlib_sightings: [json.dumps cls=, sorted key=, unittest.mock] # Dependency Injection -## Problem - -A class that builds its own collaborators — its clock, its store, its HTTP -client — can only ever be tested with the real things. The hidden `new` is -the coupling. - -## Naive solution - -`naive.py` hard-wires `datetime.now` and a concrete store inside the class. -Watch the test problem appear: the greeting depends on the actual wall -clock. - -## Pythonic solution - -Pass the collaborators in. `pythonic.py` is an overdue-invoice reminder -service with three seams — the clock, the invoice source, the mail transport — -each a `Protocol` or callable with a production default. Tests hand in a -frozen date and a capturing mailbox and become fully deterministic. No -container, no framework, no decorators. - -## In the wild - -Every `key=` argument is DI (`sorted`, `min`, `max`); `json.dumps(cls=...)` -injects the encoder; `unittest.mock` exists to be injected. The stdlib does -DI by keyword argument, and so should you. - -## Verdict - -**Pythonic.** The default-argument seam is the pattern, entire. +Hand an object its collaborators — clock, storage, transport — instead of +letting it construct them, so tests can swap in fakes. **Verdict: pythonic** — +a keyword argument with a production default is the whole mechanism. + +| Where | What | +|---|---| +| [`pattern/`](pattern/) | The importable code: `ReminderService`, `InvoiceSource`, `MailTransport`, `Clock` | +| [`docs/`](docs/) | [Fundamentals](docs/fundamentals.md) · [Implementation guide](docs/implementation.md) · [External examples](docs/examples.md) | +| [`examples/invoice_reminders/`](examples/invoice_reminders/) | Mini-project: adapters + a real composition root over `pattern/` | +| [`tests/`](tests/) | Behavioral tests for the pattern and the mini-project | + +```bash +uv run python -m patterns.modern.dependency_injection.examples.invoice_reminders.main +``` diff --git a/patterns/modern/dependency_injection/__init__.py b/patterns/modern/dependency_injection/__init__.py index d6ac82a..4b55e3f 100644 --- a/patterns/modern/dependency_injection/__init__.py +++ b/patterns/modern/dependency_injection/__init__.py @@ -1 +1,5 @@ -"""Dependency Injection: pass collaborators in; a kwarg default is the mechanism.""" +from .pattern import Clock as Clock +from .pattern import Invoice as Invoice +from .pattern import InvoiceSource as InvoiceSource +from .pattern import MailTransport as MailTransport +from .pattern import ReminderService as ReminderService diff --git a/patterns/modern/dependency_injection/docs/examples.md b/patterns/modern/dependency_injection/docs/examples.md new file mode 100644 index 0000000..066ca3a --- /dev/null +++ b/patterns/modern/dependency_injection/docs/examples.md @@ -0,0 +1,36 @@ +# Dependency Injection — where it lives outside this repo + +Cited, real implementations to study (or point an agent at) when designing or +reviewing injection seams. + +## Python standard library + +- **`sorted(key=...)` / `min` / `max`.** The ordering policy is injected as a + callable — micro-DI so idiomatic nobody calls it a pattern. + [docs.python.org/3/library/functions.html#sorted](https://docs.python.org/3/library/functions.html#sorted) +- **`json.dumps(cls=...)`.** The encoder is a constructor-injected + collaborator with a production default (`JSONEncoder`). + [docs.python.org/3/library/json.html](https://docs.python.org/3/library/json.html) +- **`unittest.mock`.** The other half of the pattern: the fakes that exist to + be injected through the seams you left. + [docs.python.org/3/library/unittest.mock.html](https://docs.python.org/3/library/unittest.mock.html) + +## Major ecosystems + +- **pytest fixtures.** Injection driven by argument *name*: declaring a + parameter called `tmp_path` is asking the framework to construct and pass + one — a composition root run per test. + [docs.pytest.org/en/stable/how-to/fixtures.html](https://docs.pytest.org/en/stable/how-to/fixtures.html) +- **FastAPI `Depends`.** Request-scoped DI as a framework feature; the + declared dependency graph is resolved per call, with overrides for tests. + [fastapi.tiangolo.com/tutorial/dependencies/](https://fastapi.tiangolo.com/tutorial/dependencies/) +- **Fowler's taxonomy.** Constructor vs setter vs interface injection, and why + containers exist at all — the vocabulary the industry still uses. + [martinfowler.com/articles/injection.html](https://martinfowler.com/articles/injection.html) + +## What to notice across all of them + +None of the Python examples involve a container: the language's keyword +arguments and structural typing carry the whole pattern. When reviewing, +look for the two failure directions — a seam that is missing (tests patch +internals) and seams that are gratuitous (constructors as wiring diagrams). diff --git a/patterns/modern/dependency_injection/docs/fundamentals.md b/patterns/modern/dependency_injection/docs/fundamentals.md new file mode 100644 index 0000000..7f3fef2 --- /dev/null +++ b/patterns/modern/dependency_injection/docs/fundamentals.md @@ -0,0 +1,69 @@ +# Dependency Injection — fundamentals + +## Intent + +Hand an object its collaborators instead of letting it construct them, so the +things that vary — the clock, the storage, the transport — can be swapped +without touching the object. Named and taxonomized by Fowler in +[Inversion of Control Containers and the Dependency Injection pattern](https://martinfowler.com/articles/injection.html) (2004). + +## Participants + +| Role | Framework-era form | Python form | +|---|---|---| +| Service | A class resolved from a container | A plain class taking collaborators as constructor arguments — `ReminderService` in [`pattern/service.py`](../pattern/service.py) | +| Seam contract | An interface registered with the container | A `Protocol` (or a bare callable type like `Clock`) | +| Adapters | Container-managed beans | Any object with the right methods — no base class, no registration | +| Composition root | XML / container configuration | The one ordinary function that builds the object graph ([`examples/invoice_reminders/app.py`](../examples/invoice_reminders/app.py)) | + +## Mechanism + +1. The service names what it needs as constructor parameters, typed by + `Protocol` so `mypy` checks any adapter structurally. +2. The composition root — one function, at the edge of the program — builds + the real collaborators and passes them in. +3. Tests build the same service with fakes: a frozen clock, a capturing + mailbox. No patching, no framework, no container. +4. A collaborator with one nearly-universal right answer keeps a **production + default** (`today: Clock = date.today`) — the seam is invisible until the + day a test needs it. + +## The hard-wired form, and what Python absorbs + +There is no GoF chapter for DI; the classic form here is the code you write +*before* the pattern — the service that builds its own collaborators: + +```python +class GreetingService: + def __init__(self) -> None: + self.sent: list[str] = [] # the "store", welded in + + def greet(self, name: str) -> str: + hour = datetime.now().hour # the clock, welded in + prefix = "good morning" if hour < 12 else "good day" + ... +``` + +This class can only be tested against the real wall clock; the hidden +construction *is* the coupling. Java grew containers, XML wiring, and +`@Autowired` to break it. Python absorbs all of that: keyword arguments are +the injection mechanism, defaults are the production wiring, `Protocol` is +the interface. What survives of the pattern is one design habit — **name your +seams, and construct nothing you might need to swap**. + +## When to use it + +- A collaborator must differ between production and tests (clock, randomness, + network, storage, transport). +- The same logic must run against interchangeable backends. + +## When not to use it + +- The collaborator never varies — `math.sqrt` needs no seam. +- Everything is injected on principle and constructors become wiring diagrams; + inject at the boundary that varies, not everywhere. + +## Verdict: pythonic + +A keyword argument with a production default is the entire mechanism; the +stdlib itself does DI this way (`sorted(key=...)`, `json.dumps(cls=...)`). diff --git a/patterns/modern/dependency_injection/docs/implementation.md b/patterns/modern/dependency_injection/docs/implementation.md new file mode 100644 index 0000000..333d653 --- /dev/null +++ b/patterns/modern/dependency_injection/docs/implementation.md @@ -0,0 +1,87 @@ +# Dependency Injection — putting it into a system + +## The smell it fixes + +A test that cannot run without the real world: + +```python +def test_greeting() -> None: + service = GreetingService() + assert service.greet("ada").startswith("good morning") # fails after noon +``` + +Whatever the class constructs for itself — `datetime.now`, `sqlite3.connect`, +`requests.Session()` — its tests drag along. The fix is to move construction +out of the class and pass the result in. + +## Steps + +1. **Find the seams.** List what the class reaches out to that varies by + environment: time, randomness, storage, network, transport. Those — and + only those — become parameters. +2. **Type each seam as a `Protocol`** (or a callable alias like + `Clock = Callable[[], date]`). Structural typing means adapters need no + base class: any object with a matching `send` method is a `MailTransport`. +3. **Take collaborators in the constructor.** Keep a default argument where + one implementation is right nearly always (`today: Clock = date.today`); + require the argument where the choice deserves to be visible. +4. **Build one composition root.** A single ordinary function at the program's + edge constructs adapters and assembles the graph + (`build_service(...)` in the mini-project). If wiring appears in more than + one place, it has leaked. +5. **Write the tests the seams were made for.** Freeze the clock with a + lambda, capture mail in a list; assert on behavior, not on mocks' innards. + +```python +from datetime import date + +from patterns.modern.dependency_injection import Invoice, ReminderService +from patterns.modern.dependency_injection.examples.invoice_reminders import ( + ConsoleMail, + InMemoryInvoices, +) + +source = InMemoryInvoices( + [ + Invoice("INV-1", "ada@example.com", 120_00, date(2026, 8, 1)), + Invoice("INV-3", "sam@example.com", 60_00, date(2026, 7, 20)), + ] +) +outbox = ConsoleMail() +service = ReminderService(invoices=source, mail=outbox, today=lambda: date(2026, 8, 27)) +assert service.send_reminders() == ["INV-1", "INV-3"] +``` + +## Python idioms that keep it small + +- **A lambda is a fine adapter** for one-method seams; `Protocol` earns its + keep from two methods up. +- **`functools.partial`** turns a configured function into an injectable + collaborator without a class. +- **No container.** When the graph grows past what one composition-root + function can hold readably, split the function — reach for a DI framework + only when you can name what the function can no longer do. + +## Pitfalls + +- **Injecting everything.** A constructor with ten parameters is a wiring + diagram; inject what varies, construct the rest. +- **Patching instead of injecting.** `unittest.mock.patch` reaches through + module internals to do what a seam would have offered openly — needing it + is the signal the seam is missing. +- **Wiring scattered through the code.** Construction belongs at the + composition root; a class that builds one collaborator and injects two + others has both problems. +- **Seams without contracts.** An untyped `mail=None` parameter accepts + anything and promises nothing; the `Protocol` is what makes the fake and + the real thing provably interchangeable. + +## Worked example + +[`examples/invoice_reminders/`](../examples/invoice_reminders/) wires the +service at a real composition root, with the demo pinning the clock through +the same seam the tests use: + +```bash +uv run python -m patterns.modern.dependency_injection.examples.invoice_reminders.main +``` diff --git a/patterns/modern/dependency_injection/examples/invoice_reminders/adapters.py b/patterns/modern/dependency_injection/examples/invoice_reminders/adapters.py new file mode 100644 index 0000000..7a5a805 --- /dev/null +++ b/patterns/modern/dependency_injection/examples/invoice_reminders/adapters.py @@ -0,0 +1,30 @@ +"""Concrete collaborators satisfying the pattern's seams. + +Nothing here is imported by ``ReminderService`` — the service knows only the +protocols. These are what the composition root chooses to plug in. +""" + +from __future__ import annotations + +from patterns.modern.dependency_injection.pattern import Invoice + + +class InMemoryInvoices: + """An invoice source backed by a list; production would wrap a database.""" + + def __init__(self, invoices: list[Invoice] | None = None) -> None: + self._invoices = list(invoices or []) + + def unpaid(self) -> list[Invoice]: + return list(self._invoices) + + +class ConsoleMail: + """A mail transport that prints; production would speak SMTP.""" + + def __init__(self) -> None: + self.sent_count = 0 + + def send(self, to: str, subject: str, body: str) -> None: + self.sent_count += 1 + print(f"MAIL to={to} subject={subject!r}") diff --git a/patterns/modern/dependency_injection/examples/invoice_reminders/app.py b/patterns/modern/dependency_injection/examples/invoice_reminders/app.py new file mode 100644 index 0000000..caaeff5 --- /dev/null +++ b/patterns/modern/dependency_injection/examples/invoice_reminders/app.py @@ -0,0 +1,41 @@ +"""The composition root: the one place that knows every concrete choice. + +The service stays ignorant of these decisions; swapping SMTP for console +mail, or a database for a fixture list, edits only this file. +""" + +from __future__ import annotations + +from datetime import date + +from patterns.modern.dependency_injection.examples.invoice_reminders.adapters import ( + ConsoleMail, + InMemoryInvoices, +) +from patterns.modern.dependency_injection.pattern import ( + Clock, + Invoice, + MailTransport, + ReminderService, +) + + +def sample_invoices() -> list[Invoice]: + return [ + Invoice("INV-1", "ada@example.com", 120_00, date(2026, 8, 1)), + Invoice("INV-2", "grace@example.com", 80_00, date(2026, 8, 25)), + Invoice("INV-3", "linus@example.com", 45_50, date(2026, 7, 15)), + ] + + +def build_service( + invoices: list[Invoice], + mail: MailTransport | None = None, + today: Clock = date.today, +) -> ReminderService: + """Assemble the production object graph; every seam overridable for tests.""" + return ReminderService( + invoices=InMemoryInvoices(invoices), + mail=mail if mail is not None else ConsoleMail(), + today=today, + ) diff --git a/patterns/modern/dependency_injection/examples/invoice_reminders/main.py b/patterns/modern/dependency_injection/examples/invoice_reminders/main.py new file mode 100644 index 0000000..f811d84 --- /dev/null +++ b/patterns/modern/dependency_injection/examples/invoice_reminders/main.py @@ -0,0 +1,22 @@ +"""Demo: a morning's reminder run with a pinned clock.""" + +from __future__ import annotations + +from datetime import date + +from patterns.modern.dependency_injection.examples.invoice_reminders.app import ( + build_service, + sample_invoices, +) + + +def main() -> None: + # The demo pins the clock at the composition root — the same seam a test + # uses, exercised for reproducibility instead of assertion. + service = build_service(sample_invoices(), today=lambda: date(2026, 8, 27)) + reminded = service.send_reminders(grace_days=3) + print(f"reminded: {reminded}") + + +if __name__ == "__main__": + main() diff --git a/patterns/modern/dependency_injection/naive.py b/patterns/modern/dependency_injection/naive.py deleted file mode 100644 index d4db417..0000000 --- a/patterns/modern/dependency_injection/naive.py +++ /dev/null @@ -1,31 +0,0 @@ -"""Hard-wired dependencies: the class news up its own collaborators. - -The cost is invisible until you try to test it -- there is no seam to -substitute the clock or the store. -""" - -from __future__ import annotations - -from datetime import datetime - - -class GreetingService: - def __init__(self) -> None: - self.sent: list[str] = [] # the "store", welded in - - def greet(self, name: str) -> str: - hour = datetime.now().hour # the clock, welded in - prefix = "good morning" if hour < 12 else "good day" - message = f"{prefix}, {name}" - self.sent.append(message) - return message - - -def main() -> None: - service = GreetingService() - print(service.greet("ada")) - print(f"stored: {service.sent}") - - -if __name__ == "__main__": - main() diff --git a/patterns/modern/dependency_injection/pattern/__init__.py b/patterns/modern/dependency_injection/pattern/__init__.py new file mode 100644 index 0000000..4bb2d7c --- /dev/null +++ b/patterns/modern/dependency_injection/pattern/__init__.py @@ -0,0 +1,5 @@ +from .service import Clock as Clock +from .service import Invoice as Invoice +from .service import InvoiceSource as InvoiceSource +from .service import MailTransport as MailTransport +from .service import ReminderService as ReminderService diff --git a/patterns/modern/dependency_injection/pattern/service.py b/patterns/modern/dependency_injection/pattern/service.py new file mode 100644 index 0000000..25418b8 --- /dev/null +++ b/patterns/modern/dependency_injection/pattern/service.py @@ -0,0 +1,72 @@ +"""Constructor injection with ``Protocol`` seams. + +The service names the collaborators that must vary — the invoice source, the +mail transport, the clock — as constructor parameters typed by ``Protocol`` +(or a plain callable). The composition root passes real adapters; tests pass +fakes. Where one implementation is right nearly always, a default argument +makes injection invisible until the day it is needed (``today=date.today``). +""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import date +from typing import Protocol + +Clock = Callable[[], date] + + +@dataclass(frozen=True) +class Invoice: + """One unpaid invoice, as the reminder policy sees it.""" + + number: str + customer_email: str + amount_cents: int + due: date + + +class InvoiceSource(Protocol): + """Where unpaid invoices come from — a database in production.""" + + def unpaid(self) -> list[Invoice]: ... + + +class MailTransport(Protocol): + """How reminders leave the system — SMTP in production.""" + + def send(self, to: str, subject: str, body: str) -> None: ... + + +class ReminderService: + """Remind customers about overdue invoices. + + Every collaborator arrives through the constructor; the service builds + nothing it depends on. The clock keeps a production default because + ``date.today`` is right everywhere except in a test. + """ + + def __init__( + self, + invoices: InvoiceSource, + mail: MailTransport, + today: Clock = date.today, + ) -> None: + self._invoices = invoices + self._mail = mail + self._today = today + + def send_reminders(self, grace_days: int = 3) -> list[str]: + """Mail every invoice more than ``grace_days`` overdue; return its numbers.""" + reminded: list[str] = [] + for invoice in self._invoices.unpaid(): + overdue = (self._today() - invoice.due).days + if overdue > grace_days: + self._mail.send( + to=invoice.customer_email, + subject=f"Invoice {invoice.number} is {overdue} days overdue", + body=f"Please pay {invoice.amount_cents / 100:.2f}.", + ) + reminded.append(invoice.number) + return reminded diff --git a/patterns/modern/dependency_injection/pythonic.py b/patterns/modern/dependency_injection/pythonic.py deleted file mode 100644 index 893d18d..0000000 --- a/patterns/modern/dependency_injection/pythonic.py +++ /dev/null @@ -1,96 +0,0 @@ -"""Constructor injection with Protocol seams and production defaults. - -A real service shape: overdue-invoice reminders. Three collaborators that -must be swappable in tests -- the clock, the invoice source, the mail -transport -- each behind a seam. Production passes nothing; tests pass -fakes and get deterministic behavior. -""" - -from __future__ import annotations - -from collections.abc import Callable -from dataclasses import dataclass -from datetime import date -from typing import Protocol - - -@dataclass(frozen=True) -class Invoice: - number: str - customer_email: str - amount_cents: int - due: date - - -class InvoiceSource(Protocol): - def unpaid(self) -> list[Invoice]: ... - - -class MailTransport(Protocol): - def send(self, to: str, subject: str, body: str) -> None: ... - - -class InMemoryInvoices: - """Production would wrap a database; the seam doesn't care.""" - - def __init__(self, invoices: list[Invoice] | None = None) -> None: - self._invoices = invoices or [] - - def unpaid(self) -> list[Invoice]: - return list(self._invoices) - - -class ConsoleMail: - """The production default transport (stand-in for SMTP).""" - - def send(self, to: str, subject: str, body: str) -> None: - print(f"MAIL to={to} subject={subject!r}") - - -class ReminderService: - def __init__( - self, - invoices: InvoiceSource, - mail: MailTransport | None = None, - today: Callable[[], date] = date.today, - ) -> None: - self.invoices = invoices - self.mail: MailTransport = mail if mail is not None else ConsoleMail() - self.today = today - - def send_reminders(self, grace_days: int = 3) -> list[str]: - """Remind every invoice more than grace_days overdue; return numbers.""" - reminded: list[str] = [] - for invoice in self.invoices.unpaid(): - overdue = (self.today() - invoice.due).days - if overdue > grace_days: - self.mail.send( - to=invoice.customer_email, - subject=f"Invoice {invoice.number} is {overdue} days overdue", - body=f"Please pay {invoice.amount_cents / 100:.2f}.", - ) - reminded.append(invoice.number) - return reminded - - -def main() -> None: - source = InMemoryInvoices( - [ - Invoice("INV-1", "ada@example.com", 120_00, date(2026, 8, 1)), - Invoice("INV-2", "grace@example.com", 80_00, date(2026, 8, 25)), - ] - ) - # Test wiring: frozen clock, captured mail -- fully deterministic. - outbox: list[str] = [] - - class CapturingMail: - def send(self, to: str, subject: str, body: str) -> None: - outbox.append(f"{to}: {subject}") - - service = ReminderService(source, mail=CapturingMail(), today=lambda: date(2026, 8, 26)) - print(f"reminded: {service.send_reminders()}") - print(f"outbox: {outbox}") - - -if __name__ == "__main__": - main() diff --git a/patterns/modern/dependency_injection/real_world.py b/patterns/modern/dependency_injection/real_world.py deleted file mode 100644 index 9d44e84..0000000 --- a/patterns/modern/dependency_injection/real_world.py +++ /dev/null @@ -1,32 +0,0 @@ -"""The stdlib does DI by keyword argument. - -``sorted(key=...)`` injects the ordering; ``json.dumps(cls=...)`` injects -the encoder. Same seam, same benefit. -""" - -from __future__ import annotations - -import json -from typing import Any - - -class UpperEncoder(json.JSONEncoder): - def encode(self, o: Any) -> str: - return super().encode(o).upper() - - -def sort_by_injected_policy(words: list[str]) -> list[str]: - return sorted(words, key=str.casefold) - - -def dump_with_injected_encoder(data: dict[str, str]) -> str: - return json.dumps(data, cls=UpperEncoder) - - -def main() -> None: - print(sort_by_injected_policy(["b", "A", "c"])) - print(dump_with_injected_encoder({"k": "v"})) - - -if __name__ == "__main__": - main() diff --git a/patterns/modern/dependency_injection/tests/__init__.py b/patterns/modern/dependency_injection/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/patterns/modern/dependency_injection/tests/test_dependency_injection.py b/patterns/modern/dependency_injection/tests/test_dependency_injection.py deleted file mode 100644 index 34da291..0000000 --- a/patterns/modern/dependency_injection/tests/test_dependency_injection.py +++ /dev/null @@ -1,62 +0,0 @@ -"""Behavioral tests for all three dependency-injection variants.""" - -from datetime import date - -from patterns.modern.dependency_injection import naive, pythonic, real_world - - -class TestNaive: - def test_works_but_depends_on_the_real_clock(self) -> None: - message = naive.GreetingService().greet("ada") - assert message.endswith(", ada") - assert message.startswith(("good morning", "good day")) - - -class CapturingMail: - def __init__(self) -> None: - self.outbox: list[tuple[str, str]] = [] - - def send(self, to: str, subject: str, body: str) -> None: - self.outbox.append((to, subject)) - - -def _service(mail: CapturingMail, today: date) -> pythonic.ReminderService: - source = pythonic.InMemoryInvoices( - [ - pythonic.Invoice("INV-1", "ada@example.com", 120_00, date(2026, 8, 1)), - pythonic.Invoice("INV-2", "grace@example.com", 80_00, date(2026, 8, 25)), - ] - ) - return pythonic.ReminderService(source, mail=mail, today=lambda: today) - - -class TestPythonic: - def test_frozen_clock_makes_reminders_deterministic(self) -> None: - mail = CapturingMail() - reminded = _service(mail, date(2026, 8, 26)).send_reminders(grace_days=3) - assert reminded == ["INV-1"] # 25 days overdue; INV-2 inside grace - assert mail.outbox == [("ada@example.com", "Invoice INV-1 is 25 days overdue")] - - def test_grace_period_is_respected(self) -> None: - mail = CapturingMail() - reminded = _service(mail, date(2026, 8, 26)).send_reminders(grace_days=30) - assert reminded == [] and mail.outbox == [] - - def test_every_seam_is_swappable(self) -> None: - # A different source, transport, and clock -- no monkeypatching anywhere. - source = pythonic.InMemoryInvoices([]) - mail = CapturingMail() - service = pythonic.ReminderService(source, mail=mail, today=lambda: date(2026, 1, 1)) - assert service.send_reminders() == [] - - def test_production_defaults_exist(self) -> None: - service = pythonic.ReminderService(pythonic.InMemoryInvoices([])) - assert isinstance(service.mail, pythonic.ConsoleMail) - - -class TestRealWorld: - def test_injected_sort_policy(self) -> None: - assert real_world.sort_by_injected_policy(["b", "A", "c"]) == ["A", "b", "c"] - - def test_injected_encoder(self) -> None: - assert real_world.dump_with_injected_encoder({"k": "v"}) == '{"K": "V"}' diff --git a/patterns/modern/dependency_injection/tests/test_invoice_reminders.py b/patterns/modern/dependency_injection/tests/test_invoice_reminders.py new file mode 100644 index 0000000..c31f649 --- /dev/null +++ b/patterns/modern/dependency_injection/tests/test_invoice_reminders.py @@ -0,0 +1,62 @@ +"""Behavioral tests for the invoice-reminders mini-project.""" + +from __future__ import annotations + +from datetime import date + +import pytest + +from patterns.modern.dependency_injection.examples.invoice_reminders.adapters import ( + ConsoleMail, + InMemoryInvoices, +) +from patterns.modern.dependency_injection.examples.invoice_reminders.app import ( + build_service, + sample_invoices, +) +from patterns.modern.dependency_injection.examples.invoice_reminders.main import main +from patterns.modern.dependency_injection.pattern import Invoice + + +class TestAdapters: + def test_in_memory_source_returns_a_copy(self) -> None: + inv = Invoice("INV-1", "ada@example.com", 100, date(2026, 8, 1)) + source = InMemoryInvoices([inv]) + source.unpaid().clear() + assert source.unpaid() == [inv] + + def test_console_mail_prints_and_counts(self, capsys: pytest.CaptureFixture[str]) -> None: + mail = ConsoleMail() + mail.send("ada@example.com", "Invoice INV-1 is 5 days overdue", "Please pay.") + assert mail.sent_count == 1 + assert "ada@example.com" in capsys.readouterr().out + + +class TestCompositionRoot: + def test_build_service_defaults_to_production_adapters( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + service = build_service(sample_invoices(), today=lambda: date(2026, 8, 27)) + reminded = service.send_reminders() + assert reminded == ["INV-1", "INV-3"] # INV-2 is inside the grace period + assert capsys.readouterr().out.count("MAIL") == 2 + + def test_every_seam_is_overridable_from_the_root(self) -> None: + captured: list[str] = [] + + class Outbox: + def send(self, to: str, subject: str, body: str) -> None: + captured.append(to) + + service = build_service(sample_invoices(), mail=Outbox(), today=lambda: date(2026, 8, 27)) + service.send_reminders() + assert captured == ["ada@example.com", "linus@example.com"] + + +class TestDemo: + def test_main_reports_the_pinned_day_reminders( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + main() + out = capsys.readouterr().out + assert "reminded: ['INV-1', 'INV-3']" in out diff --git a/patterns/modern/dependency_injection/tests/test_service.py b/patterns/modern/dependency_injection/tests/test_service.py new file mode 100644 index 0000000..703c000 --- /dev/null +++ b/patterns/modern/dependency_injection/tests/test_service.py @@ -0,0 +1,72 @@ +"""Behavioral tests for the pattern's service — every seam exercised.""" + +from __future__ import annotations + +from datetime import date + +from patterns.modern.dependency_injection import Invoice, ReminderService + + +class FixedInvoices: + def __init__(self, invoices: list[Invoice]) -> None: + self._invoices = invoices + + def unpaid(self) -> list[Invoice]: + return list(self._invoices) + + +class CapturingMail: + def __init__(self) -> None: + self.outbox: list[tuple[str, str, str]] = [] + + def send(self, to: str, subject: str, body: str) -> None: + self.outbox.append((to, subject, body)) + + +def invoice(number: str, due: date, email: str = "ada@example.com") -> Invoice: + return Invoice(number, email, 100_00, due) + + +class TestReminderPolicy: + today = date(2026, 8, 27) + + def service(self, invoices: list[Invoice], mail: CapturingMail) -> ReminderService: + return ReminderService(FixedInvoices(invoices), mail, today=lambda: self.today) + + def test_overdue_past_grace_is_reminded(self) -> None: + mail = CapturingMail() + reminded = self.service([invoice("INV-1", date(2026, 8, 1))], mail).send_reminders() + assert reminded == ["INV-1"] + assert mail.outbox == [ + ("ada@example.com", "Invoice INV-1 is 26 days overdue", "Please pay 100.00.") + ] # cents rendered as currency, not 10000 + + def test_grace_days_parameter_actually_widens_the_grace(self) -> None: + mail = CapturingMail() + overdue_26_days = invoice("INV-1", date(2026, 8, 1)) + service = self.service([overdue_26_days], mail) + assert service.send_reminders(grace_days=30) == [] # 26 < 30: quiet + assert service.send_reminders(grace_days=25) == ["INV-1"] # 26 > 25 + + def test_exactly_at_grace_is_not_reminded(self) -> None: + mail = CapturingMail() + at_grace = invoice("INV-2", date(2026, 8, 24)) # 3 days overdue == grace + assert self.service([at_grace], mail).send_reminders(grace_days=3) == [] + assert mail.outbox == [] + + def test_not_yet_due_is_not_reminded(self) -> None: + mail = CapturingMail() + future = invoice("INV-3", date(2026, 9, 1)) + assert self.service([future], mail).send_reminders() == [] + + def test_the_clock_seam_controls_the_outcome(self) -> None: + """The same invoice flips from quiet to reminded by injecting a later day.""" + inv = invoice("INV-4", date(2026, 8, 25)) + + def at(day: date) -> ReminderService: + return ReminderService(FixedInvoices([inv]), CapturingMail(), today=lambda: day) + + early = at(date(2026, 8, 26)) + late = at(date(2026, 9, 26)) + assert early.send_reminders() == [] + assert late.send_reminders() == ["INV-4"] diff --git a/patterns/modern/registry/README.md b/patterns/modern/registry/README.md index 9879f37..51496c9 100644 --- a/patterns/modern/registry/README.md +++ b/patterns/modern/registry/README.md @@ -14,28 +14,17 @@ stdlib_sightings: [codecs.register, functools.singledispatch, atexit.register] # Registry -## Problem - -An exporter supports "csv", "json", "xml"… and every new format edits the -same `if/elif` ladder. The dispatcher has become a bottleneck every plugin -must patch. - -## Naive solution - -`naive.py` is that ladder: closed for extension, growing forever. - -## Pythonic solution - -A dict from name to callable, filled by a `@register("csv")` decorator — -defining a handler *is* registering it. Dispatch is a lookup; the unknown-key -policy lives in exactly one place. - -## In the wild - -`codecs.register` is a full plugin registry (every `.encode("rot13")` is a -lookup); `functools.singledispatch` is a registry keyed by type; -`atexit.register` collects callables to run at shutdown. - -## Verdict - -**Pythonic.** The standard cure for if/elif dispatch. +Implementations announce themselves by name; dispatch is a lookup, and adding +a case is writing one new function. **Verdict: pythonic** — the standard cure +for `if/elif` dispatch. + +| Where | What | +|---|---| +| [`pattern/`](pattern/) | The importable code: `Registry`, `UnknownKeyError` | +| [`docs/`](docs/) | [Fundamentals](docs/fundamentals.md) · [Implementation guide](docs/implementation.md) · [External examples](docs/examples.md) | +| [`examples/export_plugins/`](examples/export_plugins/) | Mini-project: self-registering exporters, one in a separate plugin module | +| [`tests/`](tests/) | Behavioral tests for the pattern and the mini-project | + +```bash +uv run python -m patterns.modern.registry.examples.export_plugins.main +``` diff --git a/patterns/modern/registry/__init__.py b/patterns/modern/registry/__init__.py index c1480b6..6e142df 100644 --- a/patterns/modern/registry/__init__.py +++ b/patterns/modern/registry/__init__.py @@ -1 +1,2 @@ -"""Registry: implementations announce themselves; dispatch is a lookup.""" +from .pattern import Registry as Registry +from .pattern import UnknownKeyError as UnknownKeyError diff --git a/patterns/modern/registry/docs/examples.md b/patterns/modern/registry/docs/examples.md new file mode 100644 index 0000000..67a151a --- /dev/null +++ b/patterns/modern/registry/docs/examples.md @@ -0,0 +1,41 @@ +# Registry — where it lives outside this repo + +Cited, real implementations to study (or point an agent at) when designing or +reviewing registry-shaped code. + +## Python standard library + +- **`codecs.register` / `codecs.lookup`.** The encodings machinery is a full + plugin registry: every `"text".encode(name)` is a lookup, and registered + search functions can serve entirely new names. + [docs.python.org/3/library/codecs.html](https://docs.python.org/3/library/codecs.html) +- **`functools.singledispatch`.** A registry keyed by *type* instead of name, + with the same decorator registration surface and MRO-aware lookup. + [docs.python.org/3/library/functools.html#functools.singledispatch](https://docs.python.org/3/library/functools.html#functools.singledispatch) +- **`atexit.register`.** A registry whose "dispatch" is the interpreter + shutting down — registration as decorator, in the stdlib since forever. + [docs.python.org/3/library/atexit.html](https://docs.python.org/3/library/atexit.html) + +## Major ecosystems + +- **Flask route decorators.** `@app.route("/users")` fills the URL map — a + registry populated at import time, which is why a views module that never + gets imported serves 404s (the caveat, in production form). + [flask.palletsprojects.com](https://flask.palletsprojects.com/) +- **Django `admin.site.register`.** The explicit-call flavor: the admin is a + registry of model → options, filled in each app's `admin.py` — a module + Django deliberately auto-imports, solving the import-time problem by + convention. + [docs.djangoproject.com/en/stable/ref/contrib/admin/](https://docs.djangoproject.com/en/stable/ref/contrib/admin/) +- **setuptools entry points.** Registration moved out of code into package + metadata, so plugins in *other distributions* are discoverable without any + import — the industrial-strength answer to "a plugin nobody imports". + [packaging.python.org/en/latest/specifications/entry-points/](https://packaging.python.org/en/latest/specifications/entry-points/) + +## What to notice across all of them + +Each one has an explicit answer to the two policy questions: unknown names +(`LookupError` from `codecs`, 404 from Flask) and registration time (import +side effects, auto-imported conventions, or metadata). When reviewing +registry code, find both answers; if either is implicit, that's the bug +waiting. diff --git a/patterns/modern/registry/docs/fundamentals.md b/patterns/modern/registry/docs/fundamentals.md new file mode 100644 index 0000000..8b633c8 --- /dev/null +++ b/patterns/modern/registry/docs/fundamentals.md @@ -0,0 +1,68 @@ +# Registry — fundamentals + +## Intent + +Let implementations announce themselves by name so dispatch becomes a lookup +instead of an `if/elif` ladder — and adding a case means writing one new +function, not editing the dispatcher. + +## Participants + +| Role | Ladder form | Python form | +|---|---|---| +| Dispatcher | One growing `if/elif` function | A mapping — `Registry` in [`pattern/registry.py`](../pattern/registry.py) | +| Cases | Arms of the ladder | Independent callables, possibly in other modules | +| Registration | Editing the ladder | A `@registry.register("name")` decorator at definition site | +| Lookup policy | The trailing `else`, per call site | `registry.get(name)` — one place, one decision | + +## Mechanism + +1. A module owns a `Registry` instance typed by what it stores + (`Registry[Exporter]`). +2. Each implementation registers itself where it is defined — the decorator + makes *defining* a handler and *announcing* it the same act. +3. Dispatch asks the registry by name. Unknown names raise `UnknownKeyError` + listing what is known; duplicate registrations are an error unless + explicitly replaced. +4. Because registration runs at import time, a plugin exists only once its + module has been imported — the pattern's one genuine sharp edge. + +## The classic form, and what Python absorbs + +The pre-pattern shape is the ladder every plugin must patch: + +```python +def export(rows, fmt): + if fmt == "csv": + ... # arm 1 + elif fmt == "keyvalue": + ... # arm 2 + else: + raise ValueError(...) # the unknown-name policy, re-decided per ladder +``` + +Closed for extension: format N+1 edits this function, and every parallel +ladder (validate, describe, …) drifts out of sync. Python absorbs the +machinery a plugin framework would add — a dict is the registry, a decorator +is the registration API, first-class functions are the plugins. What survives +is two policies the folk pattern leaves implicit: **what happens on an +unknown name**, and **what happens on a duplicate**. + +## When to use it + +- Open-ended families keyed by a value: exporters by format, handlers by + event name, commands by verb. +- Plugins live in modules the dispatcher must not know about. + +## When not to use it + +- The key is a *type* — `functools.singledispatch` is that registry, built in. +- The set of cases is small, closed, and local — a literal dict (or `match`) + says so more plainly. +- Cross-package plugins — reach for entry points, which solve the import-time + problem the plain registry cannot. + +## Verdict: pythonic + +The standard cure for `if/elif` dispatch; the stdlib itself ships registries +(`codecs.register`, `atexit.register`, `singledispatch.register`). diff --git a/patterns/modern/registry/docs/implementation.md b/patterns/modern/registry/docs/implementation.md new file mode 100644 index 0000000..610ded1 --- /dev/null +++ b/patterns/modern/registry/docs/implementation.md @@ -0,0 +1,86 @@ +# Registry — putting it into a system + +## The smell it fixes + +A dispatcher that every new case must edit: + +```python +def export(rows, fmt): + if fmt == "csv": + ... + elif fmt == "json": + ... + elif fmt == "xml": + ... # this week's edit + else: + raise ValueError(...) +``` + +The ladder couples every format to one function, and the unknown-name policy +gets re-decided (differently) at every ladder in the codebase. + +## Steps + +1. **Name the contract.** A type alias for what the registry stores + (`Exporter = Callable[[Rows], str]`) turns "any function" into a checkable + promise. +2. **Create one registry instance in the module that owns dispatch** — + `EXPORTERS: Registry[Exporter] = Registry(kind="format")`. The `kind` + string buys readable errors for free. +3. **Convert each ladder arm into a decorated function.** Its condition + becomes its name: `@EXPORTERS.register("csv")`. +4. **Route all dispatch through one lookup.** `EXPORTERS.get(fmt)(rows)` — + the unknown-name policy now lives in the registry, once. +5. **Guarantee plugins are imported.** Registration is an import-time side + effect, so some module must import each plugin. The package `__init__` is + the honest place — with a comment saying the import is load-bearing. + +```python +from collections.abc import Callable + +from patterns.modern.registry import Registry + +Rows = list[dict[str, str]] +Exporter = Callable[[Rows], str] + +EXPORTERS: Registry[Exporter] = Registry(kind="format") + + +@EXPORTERS.register("csv") +def to_csv(rows: Rows) -> str: ... +``` + +## Python idioms that keep it small + +- **The decorator returns its target unchanged**, so a registered function is + still an ordinary, individually-testable function. +- **Keep registries module-level and typed.** A registry passed around as a + parameter is usually dependency injection wearing the wrong hat. +- **For type-keyed dispatch, don't rebuild this** — `functools.singledispatch` + already is the registry, with MRO-aware lookup. + +## Pitfalls + +- **The plugin nobody imports.** The registry only knows what has run. + Symptom: works in the app (which imports everything), fails in a test that + imports one module. Fix: import plugins in the package `__init__`, or use + entry points for cross-package discovery. +- **Silent duplicate registration.** With a bare dict, two plugins claiming + `"csv"` is a last-import-wins race. `Registry` makes it an error; + `replace=True` makes an intentional override visible in the diff. +- **Unknown-name policy at call sites.** If callers wrap `get` in their own + `try/except KeyError` with their own fallbacks, the policy has leaked back + out — decide it once. +- **Registration with heavier side effects.** The decorator should record the + entry, nothing more; a plugin that opens connections at import time turns + every importer into an integration test. + +## Worked example + +[`examples/export_plugins/`](../examples/export_plugins/) applies every step, +including a plugin in its own module whose `__init__` import is the +documented fix for the import-time caveat: + +```bash +uv run python -m patterns.modern.registry.examples.export_plugins.main +``` diff --git a/patterns/modern/registry/examples/export_plugins/__init__.py b/patterns/modern/registry/examples/export_plugins/__init__.py new file mode 100644 index 0000000..c20d74a --- /dev/null +++ b/patterns/modern/registry/examples/export_plugins/__init__.py @@ -0,0 +1,2 @@ +# Load-bearing: markdown registers itself at import time (the unit's caveat demo). +from . import markdown as markdown diff --git a/patterns/modern/registry/examples/export_plugins/exporters.py b/patterns/modern/registry/examples/export_plugins/exporters.py new file mode 100644 index 0000000..e181205 --- /dev/null +++ b/patterns/modern/registry/examples/export_plugins/exporters.py @@ -0,0 +1,32 @@ +"""The registry, the built-in exporters, and the one dispatch function.""" + +from __future__ import annotations + +import json +from collections.abc import Callable + +from patterns.modern.registry.pattern import Registry + +Rows = list[dict[str, str]] +Exporter = Callable[[Rows], str] + +EXPORTERS: Registry[Exporter] = Registry(kind="format") + + +@EXPORTERS.register("csv") +def to_csv(rows: Rows) -> str: + if not rows: + return "" + header = ",".join(rows[0]) + body = "\n".join(",".join(row.values()) for row in rows) + return f"{header}\n{body}" + + +@EXPORTERS.register("json") +def to_json(rows: Rows) -> str: + return json.dumps(rows, indent=2) + + +def export(rows: Rows, fmt: str) -> str: + """Dispatch is a lookup; the unknown-format policy lives in the registry, once.""" + return EXPORTERS.get(fmt)(rows) diff --git a/patterns/modern/registry/examples/export_plugins/main.py b/patterns/modern/registry/examples/export_plugins/main.py new file mode 100644 index 0000000..f7d7a77 --- /dev/null +++ b/patterns/modern/registry/examples/export_plugins/main.py @@ -0,0 +1,24 @@ +"""Demo: one dataset through every registered exporter.""" + +from __future__ import annotations + +from patterns.modern.registry.examples.export_plugins.exporters import EXPORTERS, export +from patterns.modern.registry.pattern import UnknownKeyError + + +def main() -> None: + rows = [ + {"name": "ada", "role": "eng"}, + {"name": "grace", "role": "ops"}, + ] + for fmt in EXPORTERS.names(): + print(f"--- {fmt} ---") + print(export(rows, fmt)) + try: + export(rows, "xml") + except UnknownKeyError as exc: + print(f"--- unknown format ---\n{exc}") + + +if __name__ == "__main__": + main() diff --git a/patterns/modern/registry/examples/export_plugins/markdown.py b/patterns/modern/registry/examples/export_plugins/markdown.py new file mode 100644 index 0000000..0406109 --- /dev/null +++ b/patterns/modern/registry/examples/export_plugins/markdown.py @@ -0,0 +1,25 @@ +"""A plugin in its own module — the import-time caveat, made concrete. + +Nothing imports this module for its names; it is imported (by the package +``__init__``) purely so the ``@EXPORTERS.register`` below runs. Comment out +that import and ``"markdown"`` vanishes from the registry without any other +code changing — which is exactly why real plugin systems pair registries +with entry points or explicit plugin loading. +""" + +from __future__ import annotations + +from patterns.modern.registry.examples.export_plugins.exporters import EXPORTERS, Rows + + +@EXPORTERS.register("markdown") +def to_markdown(rows: Rows) -> str: + if not rows: + return "" + headers = list(rows[0]) + lines = [ + "| " + " | ".join(headers) + " |", + "| " + " | ".join("---" for _ in headers) + " |", + ] + lines += ["| " + " | ".join(row[h] for h in headers) + " |" for row in rows] + return "\n".join(lines) diff --git a/patterns/modern/registry/naive.py b/patterns/modern/registry/naive.py deleted file mode 100644 index 7095db2..0000000 --- a/patterns/modern/registry/naive.py +++ /dev/null @@ -1,26 +0,0 @@ -"""Dispatch as an if/elif ladder: every new format edits this function.""" - -from __future__ import annotations - - -def export(rows: list[dict[str, str]], fmt: str) -> str: - if fmt == "csv": - if not rows: - return "" - header = ",".join(rows[0]) - body = "\n".join(",".join(row.values()) for row in rows) - return f"{header}\n{body}" - elif fmt == "keyvalue": - return "\n".join(f"{k}={v}" for row in rows for k, v in row.items()) - else: - raise ValueError(f"unknown format: {fmt}") - - -def main() -> None: - rows = [{"name": "ada", "role": "eng"}] - print(export(rows, "csv")) - print(export(rows, "keyvalue")) - - -if __name__ == "__main__": - main() diff --git a/patterns/modern/registry/pattern/__init__.py b/patterns/modern/registry/pattern/__init__.py new file mode 100644 index 0000000..33c73e3 --- /dev/null +++ b/patterns/modern/registry/pattern/__init__.py @@ -0,0 +1,2 @@ +from .registry import Registry as Registry +from .registry import UnknownKeyError as UnknownKeyError diff --git a/patterns/modern/registry/pattern/registry.py b/patterns/modern/registry/pattern/registry.py new file mode 100644 index 0000000..7610947 --- /dev/null +++ b/patterns/modern/registry/pattern/registry.py @@ -0,0 +1,60 @@ +"""A typed plugin registry: a dict, a decorator, and one lookup policy. + +``Registry`` maps names to implementations. Defining a handler registers it +(``@registry.register("csv")``); dispatch is ``registry.get(name)``. The two +policies the folk pattern leaves implicit are explicit here: duplicate names +are an error unless ``replace=True``, and unknown names raise +``UnknownKeyError`` naming what *is* registered. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Generic, TypeVar + +T = TypeVar("T") + + +class UnknownKeyError(LookupError): + """The name is not registered; the message lists the names that are.""" + + +class Registry(Generic[T]): + """A name-to-implementation mapping filled by decorator.""" + + def __init__(self, kind: str = "entry") -> None: + self._kind = kind # names the registry's contents in error messages + self._entries: dict[str, T] = {} + + def register(self, name: str, *, replace: bool = False) -> Callable[[T], T]: + """Return a decorator that registers its target under ``name``. + + Duplicate names raise ``ValueError`` — a silent overwrite is how two + plugins fight over a name without anyone noticing — unless the caller + says ``replace=True``. + """ + + def decorator(entry: T) -> T: + if name in self._entries and not replace: + raise ValueError(f"{self._kind} {name!r} is already registered (pass replace=True)") + self._entries[name] = entry + return entry + + return decorator + + def get(self, name: str) -> T: + """Look up one entry; unknown names fail loudly, listing known ones.""" + try: + return self._entries[name] + except KeyError: + known = ", ".join(sorted(self._entries)) or "" + raise UnknownKeyError(f"unknown {self._kind} {name!r} (known: {known})") from None + + def names(self) -> tuple[str, ...]: + return tuple(sorted(self._entries)) + + def __contains__(self, name: object) -> bool: + return name in self._entries + + def __len__(self) -> int: + return len(self._entries) diff --git a/patterns/modern/registry/pythonic.py b/patterns/modern/registry/pythonic.py deleted file mode 100644 index 9021658..0000000 --- a/patterns/modern/registry/pythonic.py +++ /dev/null @@ -1,55 +0,0 @@ -"""The decorator-filled registry: defining a handler registers it. - -New formats are new functions -- possibly in other modules -- and the -dispatcher never changes again. -""" - -from __future__ import annotations - -from collections.abc import Callable - -Exporter = Callable[[list[dict[str, str]]], str] - -EXPORTERS: dict[str, Exporter] = {} - - -def register(name: str) -> Callable[[Exporter], Exporter]: - def decorator(func: Exporter) -> Exporter: - EXPORTERS[name] = func - return func - - return decorator - - -@register("csv") -def to_csv(rows: list[dict[str, str]]) -> str: - if not rows: - return "" - header = ",".join(rows[0]) - body = "\n".join(",".join(row.values()) for row in rows) - return f"{header}\n{body}" - - -@register("keyvalue") -def to_keyvalue(rows: list[dict[str, str]]) -> str: - return "\n".join(f"{k}={v}" for row in rows for k, v in row.items()) - - -def export(rows: list[dict[str, str]], fmt: str) -> str: - """Dispatch is a lookup; the unknown-key policy lives here, once.""" - try: - exporter = EXPORTERS[fmt] - except KeyError: - known = ", ".join(sorted(EXPORTERS)) - raise ValueError(f"unknown format {fmt!r} (known: {known})") from None - return exporter(rows) - - -def main() -> None: - rows = [{"name": "ada", "role": "eng"}] - print(export(rows, "csv")) - print(export(rows, "keyvalue")) - - -if __name__ == "__main__": - main() diff --git a/patterns/modern/registry/real_world.py b/patterns/modern/registry/real_world.py deleted file mode 100644 index 705f901..0000000 --- a/patterns/modern/registry/real_world.py +++ /dev/null @@ -1,28 +0,0 @@ -"""``codecs``: the stdlib's plugin registry in daily use. - -Every str.encode(name) is a registry lookup; codecs.register() adds a -search function that can serve entirely new names. -""" - -from __future__ import annotations - -import codecs - - -def rot13(text: str) -> str: - """'rot13' resolves through the codec registry.""" - return codecs.encode(text, "rot13") - - -def lookup_is_the_registry(name: str) -> str: - """Ask the registry directly for a codec entry.""" - return codecs.lookup(name).name - - -def main() -> None: - print(rot13("gura fur fnvq")) - print(f"'UTF8' resolves to: {lookup_is_the_registry('UTF8')}") - - -if __name__ == "__main__": - main() diff --git a/patterns/modern/registry/tests/__init__.py b/patterns/modern/registry/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/patterns/modern/registry/tests/test_export_plugins.py b/patterns/modern/registry/tests/test_export_plugins.py new file mode 100644 index 0000000..f280176 --- /dev/null +++ b/patterns/modern/registry/tests/test_export_plugins.py @@ -0,0 +1,52 @@ +"""Behavioral tests for the export-plugins mini-project.""" + +from __future__ import annotations + +import json + +import pytest + +from patterns.modern.registry.examples.export_plugins.exporters import EXPORTERS, export +from patterns.modern.registry.examples.export_plugins.main import main +from patterns.modern.registry.pattern import UnknownKeyError + +ROWS = [{"name": "ada", "role": "eng"}, {"name": "grace", "role": "ops"}] + + +class TestExporters: + def test_csv_round_trips_headers_and_rows(self) -> None: + assert export(ROWS, "csv") == "name,role\nada,eng\ngrace,ops" + + def test_json_is_real_json(self) -> None: + assert json.loads(export(ROWS, "json")) == ROWS + + def test_markdown_renders_a_table(self) -> None: + out = export(ROWS, "markdown") + assert out.splitlines()[0] == "| name | role |" + assert "| ada | eng |" in out + + def test_empty_input_is_not_an_error(self) -> None: + assert export([], "csv") == "" + assert export([], "markdown") == "" + + +class TestPluginDiscovery: + def test_the_separate_module_plugin_registered_via_the_package_import(self) -> None: + # markdown.py is imported only by the package __init__ — its presence + # here is the import-time caveat's fix, working. + assert EXPORTERS.names() == ("csv", "json", "markdown") + + def test_unknown_format_policy_lives_in_one_place(self) -> None: + with pytest.raises(UnknownKeyError, match="unknown format 'xml'"): + export(ROWS, "xml") + + +class TestDemo: + def test_main_exports_every_format_and_shows_the_unknown_policy( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + main() + out = capsys.readouterr().out + assert "--- csv ---" in out + assert "--- markdown ---" in out + assert "unknown format 'xml'" in out diff --git a/patterns/modern/registry/tests/test_registry.py b/patterns/modern/registry/tests/test_registry.py index 18fa282..74cc582 100644 --- a/patterns/modern/registry/tests/test_registry.py +++ b/patterns/modern/registry/tests/test_registry.py @@ -1,42 +1,72 @@ -"""Behavioral tests for all three registry variants.""" +"""Behavioral tests for the pattern's ``Registry``.""" + +from __future__ import annotations + +from collections.abc import Callable import pytest -from patterns.modern.registry import naive, pythonic, real_world +from patterns.modern.registry import Registry, UnknownKeyError + +Handler = Callable[[str], str] + + +def make_registry() -> Registry[Handler]: + return Registry(kind="handler") + -ROWS = [{"name": "ada", "role": "eng"}] +class TestRegistration: + def test_the_decorator_registers_and_returns_the_function_unchanged(self) -> None: + registry = make_registry() + @registry.register("upper") + def shout(text: str) -> str: + return text.upper() -class TestNaive: - def test_ladder_dispatch_works(self) -> None: - assert naive.export(ROWS, "csv") == "name,role\nada,eng" + assert registry.get("upper") is shout + assert shout("hi") == "HI" # still an ordinary function - def test_unknown_format(self) -> None: - with pytest.raises(ValueError, match="unknown format"): - naive.export(ROWS, "yaml") + def test_duplicate_names_are_an_error(self) -> None: + registry = make_registry() + registry.register("upper")(str.upper) + with pytest.raises(ValueError, match="handler 'upper' is already registered"): + registry.register("upper")(str.lower) + assert registry.get("upper")("hi") == "HI" # original untouched + def test_replace_makes_an_override_explicit(self) -> None: + registry = make_registry() + registry.register("case")(str.upper) + registry.register("case", replace=True)(str.lower) + assert registry.get("case")("Hi") == "hi" -class TestPythonic: - def test_registered_handlers_dispatch_by_name(self) -> None: - assert pythonic.export(ROWS, "csv") == "name,role\nada,eng" - assert pythonic.export(ROWS, "keyvalue") == "name=ada\nrole=eng" - def test_new_handler_registers_without_touching_the_dispatcher(self) -> None: - @pythonic.register("upper") - def to_upper(rows: list[dict[str, str]]) -> str: - return " ".join(v.upper() for row in rows for v in row.values()) +class TestLookup: + def test_unknown_names_fail_loudly_and_list_known_ones(self) -> None: + registry = make_registry() + registry.register("upper")(str.upper) + registry.register("lower")(str.lower) + message = r"unknown handler 'title' \(known: lower, upper\)" + with pytest.raises(UnknownKeyError, match=message): + registry.get("title") - try: - assert pythonic.export(ROWS, "upper") == "ADA ENG" - finally: - del pythonic.EXPORTERS["upper"] + def test_an_empty_registry_says_so(self) -> None: + with pytest.raises(UnknownKeyError, match=""): + make_registry().get("anything") - def test_unknown_format_names_the_known_ones(self) -> None: - with pytest.raises(ValueError, match="known: csv, keyvalue"): - pythonic.export(ROWS, "yaml") + def test_introspection_surface(self) -> None: + registry = make_registry() + registry.register("b")(str.upper) + registry.register("a")(str.lower) + assert registry.names() == ("a", "b") + assert "a" in registry and "z" not in registry + assert len(registry) == 2 -class TestRealWorld: - def test_codec_registry_resolves_names(self) -> None: - assert real_world.rot13("gura fur fnvq") == "then she said" - assert real_world.lookup_is_the_registry("UTF8") == "utf-8" +class TestDefaultKind: + def test_default_kind_names_entries_in_errors(self) -> None: + registry: Registry[str] = Registry() # no kind given + registry.register("x")("value") + with pytest.raises(ValueError, match="entry 'x' is already registered"): + registry.register("x")("other") + with pytest.raises(UnknownKeyError, match="unknown entry 'y'"): + registry.get("y") diff --git a/patterns/modern/repository/README.md b/patterns/modern/repository/README.md index f4caf2f..1bd313f 100644 --- a/patterns/modern/repository/README.md +++ b/patterns/modern/repository/README.md @@ -14,30 +14,17 @@ stdlib_sightings: [sqlite3, shelve] # Repository -## Problem - -Pricing rules shouldn't know SQL. When persistence details soak into domain -logic, every business test drags a database behind it and every storage -change touches everything. - -## Naive solution - -`naive.py` inlines sqlite calls in the domain function — compact, and -welded shut. - -## Pythonic solution - -A `Protocol` names the collection-like operations the domain needs (`add`, -`get`, `list`); an in-memory dict repo serves tests, a sqlite repo serves -production, and the domain function accepts either. - -## In the wild - -`shelve` is a ready-made key-object repository over `dbm`; `sqlite3` with a -thin class over it is the standard hand-rolled form (shown in -`real_world.py`). - -## Verdict - -**Use with care.** Earn it with a real second implementation (the in-memory -fake counts); skip it for scripts that just need a query. +Domain logic speaks to storage through a small `Protocol` port; a fake and a +real adapter both satisfy it, held together by shared contract tests. +**Verdict: use with care** — earn it with a genuine second implementation. + +| Where | What | +|---|---| +| [`pattern/`](pattern/) | The importable code: `Invoice`, `Invoices` (port), `InMemoryInvoices` (fake), `total_owed`, `overdue` | +| [`docs/`](docs/) | [Fundamentals](docs/fundamentals.md) · [Implementation guide](docs/implementation.md) · [External examples](docs/examples.md) | +| [`examples/invoice_ledger/`](examples/invoice_ledger/) | Mini-project: sqlite adapter + identical answers from both backends | +| [`tests/`](tests/) | Domain tests on the fake; one contract suite parametrized over both adapters | + +```bash +uv run python -m patterns.modern.repository.examples.invoice_ledger.main +``` diff --git a/patterns/modern/repository/__init__.py b/patterns/modern/repository/__init__.py index 7f92ffb..a60ab76 100644 --- a/patterns/modern/repository/__init__.py +++ b/patterns/modern/repository/__init__.py @@ -1 +1,5 @@ -"""Repository: collection-like storage seam for domain logic.""" +from .pattern import InMemoryInvoices as InMemoryInvoices +from .pattern import Invoice as Invoice +from .pattern import Invoices as Invoices +from .pattern import overdue as overdue +from .pattern import total_owed as total_owed diff --git a/patterns/modern/repository/docs/examples.md b/patterns/modern/repository/docs/examples.md new file mode 100644 index 0000000..1c30260 --- /dev/null +++ b/patterns/modern/repository/docs/examples.md @@ -0,0 +1,46 @@ +# Repository — where it lives outside this repo + +Cited, real implementations to study (or point an agent at) when designing or +reviewing repository-shaped code. + +## Canonical references + +- **Fowler, PoEAA — Repository.** The original catalog entry: a + collection-like interface mediating between domain and data mapping. + [martinfowler.com/eaaCatalog/repository.html](https://martinfowler.com/eaaCatalog/repository.html) +- **Percival & Gregory, *Architecture Patterns with Python*, ch. 2.** The + canonical Python worked example — `AbstractRepository`, a fake, SQLAlchemy + adapter, and the argument for contract tests. This unit is that chapter + in miniature, with `Protocol` instead of an ABC. + [cosmicpython.com/book/chapter_02_repository.html](https://www.cosmicpython.com/book/chapter_02_repository.html) + +## Python standard library + +- **`sqlite3`.** The stdlib backend a real adapter wraps — the mini-project's + `SqliteInvoices` is the standard hand-rolled form. + [docs.python.org/3/library/sqlite3.html](https://docs.python.org/3/library/sqlite3.html) +- **`shelve`.** A ready-made key→object repository over `dbm`: the smallest + possible repository surface (`__getitem__`/`__setitem__`), useful for + calibrating how little a port can be. + [docs.python.org/3/library/shelve.html](https://docs.python.org/3/library/shelve.html) + +## Major ecosystems — and a contrast + +- **Django `Manager`/`QuerySet`.** The *active-record* flavor: storage API + attached to the model class itself (`Invoice.objects.filter(...)`). + Convenient, and exactly what repository is **not** — the domain type and + the query surface are welded together, so there is no port to fake. + Knowing the difference is most of knowing when you need this pattern. + [docs.djangoproject.com/en/stable/topics/db/managers/](https://docs.djangoproject.com/en/stable/topics/db/managers/) +- **SQLAlchemy `Session`.** The data-mapper half the pattern assumes: domain + objects stay plain, the session maps them — a repository is a thin, + domain-vocabulary port over it. + [docs.sqlalchemy.org/en/latest/orm/session_basics.html](https://docs.sqlalchemy.org/en/latest/orm/session_basics.html) + +## What to notice across all of them + +The dividing line is always *who owns the interface*: repository puts the +domain in charge of a small port; active record puts the framework in charge +of a wide one. When reviewing, ask for the fake — if an in-memory +implementation would be laborious to write, the port has grown past the +domain's actual needs. diff --git a/patterns/modern/repository/docs/fundamentals.md b/patterns/modern/repository/docs/fundamentals.md new file mode 100644 index 0000000..53aeaeb --- /dev/null +++ b/patterns/modern/repository/docs/fundamentals.md @@ -0,0 +1,64 @@ +# Repository — fundamentals + +## Intent + +Keep domain logic ignorant of how objects are stored by mediating through a +collection-like interface. Named in Fowler's +[Patterns of Enterprise Application Architecture](https://martinfowler.com/eaaCatalog/repository.html); +given its canonical modern-Python treatment in +[Architecture Patterns with Python, ch. 2](https://www.cosmicpython.com/book/chapter_02_repository.html). + +## Participants + +| Role | Enterprise form | Python form | +|---|---|---| +| Domain objects | Mapped entities | Frozen dataclasses — `Invoice` in [`pattern/ledger.py`](../pattern/ledger.py) | +| The port | A repository interface | A `Protocol` naming only the operations the domain needs (`Invoices`) | +| Real adapter | ORM-backed repository class | Any class with the same methods (the mini-project's `SqliteInvoices`) | +| The fake | A mocking framework's job | `InMemoryInvoices` — a list with the port's methods, shipped *with* the pattern | +| Domain services | Methods on entities/services | Plain functions taking the port (`total_owed`, `overdue`) | + +## Mechanism + +1. The domain names its storage needs as a small `Protocol` — the operations + it actually uses, not a generic CRUD surface. +2. Domain logic takes the port as a parameter and never imports a driver. +3. Two adapters satisfy the port: an in-memory fake for tests and a real one + for production. Structural typing means neither declares anything. +4. One shared contract test suite runs against **both** adapters — that suite + is what makes "the fake behaves like production" a checked fact instead of + a hope. + +## The welded-shut form, and what Python absorbs + +The pre-pattern shape inlines storage into the domain question: + +```python +def total_owed(conn: sqlite3.Connection, customer: str) -> int: + rows = conn.execute("SELECT amount FROM invoices WHERE customer = ?", (customer,)).fetchall() + return sum(amount for (amount,) in rows) # domain math, welded to SQL +``` + +Compact — and every test of the *math* now drags a database, and every +storage change touches domain files. Enterprise stacks answered with +repository interfaces, unit-of-work classes, and ORMs. Python absorbs the +ceremony: `Protocol` gives the interface without inheritance, a list gives +the fake without a mocking framework. What survives is the discipline — +**the domain speaks only in its own types, through a port it owns**. + +## When to use it + +- Domain logic worth testing at speed, uncoupled from storage. +- A genuine second backend (and the in-memory fake counts as one). + +## When not to use it + +- Scripts that just need a query — the pattern's indirection buys nothing. +- One entity, one backend, no tests that hurt — wait for the pain. +- An ORM you're happy to couple to everywhere is itself a repository-shaped + boundary; wrapping it again adds a layer with no new seam. + +## Verdict: use with care + +Earn it with a real second implementation and shared contract tests; if your +tests still hit a database, the repository isn't earning its keep. diff --git a/patterns/modern/repository/docs/implementation.md b/patterns/modern/repository/docs/implementation.md new file mode 100644 index 0000000..8e4678c --- /dev/null +++ b/patterns/modern/repository/docs/implementation.md @@ -0,0 +1,80 @@ +# Repository — putting it into a system + +## The smell it fixes + +Domain tests that need infrastructure: + +```python +def test_total_owed() -> None: + conn = sqlite3.connect(TEST_DB) # schema setup, fixtures, teardown... + assert total_owed(conn, "ada") == 150 +``` + +SQL scattered through business logic means the business rules can't be +tested — or changed — without dragging storage along. + +## Steps + +1. **Write the domain type first.** A frozen dataclass in domain vocabulary + (`Invoice(number, customer, amount_cents, due)`) — no ORM base, no row + shapes. +2. **Name the port from the domain's demand side.** List the storage + operations domain code *actually performs* and put exactly those in a + `Protocol`. Three methods is a normal size; ten is a warning. +3. **Build the fake in the same module as the port.** A list with the port's + methods. It ships with the pattern, not buried in test helpers, because + it *is* the deliverable that makes domain tests instant. +4. **Move the SQL into a real adapter** that satisfies the same `Protocol` + (structurally — no base class), owning all row↔dataclass conversion. +5. **Write one contract test suite, parametrized over both adapters.** Same + assertions, both backends. This is the step most implementations skip, + and it is what keeps the fake honest. +6. **Pass the port into domain functions** — plain functions taking + `repo: Invoices` stay importable, testable, and driver-free. + +```python +from datetime import date + +from patterns.modern.repository import InMemoryInvoices, Invoice, total_owed + +repo = InMemoryInvoices() +repo.add(Invoice("INV-1", "ada", 120_00, date(2026, 8, 1))) +assert total_owed(repo, "ada") == 120_00 +``` + +## Python idioms that keep it small + +- **`Protocol` over ABC**: adapters stay dependency-free; sqlite3's and the + fake's only relationship is behavioral. +- **Frozen dataclasses** make identity questions explicit and rows + hashable-by-value in tests. +- **Keep queries as methods, not a query language.** `for_customer(name)` + beats `find(spec)` until you have evidence otherwise (the caveat about + generic `Repository[T]` in this unit's frontmatter). + +## Pitfalls + +- **The port grows to mirror SQL.** If a method exists because a screen + needed a `JOIN`, the domain is no longer defining the port. Split read + models out rather than widening the port. +- **The fake drifts from production.** Without shared contract tests, the + fake quietly diverges (ordering, duplicates, missing rows) and domain + tests pass against behavior production doesn't have. +- **Leaking storage types** — returning rows, cursors, or ORM instances + through the port re-couples everything the pattern decoupled. +- **A repository per table** instead of per domain concept: the port serves + an aggregate, not a schema. +- **Transactions smeared across repositories.** Commit/rollback is its own + seam (unit of work); bolting `commit()` onto each repository hides it. + (The mini-project's sqlite adapter commits per write so its durability + claim stays true at demo scale; a production design lifts commit here.) + +## Worked example + +[`examples/invoice_ledger/`](../examples/invoice_ledger/) adds the sqlite +adapter and prints identical domain answers from both backends; the shared +contract tests live in [`tests/test_invoice_ledger.py`](../tests/test_invoice_ledger.py): + +```bash +uv run python -m patterns.modern.repository.examples.invoice_ledger.main +``` diff --git a/patterns/modern/repository/examples/invoice_ledger/main.py b/patterns/modern/repository/examples/invoice_ledger/main.py new file mode 100644 index 0000000..026f6d8 --- /dev/null +++ b/patterns/modern/repository/examples/invoice_ledger/main.py @@ -0,0 +1,38 @@ +"""Demo: identical domain answers from the fake and the sqlite adapter.""" + +from __future__ import annotations + +import sqlite3 +from datetime import date + +from patterns.modern.repository.examples.invoice_ledger.sqlite_repo import SqliteInvoices +from patterns.modern.repository.pattern import ( + InMemoryInvoices, + Invoice, + Invoices, + overdue, + total_owed, +) + +LEDGER = [ + Invoice("INV-1", "ada", 120_00, date(2026, 8, 1)), + Invoice("INV-2", "ada", 80_00, date(2026, 9, 15)), + Invoice("INV-3", "grace", 45_50, date(2026, 7, 15)), +] +TODAY = date(2026, 8, 27) + + +def report(label: str, repo: Invoices) -> None: + for invoice in LEDGER: + repo.add(invoice) + late = ", ".join(i.number for i in overdue(repo, TODAY)) or "none" + print(f"[{label}] ada owes {total_owed(repo, 'ada') / 100:.2f}; overdue: {late}") + + +def main() -> None: + report("memory", InMemoryInvoices()) + report("sqlite", SqliteInvoices(sqlite3.connect(":memory:"))) + + +if __name__ == "__main__": + main() diff --git a/patterns/modern/repository/examples/invoice_ledger/sqlite_repo.py b/patterns/modern/repository/examples/invoice_ledger/sqlite_repo.py new file mode 100644 index 0000000..d3dd272 --- /dev/null +++ b/patterns/modern/repository/examples/invoice_ledger/sqlite_repo.py @@ -0,0 +1,57 @@ +"""The real adapter: the same three methods over durable storage. + +The domain functions cannot tell this from ``InMemoryInvoices`` — the shared +contract tests in ``tests/test_invoice_ledger.py`` hold both to it. +""" + +from __future__ import annotations + +import sqlite3 +from datetime import date + +from patterns.modern.repository.pattern import Invoice + + +class SqliteInvoices: + """An ``Invoices`` adapter over sqlite3 (stdlib, durable when given a path). + + This demo commits per write so the durability claim is true of the code; + production designs often lift commit into a unit-of-work seam instead + (see the pitfalls in ``docs/implementation.md``). + """ + + def __init__(self, conn: sqlite3.Connection) -> None: + self._conn = conn + self._conn.execute( + "CREATE TABLE IF NOT EXISTS invoices" + " (number TEXT PRIMARY KEY, customer TEXT, amount_cents INT, due TEXT)" + ) + self._conn.commit() + + def add(self, invoice: Invoice) -> None: + try: + self._conn.execute( + "INSERT INTO invoices VALUES (?, ?, ?, ?)", + (invoice.number, invoice.customer, invoice.amount_cents, invoice.due.isoformat()), + ) + except sqlite3.IntegrityError: + raise ValueError(f"invoice {invoice.number!r} already exists") from None + self._conn.commit() + + def for_customer(self, customer: str) -> list[Invoice]: + rows = self._conn.execute( + "SELECT number, customer, amount_cents, due FROM invoices WHERE customer = ?", + (customer,), + ).fetchall() + return [self._to_invoice(row) for row in rows] + + def list_all(self) -> list[Invoice]: + rows = self._conn.execute( + "SELECT number, customer, amount_cents, due FROM invoices" + ).fetchall() + return [self._to_invoice(row) for row in rows] + + @staticmethod + def _to_invoice(row: tuple[str, str, int, str]) -> Invoice: + number, customer, amount_cents, due = row + return Invoice(number, customer, amount_cents, date.fromisoformat(due)) diff --git a/patterns/modern/repository/naive.py b/patterns/modern/repository/naive.py deleted file mode 100644 index f01d053..0000000 --- a/patterns/modern/repository/naive.py +++ /dev/null @@ -1,23 +0,0 @@ -"""Persistence soaked into domain logic: SQL inline, everywhere.""" - -from __future__ import annotations - -import sqlite3 - - -def total_owed(conn: sqlite3.Connection, customer: str) -> int: - """Domain question, welded to storage details.""" - conn.execute("CREATE TABLE IF NOT EXISTS invoices (customer TEXT, amount INT)") - rows = conn.execute("SELECT amount FROM invoices WHERE customer = ?", (customer,)).fetchall() - return sum(amount for (amount,) in rows) - - -def main() -> None: - conn = sqlite3.connect(":memory:") - conn.execute("CREATE TABLE invoices (customer TEXT, amount INT)") - conn.executemany("INSERT INTO invoices VALUES (?, ?)", [("ada", 100), ("ada", 50)]) - print(f"ada owes {total_owed(conn, 'ada')}") - - -if __name__ == "__main__": - main() diff --git a/patterns/modern/repository/pattern/__init__.py b/patterns/modern/repository/pattern/__init__.py new file mode 100644 index 0000000..195ae66 --- /dev/null +++ b/patterns/modern/repository/pattern/__init__.py @@ -0,0 +1,5 @@ +from .ledger import InMemoryInvoices as InMemoryInvoices +from .ledger import Invoice as Invoice +from .ledger import Invoices as Invoices +from .ledger import overdue as overdue +from .ledger import total_owed as total_owed diff --git a/patterns/modern/repository/pattern/ledger.py b/patterns/modern/repository/pattern/ledger.py new file mode 100644 index 0000000..8fd85f3 --- /dev/null +++ b/patterns/modern/repository/pattern/ledger.py @@ -0,0 +1,67 @@ +"""The repository seam: a domain type, a ``Protocol`` port, and the fake. + +``Invoices`` names the collection-like operations the domain needs — three +methods, no more. ``InMemoryInvoices`` lives here rather than in a test +helper because the fake *is* the pattern's payoff: domain tests run against +it instantly, and any real adapter (see the mini-project's sqlite one) must +behave identically or the shared contract tests say so. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date +from typing import Protocol + + +@dataclass(frozen=True) +class Invoice: + """One invoice, as the domain sees it — no storage details.""" + + number: str + customer: str + amount_cents: int + due: date + + +class Invoices(Protocol): + """The port: what the domain may ask of invoice storage. + + Contract (held by the shared tests): ``add`` refuses a duplicate invoice + number with ``ValueError``; ``list_all`` returns insertion order. + """ + + def add(self, invoice: Invoice) -> None: ... + + def for_customer(self, customer: str) -> list[Invoice]: ... + + def list_all(self) -> list[Invoice]: ... + + +class InMemoryInvoices: + """The fake that makes domain tests instant.""" + + def __init__(self) -> None: + self._items: list[Invoice] = [] + + def add(self, invoice: Invoice) -> None: + if any(existing.number == invoice.number for existing in self._items): + raise ValueError(f"invoice {invoice.number!r} already exists") + self._items.append(invoice) + + def for_customer(self, customer: str) -> list[Invoice]: + return [i for i in self._items if i.customer == customer] + + def list_all(self) -> list[Invoice]: + return list(self._items) + + +def total_owed(repo: Invoices, customer: str) -> int: + """Pure domain logic: no storage details anywhere in sight.""" + return sum(invoice.amount_cents for invoice in repo.for_customer(customer)) + + +def overdue(repo: Invoices, today: date, grace_days: int = 0) -> list[Invoice]: + """Every invoice more than ``grace_days`` past due, oldest first.""" + late = [i for i in repo.list_all() if (today - i.due).days > grace_days] + return sorted(late, key=lambda i: i.due) diff --git a/patterns/modern/repository/pythonic.py b/patterns/modern/repository/pythonic.py deleted file mode 100644 index 708117e..0000000 --- a/patterns/modern/repository/pythonic.py +++ /dev/null @@ -1,54 +0,0 @@ -"""The repository seam: a Protocol, a fake, and domain logic that can't tell. - -Tests use InMemoryInvoices; production wires something durable. The domain -function is identical either way. -""" - -from __future__ import annotations - -from dataclasses import dataclass -from typing import Protocol - - -@dataclass(frozen=True) -class Invoice: - customer: str - amount: int - - -class Invoices(Protocol): - """The collection-like operations the domain actually needs.""" - - def add(self, invoice: Invoice) -> None: ... - - def for_customer(self, customer: str) -> list[Invoice]: ... - - -class InMemoryInvoices: - """The fake that makes domain tests instant.""" - - def __init__(self) -> None: - self._items: list[Invoice] = [] - - def add(self, invoice: Invoice) -> None: - self._items.append(invoice) - - def for_customer(self, customer: str) -> list[Invoice]: - return [i for i in self._items if i.customer == customer] - - -def total_owed(repo: Invoices, customer: str) -> int: - """Pure domain logic: no storage details anywhere in sight.""" - return sum(invoice.amount for invoice in repo.for_customer(customer)) - - -def main() -> None: - repo = InMemoryInvoices() - repo.add(Invoice("ada", 100)) - repo.add(Invoice("ada", 50)) - repo.add(Invoice("grace", 9)) - print(f"ada owes {total_owed(repo, 'ada')}") - - -if __name__ == "__main__": - main() diff --git a/patterns/modern/repository/real_world.py b/patterns/modern/repository/real_world.py deleted file mode 100644 index d1ed1d0..0000000 --- a/patterns/modern/repository/real_world.py +++ /dev/null @@ -1,36 +0,0 @@ -"""A sqlite3-backed repository satisfying the same protocol. - -Same domain function, durable storage -- the swap the pattern promises. -""" - -from __future__ import annotations - -import sqlite3 - -from patterns.modern.repository.pythonic import Invoice, total_owed - - -class SqliteInvoices: - def __init__(self, conn: sqlite3.Connection) -> None: - self._conn = conn - self._conn.execute("CREATE TABLE IF NOT EXISTS invoices (customer TEXT, amount INT)") - - def add(self, invoice: Invoice) -> None: - self._conn.execute("INSERT INTO invoices VALUES (?, ?)", (invoice.customer, invoice.amount)) - - def for_customer(self, customer: str) -> list[Invoice]: - rows = self._conn.execute( - "SELECT customer, amount FROM invoices WHERE customer = ?", (customer,) - ).fetchall() - return [Invoice(c, a) for c, a in rows] - - -def main() -> None: - repo = SqliteInvoices(sqlite3.connect(":memory:")) - repo.add(Invoice("ada", 100)) - repo.add(Invoice("ada", 50)) - print(f"ada owes {total_owed(repo, 'ada')} (from sqlite)") - - -if __name__ == "__main__": - main() diff --git a/patterns/modern/repository/tests/__init__.py b/patterns/modern/repository/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/patterns/modern/repository/tests/test_invoice_ledger.py b/patterns/modern/repository/tests/test_invoice_ledger.py new file mode 100644 index 0000000..810489a --- /dev/null +++ b/patterns/modern/repository/tests/test_invoice_ledger.py @@ -0,0 +1,94 @@ +"""The mini-project's point, as tests: ONE contract suite, BOTH adapters. + +Every test here runs against the in-memory fake and the sqlite adapter via +the parametrized fixture — the fake stays honest because the same +assertions hold production storage to the same behavior. +""" + +from __future__ import annotations + +import sqlite3 +from collections.abc import Iterator +from datetime import date +from pathlib import Path + +import pytest + +from patterns.modern.repository.examples.invoice_ledger.main import main +from patterns.modern.repository.examples.invoice_ledger.sqlite_repo import SqliteInvoices +from patterns.modern.repository.pattern import ( + InMemoryInvoices, + Invoice, + Invoices, + overdue, + total_owed, +) + +TODAY = date(2026, 8, 27) + + +@pytest.fixture(params=["memory", "sqlite"]) +def repo(request: pytest.FixtureRequest) -> Iterator[Invoices]: + if request.param == "memory": + yield InMemoryInvoices() + else: + conn = sqlite3.connect(":memory:") + yield SqliteInvoices(conn) + conn.close() + + +class TestRepositoryContract: + """The port's behavior, pinned identically for fake and real adapter.""" + + def test_added_invoices_come_back_whole(self, repo: Invoices) -> None: + inv = Invoice("INV-1", "ada", 120_00, date(2026, 8, 1)) + repo.add(inv) + assert repo.for_customer("ada") == [inv] # round-trip preserves types + + def test_for_customer_filters(self, repo: Invoices) -> None: + repo.add(Invoice("INV-1", "ada", 100, TODAY)) + repo.add(Invoice("INV-2", "grace", 200, TODAY)) + assert [i.number for i in repo.for_customer("grace")] == ["INV-2"] + + def test_list_all_returns_everything_in_insertion_order(self, repo: Invoices) -> None: + repo.add(Invoice("INV-2", "grace", 200, TODAY)) # non-alphabetical on purpose + repo.add(Invoice("INV-1", "ada", 100, TODAY)) + assert [i.number for i in repo.list_all()] == ["INV-2", "INV-1"] + + def test_duplicate_invoice_numbers_are_refused(self, repo: Invoices) -> None: + repo.add(Invoice("INV-1", "ada", 100, TODAY)) + with pytest.raises(ValueError, match="INV-1"): + repo.add(Invoice("INV-1", "grace", 999, TODAY)) + assert len(repo.list_all()) == 1 # the original survives, nothing half-added + + def test_domain_logic_cannot_tell_the_adapters_apart(self, repo: Invoices) -> None: + repo.add(Invoice("INV-1", "ada", 120_00, date(2026, 8, 1))) + repo.add(Invoice("INV-2", "ada", 80_00, date(2026, 9, 15))) + assert total_owed(repo, "ada") == 200_00 + assert [i.number for i in overdue(repo, TODAY)] == ["INV-1"] + + +class TestSqliteDurability: + def test_writes_survive_closing_and_reopening_the_file(self, tmp_path: Path) -> None: + db = tmp_path / "ledger.db" + conn = sqlite3.connect(db) + SqliteInvoices(conn).add(Invoice("INV-1", "ada", 120_00, date(2026, 8, 1))) + conn.close() + + reopened = sqlite3.connect(db) + try: + rows = SqliteInvoices(reopened).list_all() + finally: + reopened.close() + assert [i.number for i in rows] == ["INV-1"] # durable, as the docstring claims + + +class TestDemo: + def test_main_reports_identical_answers_from_both_backends( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + main() + lines = capsys.readouterr().out.strip().splitlines() + assert lines[0].replace("[memory]", "") == lines[1].replace("[sqlite]", "") + assert "ada owes 200.00" in lines[0] + assert "overdue: INV-3, INV-1" in lines[0] diff --git a/patterns/modern/repository/tests/test_ledger.py b/patterns/modern/repository/tests/test_ledger.py new file mode 100644 index 0000000..4089d4c --- /dev/null +++ b/patterns/modern/repository/tests/test_ledger.py @@ -0,0 +1,38 @@ +"""Behavioral tests for the pattern's domain logic, run against the fake.""" + +from __future__ import annotations + +from datetime import date + +from patterns.modern.repository import InMemoryInvoices, Invoice, overdue, total_owed + +TODAY = date(2026, 8, 27) + + +def invoice(number: str, customer: str = "ada", cents: int = 100_00, due: date = TODAY) -> Invoice: + return Invoice(number, customer, cents, due) + + +class TestDomainLogic: + def test_total_owed_sums_one_customer_only(self) -> None: + repo = InMemoryInvoices() + repo.add(invoice("INV-1", "ada", 100_00)) + repo.add(invoice("INV-2", "ada", 50_00)) + repo.add(invoice("INV-3", "grace", 9_00)) + assert total_owed(repo, "ada") == 150_00 + + def test_total_owed_for_an_unknown_customer_is_zero(self) -> None: + assert total_owed(InMemoryInvoices(), "nobody") == 0 + + def test_overdue_respects_grace_and_sorts_oldest_first(self) -> None: + repo = InMemoryInvoices() + repo.add(invoice("INV-1", due=date(2026, 8, 1))) + repo.add(invoice("INV-2", due=date(2026, 7, 1))) + repo.add(invoice("INV-3", due=date(2026, 8, 26))) # 1 day late + late = overdue(repo, TODAY, grace_days=5) + assert [i.number for i in late] == ["INV-2", "INV-1"] + + def test_due_today_is_not_overdue(self) -> None: + repo = InMemoryInvoices() + repo.add(invoice("INV-1", due=TODAY)) + assert overdue(repo, TODAY) == [] diff --git a/patterns/modern/repository/tests/test_repository.py b/patterns/modern/repository/tests/test_repository.py deleted file mode 100644 index 44ac74c..0000000 --- a/patterns/modern/repository/tests/test_repository.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Behavioral tests for all three repository variants.""" - -import sqlite3 - -from patterns.modern.repository import naive, pythonic, real_world -from patterns.modern.repository.pythonic import Invoice - - -class TestNaive: - def test_inline_sql_works_but_needs_a_database(self) -> None: - conn = sqlite3.connect(":memory:") - conn.execute("CREATE TABLE invoices (customer TEXT, amount INT)") - conn.executemany("INSERT INTO invoices VALUES (?, ?)", [("ada", 100), ("ada", 50)]) - assert naive.total_owed(conn, "ada") == 150 - - -class TestPythonic: - def test_domain_logic_runs_on_the_fake(self) -> None: - repo = pythonic.InMemoryInvoices() - repo.add(Invoice("ada", 100)) - repo.add(Invoice("grace", 9)) - assert pythonic.total_owed(repo, "ada") == 100 - - def test_unknown_customer_owes_nothing(self) -> None: - assert pythonic.total_owed(pythonic.InMemoryInvoices(), "nobody") == 0 - - -class TestRealWorld: - def test_same_domain_function_over_sqlite(self) -> None: - repo = real_world.SqliteInvoices(sqlite3.connect(":memory:")) - repo.add(Invoice("ada", 100)) - repo.add(Invoice("ada", 50)) - assert pythonic.total_owed(repo, "ada") == 150 - - def test_the_two_repos_are_interchangeable(self) -> None: - for repo in ( - pythonic.InMemoryInvoices(), - real_world.SqliteInvoices(sqlite3.connect(":memory:")), - ): - repo.add(Invoice("x", 7)) - assert pythonic.total_owed(repo, "x") == 7 diff --git a/patterns/principle/__init__.py b/patterns/principle/__init__.py deleted file mode 100644 index 14f93e4..0000000 --- a/patterns/principle/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Design principles.""" diff --git a/patterns/principle/composition_over_inheritance/README.md b/patterns/principle/composition_over_inheritance/README.md index 8af7d3d..8374771 100644 --- a/patterns/principle/composition_over_inheritance/README.md +++ b/patterns/principle/composition_over_inheritance/README.md @@ -14,31 +14,17 @@ stdlib_sightings: [logging.Logger, logging.Handler, logging.Filter] # Composition Over Inheritance -## Problem - -A logger can filter messages and can write to a file or a socket. With -inheritance, every combination costs a class: `FilteredLogger`, -`SocketLogger`, `FilteredSocketLogger`… M filters × N destinations = M×N -classes. This is the guide's opening case study. - -## Naive solution - -`naive.py` builds exactly that explosion, three classes deep, so you can -watch the combinatorics happen. - -## Pythonic solution - -Split each axis into its own object — filters decide, handlers write — and -*compose* them in one logger. M + N small classes cover all M × N behaviors, -and new combinations are constructor arguments, not new classes. - -## In the wild - -The stdlib `logging` module is this principle shipped at scale: `Logger` -composes `Handler`s, `Filter`s, and `Formatter`s, and no class named -`FilteredRotatingSyslogLogger` needs to exist. - -## Verdict - -**Pythonic** — and the single most load-bearing idea behind the other -patterns in this catalog. +One small piece per axis of variation, composed at a single point: M + N +pieces instead of M × N subclasses. **Verdict: pythonic** — the most +load-bearing idea behind the rest of this catalog. + +| Where | What | +|---|---| +| [`pattern/`](pattern/) | The importable code: `Pipeline` (the composition point), `Filter`/`Transform`/`Sink` axis aliases, and `Logger` built on it | +| [`docs/`](docs/) | [Fundamentals](docs/fundamentals.md) · [Implementation guide](docs/implementation.md) · [External examples](docs/examples.md) | +| [`examples/notification_router/`](examples/notification_router/) | Mini-project: alerts through filter × format × deliver pieces, zero combination subclasses | +| [`tests/`](tests/) | Behavioral tests for the pattern and the mini-project | + +```bash +uv run python -m patterns.principle.composition_over_inheritance.examples.notification_router.main +``` diff --git a/patterns/principle/composition_over_inheritance/__init__.py b/patterns/principle/composition_over_inheritance/__init__.py index 6732fce..5ca3f20 100644 --- a/patterns/principle/composition_over_inheritance/__init__.py +++ b/patterns/principle/composition_over_inheritance/__init__.py @@ -1 +1,6 @@ -"""Composition over inheritance: objects per axis, not classes per combination.""" +from .pattern import Filter as Filter +from .pattern import Logger as Logger +from .pattern import Pipeline as Pipeline +from .pattern import Sink as Sink +from .pattern import Transform as Transform +from .pattern import identity as identity diff --git a/patterns/principle/composition_over_inheritance/docs/examples.md b/patterns/principle/composition_over_inheritance/docs/examples.md new file mode 100644 index 0000000..360383c --- /dev/null +++ b/patterns/principle/composition_over_inheritance/docs/examples.md @@ -0,0 +1,31 @@ +# Composition Over Inheritance — where it lives outside this repo + +Cited, real implementations to study (or point an agent at) when designing or +reviewing class hierarchies. + +## Python standard library + +- **`logging`.** The guide's own worked example, shipped at scale: `Logger` + composes `Handler`s, `Filter`s, and `Formatter`s — three orthogonal axes, + no class per combination. + [docs.python.org/3/library/logging.html](https://docs.python.org/3/library/logging.html) +- **`socketserver`.** The honest contrast: the stdlib's mixin dodge + (`ThreadingMixIn` + `TCPServer` = `ThreadingTCPServer`), useful to study + as the alternative the principle warns will reconverge on the diamond. + [docs.python.org/3/library/socketserver.html](https://docs.python.org/3/library/socketserver.html) + +## Major ecosystems + +- **pytest's plugin architecture.** Behavior is added by composing plugins + registered with hook functions — not by subclassing the test runner. + [docs.pytest.org/en/stable/how-to/writing_plugins.html](https://docs.pytest.org/en/stable/how-to/writing_plugins.html) +- **The guide chapter** — the subclass-explosion case study and the taxonomy + of dodges (multiple inheritance, mixins, dynamically built classes). + [python-patterns.guide/gang-of-four/composition-over-inheritance](https://python-patterns.guide/gang-of-four/composition-over-inheritance/) + +## What to notice across all of them + +In every healthy example the *combination* is expressed at runtime — a +constructor call, a registration — while the *pieces* stay single-purpose. +When reviewing, count adjectives in class names: two or more is the explosion +starting. diff --git a/patterns/principle/composition_over_inheritance/docs/fundamentals.md b/patterns/principle/composition_over_inheritance/docs/fundamentals.md new file mode 100644 index 0000000..2e54e6e --- /dev/null +++ b/patterns/principle/composition_over_inheritance/docs/fundamentals.md @@ -0,0 +1,74 @@ +# Composition Over Inheritance — fundamentals + +## Intent + +Vary independent behaviors without one subclass per combination of them. +"Favor object composition over class inheritance" is the second principle in +the GoF introduction — the soil the structural and behavioral patterns grow +from. Each independent axis of variation becomes its own small object, +injected where needed; M + N pieces cover M × N behaviors. Source chapter: +[python-patterns.guide/gang-of-four/composition-over-inheritance](https://python-patterns.guide/gang-of-four/composition-over-inheritance/). + +## Participants + +| Role | What it is | +|---|---| +| Axes of variation | The independent behaviors (what to accept, how to shape, where to send) | +| One small piece per axis | A callable or tiny class per behavior — `Filter`/`Transform`/`Sink` in [`pattern/compose.py`](../pattern/compose.py) | +| The composition point | The one class that holds a piece per axis and wires them — `Pipeline` in [`pattern/compose.py`](../pattern/compose.py); `Logger` and the example's `Notifier` are `Pipeline` put to work | +| Clients | Pick pieces and construct; a new combination is a constructor call | + +## Mechanism + +1. Name the axes. If a class name wants two adjectives + (`FilteredSocketLogger`), there are at least two. +2. Give each axis its own minimal interface — in Python usually just a + callable signature. +3. Write one composition-point class holding one piece per axis; its methods + delegate in a fixed, readable order. +4. Combinations are now data: constructed, passed, and tested — never + subclassed into existence. + +## The subclass explosion, and what composition replaces + +The classic route bolts each behavior on by subclassing — and then needs a +class *per combination*: + +```python +class Logger: ... # base + + +class FilteredLogger(Logger): ... # axis 1 bolted on + + +class UppercaseLogger(Logger): ... # axis 2 bolted on + + +class FilteredUppercaseLogger(FilteredLogger): # the explosion begins: + """One class PER COMBINATION.""" # M x N x P classes coming +``` + +Two axes already cost four classes; each new filter or destination +*multiplies* the count. The dodges — multiple inheritance, mixins, +dynamically built classes — postpone the explosion rather than end it. The +composed form keeps one class per *piece* plus one composition point, and +the arithmetic turns from multiplication into addition. + +## When to use it + +- Two or more behaviors vary independently (filtering × formatting × + destination; retry × serialization × transport). +- Subclass names are growing adjectives, or a mixin diamond is forming. + +## When not to use it + +- One axis, two variants, stable → a single `if` or a subclass is honest and + smaller; composition machinery would be ceremony. +- The "axes" are not independent (one behavior's output feeds another's + contract) → model the coupling explicitly; forced composition hides it. + +## Verdict: pythonic + +The single most load-bearing idea behind the rest of this catalog — the +stdlib's `logging` (Logger/Handler/Filter/Formatter) is this principle +shipped at scale, with no `FilteredRotatingSyslogLogger` in sight. diff --git a/patterns/principle/composition_over_inheritance/docs/implementation.md b/patterns/principle/composition_over_inheritance/docs/implementation.md new file mode 100644 index 0000000..75d1984 --- /dev/null +++ b/patterns/principle/composition_over_inheritance/docs/implementation.md @@ -0,0 +1,74 @@ +# Composition Over Inheritance — putting it into a system + +## The smell it fixes + +Class names collecting adjectives, and a new requirement meaning a new +subclass: + +```python +class JsonFileNotifier(FileNotifier): ... + + +class DedupJsonFileNotifier(JsonFileNotifier): ... + + +class DedupJsonWebhookNotifier(...): ... # and severity thresholds arrive Monday +``` + +## Steps + +1. **List the axes.** Read the subclass names: each adjective is an axis + (dedup / json / webhook = decide / reshape / act). +2. **Define one minimal interface per axis.** In Python a callable signature + is usually enough — `Filter[T] = Callable[[T], bool]`, + `Transform[T, U] = Callable[[T], U]`, `Sink[T] = Callable[[T], None]` + (importable from this unit's `pattern/`). +3. **Extract each behavior into a piece.** Plain functions for stateless + pieces; a small callable class where state is real (`Dedup`); a closure + factory where only a parameter varies (`min_severity(4)`). +4. **Write the composition point** — one dataclass with one field per axis + and the delegation order spelled out in one method. +5. **Delete the subclass tree.** Every leaf class becomes a constructor call; + its tests become tests of a *combination*, which now read as configuration. + +```python +from patterns.principle.composition_over_inheritance import Pipeline + +# Notifier = Pipeline[Alert, str] — the domain names the composition point. +pager = Notifier(filters=(min_severity(4), Dedup()), transform=as_json, sink=webhook) +``` + +## Python idioms that keep it small + +- **Callables are the interfaces.** No ABCs needed until an axis has multiple + methods; `Protocol` when it does. +- **Closure factories replace parameter subclasses**: `min_severity(4)` is + the whole class `SeverityAtLeastFourFilter`. +- **`functools.partial`** turns any configurable function into a piece. +- **Dataclasses as composition points** make the wiring visible in `repr` + and trivially testable. + +## Pitfalls + +- **Rebuilding inheritance inside composition** — a piece that reaches into + the composition point (or another piece) recreates the coupling with extra + steps. Pieces see only their own input. +- **The god-piece.** One "filter" that also formats and delivers has eaten + the axes; if a piece needs two verbs to describe, split it. +- **The mixin dodge.** `class DedupMixin: ...` feels cheaper today and + reconverges on the diamond tomorrow — the guide's taxonomy of dodges is + worth rereading when tempted. +- **Order left implicit.** The composition point owns the delegation order; + document and test it (filters run in order and short-circuit — a stateful + filter placed after a veto never records vetoed items). + +## Worked example + +[`examples/notification_router/`](../examples/notification_router/) routes +alerts through filter × format × deliver pieces — console plain-text at +severity ≥ 2, deduped JSON to a webhook at severity ≥ 4 — one `Notifier` +class, zero combination subclasses. Run it: + +```bash +uv run python -m patterns.principle.composition_over_inheritance.examples.notification_router.main +``` diff --git a/patterns/principle/composition_over_inheritance/examples/notification_router/axes.py b/patterns/principle/composition_over_inheritance/examples/notification_router/axes.py new file mode 100644 index 0000000..c3d4721 --- /dev/null +++ b/patterns/principle/composition_over_inheritance/examples/notification_router/axes.py @@ -0,0 +1,81 @@ +"""One small piece per axis of variation: filter x format x deliver. + +Three axes, a few pieces each. Composed, they cover every combination the +inheritance route would need a class for — no ``DedupJsonWebhookNotifier`` +anywhere. +""" + +from __future__ import annotations + +import json + +from patterns.principle.composition_over_inheritance.examples.notification_router.models import ( + Alert, +) +from patterns.principle.composition_over_inheritance.pattern import Filter + +# --- axis 1: filters (decide) ------------------------------------------------ + + +def min_severity(threshold: int) -> Filter[Alert]: + """Parameterization instead of a subclass per threshold.""" + + def accepts(alert: Alert) -> bool: + return alert.severity >= threshold + + return accepts + + +class Dedup: + """A stateful filter: each (source, message) passes once.""" + + def __init__(self) -> None: + self._seen: set[tuple[str, str]] = set() + + def __call__(self, alert: Alert) -> bool: + key = (alert.source, alert.message) + if key in self._seen: + return False + self._seen.add(key) + return True + + +# --- axis 2: formats (reshape) ----------------------------------------------- + + +def plain_text(alert: Alert) -> str: + return f"[{alert.severity}] {alert.source}: {alert.message}" + + +def as_json(alert: Alert) -> str: + return json.dumps( + {"source": alert.source, "severity": alert.severity, "message": alert.message} + ) + + +# --- axis 3: deliveries (act) ------------------------------------------------ + + +def console(line: str) -> None: + print(line) + + +class MemorySink: + """Delivery for tests and demos: keeps what it was handed.""" + + def __init__(self) -> None: + self.lines: list[str] = [] + + def __call__(self, line: str) -> None: + self.lines.append(line) + + +class FakeWebhook: + """Stands in for an HTTP POST; records payloads instead of sending.""" + + def __init__(self, url: str) -> None: + self.url = url + self.posted: list[str] = [] + + def __call__(self, payload: str) -> None: + self.posted.append(payload) diff --git a/patterns/principle/composition_over_inheritance/examples/notification_router/main.py b/patterns/principle/composition_over_inheritance/examples/notification_router/main.py new file mode 100644 index 0000000..af7d2e2 --- /dev/null +++ b/patterns/principle/composition_over_inheritance/examples/notification_router/main.py @@ -0,0 +1,42 @@ +"""Demo: two very different notifiers from one class and a handful of pieces.""" + +from __future__ import annotations + +from patterns.principle.composition_over_inheritance.examples.notification_router.axes import ( + Dedup, + FakeWebhook, + as_json, + console, + min_severity, + plain_text, +) +from patterns.principle.composition_over_inheritance.examples.notification_router.models import ( + Alert, +) +from patterns.principle.composition_over_inheritance.examples.notification_router.router import ( + Notifier, + Router, +) + + +def main() -> None: + webhook = FakeWebhook("https://pager.example/hook") + router = Router( + notifiers=( + Notifier(filters=(min_severity(2),), transform=plain_text, sink=console), + Notifier(filters=(min_severity(4), Dedup()), transform=as_json, sink=webhook), + ) + ) + alerts = [ + Alert("api", 2, "latency rising"), + Alert("db", 5, "primary down"), + Alert("db", 5, "primary down"), # duplicate: webhook dedups, console repeats + Alert("cron", 1, "nightly job finished"), + ] + for alert in alerts: + router.broadcast(alert) + print(f"webhook received {len(webhook.posted)} page(s): {webhook.posted[0]}") + + +if __name__ == "__main__": + main() diff --git a/patterns/principle/composition_over_inheritance/examples/notification_router/models.py b/patterns/principle/composition_over_inheritance/examples/notification_router/models.py new file mode 100644 index 0000000..de4b4d7 --- /dev/null +++ b/patterns/principle/composition_over_inheritance/examples/notification_router/models.py @@ -0,0 +1,14 @@ +"""Domain types for the notification-router mini-project.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Alert: + """Something worth telling someone about. Severity: 1 (info) .. 5 (page).""" + + source: str + severity: int + message: str diff --git a/patterns/principle/composition_over_inheritance/examples/notification_router/router.py b/patterns/principle/composition_over_inheritance/examples/notification_router/router.py new file mode 100644 index 0000000..44e3f90 --- /dev/null +++ b/patterns/principle/composition_over_inheritance/examples/notification_router/router.py @@ -0,0 +1,24 @@ +"""The composition point, instantiated for the notification domain.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from patterns.principle.composition_over_inheritance.examples.notification_router.models import ( + Alert, +) +from patterns.principle.composition_over_inheritance.pattern import Pipeline + +#: One class, ever — the pattern's ``Pipeline`` bound to the alert domain. +#: A new behavior combination is a constructor call, not a subclass. +Notifier = Pipeline[Alert, str] + + +@dataclass +class Router: + """Fan-out: every notifier sees every alert, each applies its own policy.""" + + notifiers: tuple[Notifier, ...] + + def broadcast(self, alert: Alert) -> int: + return sum(1 for notifier in self.notifiers if notifier.process(alert)) diff --git a/patterns/principle/composition_over_inheritance/naive.py b/patterns/principle/composition_over_inheritance/naive.py deleted file mode 100644 index 3c7a6ad..0000000 --- a/patterns/principle/composition_over_inheritance/naive.py +++ /dev/null @@ -1,53 +0,0 @@ -"""The subclass explosion, reproduced faithfully. - -Two independent axes (filtering, destination) already cost four classes; -each new filter or destination multiplies, not adds. -""" - -from __future__ import annotations - - -class Logger: - def __init__(self, sink: list[str]) -> None: - self.sink = sink - - def log(self, message: str) -> None: - self.sink.append(message) - - -class FilteredLogger(Logger): - """Axis 1 bolted on by subclassing.""" - - def __init__(self, pattern: str, sink: list[str]) -> None: - super().__init__(sink) - self.pattern = pattern - - def log(self, message: str) -> None: - if self.pattern in message: - super().log(message) - - -class UppercaseLogger(Logger): - """Axis 2 bolted on by subclassing.""" - - def log(self, message: str) -> None: - super().log(message.upper()) - - -class FilteredUppercaseLogger(FilteredLogger): - """And here is the explosion: one class PER COMBINATION.""" - - def log(self, message: str) -> None: - if self.pattern in message: - self.sink.append(message.upper()) - - -def main() -> None: - sink: list[str] = [] - FilteredUppercaseLogger("error", sink).log("error: disk full") - FilteredUppercaseLogger("error", sink).log("all fine") - print(sink) - - -if __name__ == "__main__": - main() diff --git a/patterns/principle/composition_over_inheritance/pattern/__init__.py b/patterns/principle/composition_over_inheritance/pattern/__init__.py new file mode 100644 index 0000000..ac4632b --- /dev/null +++ b/patterns/principle/composition_over_inheritance/pattern/__init__.py @@ -0,0 +1,6 @@ +from .compose import Filter as Filter +from .compose import Logger as Logger +from .compose import Pipeline as Pipeline +from .compose import Sink as Sink +from .compose import Transform as Transform +from .compose import identity as identity diff --git a/patterns/principle/composition_over_inheritance/pattern/compose.py b/patterns/principle/composition_over_inheritance/pattern/compose.py new file mode 100644 index 0000000..ef13ce4 --- /dev/null +++ b/patterns/principle/composition_over_inheritance/pattern/compose.py @@ -0,0 +1,69 @@ +"""Composition Over Inheritance as importable, typed building blocks. + +The principle's vocabulary: each independent axis of variation is a small +callable — something that decides (``Filter``), something that reshapes +(``Transform``), something that acts (``Sink``) — and ``Pipeline`` is the +one composition point that wires a piece per axis together. ``Logger`` is +the guide's own worked shape, built *on* ``Pipeline`` rather than beside +it: M filters + N transforms cover M x N behaviors with M + N pieces. +""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass, field +from typing import Generic, TypeVar + +T = TypeVar("T") +U = TypeVar("U") +In = TypeVar("In") +Out = TypeVar("Out") + +#: One alias per axis kind — the principle's whole type system. +Filter = Callable[[T], bool] +Transform = Callable[[T], U] +Sink = Callable[[T], None] + + +def identity(message: str) -> str: + """The do-nothing Transform: a composed axis can opt out explicitly.""" + return message + + +@dataclass +class Pipeline(Generic[In, Out]): + """The composition point: one piece per axis, wired once, no subclasses. + + Filters run in declaration order and short-circuit (``all``). That order + is behavior when a filter keeps state: put cheap vetoes before + state-recording filters, or the recorder remembers items that were never + delivered. + """ + + filters: tuple[Filter[In], ...] + transform: Transform[In, Out] + sink: Sink[Out] + + def process(self, item: In) -> bool: + """Deliver if every filter accepts; report whether delivery happened.""" + if not all(accepts(item) for accepts in self.filters): + return False + self.sink(self.transform(item)) + return True + + +@dataclass +class Logger: + """The guide's worked shape, composed from ``Pipeline``. + + The sink axis here is "append to my lines": ``sink`` stays a plain + ``list[str]`` so callers and tests read results directly, and the + ``Sink`` handed to the underlying ``Pipeline`` is ``sink.append``. + """ + + sink: list[str] = field(default_factory=list) + filters: tuple[Filter[str], ...] = () + transform: Transform[str, str] = identity + + def log(self, message: str) -> None: + Pipeline(self.filters, self.transform, self.sink.append).process(message) diff --git a/patterns/principle/composition_over_inheritance/pythonic.py b/patterns/principle/composition_over_inheritance/pythonic.py deleted file mode 100644 index 2c21476..0000000 --- a/patterns/principle/composition_over_inheritance/pythonic.py +++ /dev/null @@ -1,45 +0,0 @@ -"""Composition: one small object per axis, combined at runtime. - -M filters + N transforms cover M x N behaviors with M + N classes; a new -combination is a constructor call, not a new class. -""" - -from __future__ import annotations - -from collections.abc import Callable -from dataclasses import dataclass, field - -Filter = Callable[[str], bool] -Transform = Callable[[str], str] - - -def contains(pattern: str) -> Filter: - return lambda message: pattern in message - - -def identity(message: str) -> str: - return message - - -@dataclass -class Logger: - """One logger class, ever. Behavior comes from what you compose into it.""" - - sink: list[str] = field(default_factory=list) - filters: tuple[Filter, ...] = () - transform: Transform = identity - - def log(self, message: str) -> None: - if all(f(message) for f in self.filters): - self.sink.append(self.transform(message)) - - -def main() -> None: - loud_errors = Logger(filters=(contains("error"),), transform=str.upper) - loud_errors.log("error: disk full") - loud_errors.log("all fine") - print(loud_errors.sink) - - -if __name__ == "__main__": - main() diff --git a/patterns/principle/composition_over_inheritance/real_world.py b/patterns/principle/composition_over_inheritance/real_world.py deleted file mode 100644 index 7ae4289..0000000 --- a/patterns/principle/composition_over_inheritance/real_world.py +++ /dev/null @@ -1,37 +0,0 @@ -"""The ``logging`` module: composition at industrial scale. - -A Logger composes Handlers and Filters; nobody subclasses per combination. -""" - -from __future__ import annotations - -import logging - - -def build_error_logger(name: str, sink: list[str]) -> logging.Logger: - """Compose: a list-writing handler + a substring filter, one stock Logger.""" - - class ListHandler(logging.Handler): - def emit(self, record: logging.LogRecord) -> None: - sink.append(record.getMessage()) - - logger = logging.getLogger(name) - logger.handlers.clear() - logger.propagate = False - logger.setLevel(logging.INFO) - handler = ListHandler() - handler.addFilter(lambda record: "error" in record.getMessage()) - logger.addHandler(handler) - return logger - - -def main() -> None: - sink: list[str] = [] - logger = build_error_logger("demo", sink) - logger.info("error: disk full") - logger.info("all fine") - print(sink) - - -if __name__ == "__main__": - main() diff --git a/patterns/principle/composition_over_inheritance/tests/__init__.py b/patterns/principle/composition_over_inheritance/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/patterns/principle/composition_over_inheritance/tests/test_compose.py b/patterns/principle/composition_over_inheritance/tests/test_compose.py new file mode 100644 index 0000000..6490438 --- /dev/null +++ b/patterns/principle/composition_over_inheritance/tests/test_compose.py @@ -0,0 +1,82 @@ +"""Behavioral tests for the pattern's Pipeline core and the composed Logger.""" + +from __future__ import annotations + +from patterns.principle.composition_over_inheritance import ( + Filter, + Logger, + Pipeline, + identity, +) + + +def contains(pattern: str) -> Filter[str]: + def accepts(message: str) -> bool: + return pattern in message + + return accepts + + +class Recording: + """A stateful filter that remembers everything it was asked about.""" + + def __init__(self, verdict: bool = True) -> None: + self.verdict = verdict + self.asked: list[str] = [] + + def __call__(self, message: str) -> bool: + self.asked.append(message) + return self.verdict + + +class TestPipeline: + def test_delivers_the_transformed_item_when_all_filters_accept(self) -> None: + out: list[str] = [] + pipe: Pipeline[str, str] = Pipeline( + filters=(contains("error"),), transform=str.upper, sink=out.append + ) + assert pipe.process("error: disk full") is True + assert out == ["ERROR: DISK FULL"] + + def test_one_rejecting_filter_blocks_delivery(self) -> None: + out: list[str] = [] + pipe: Pipeline[str, str] = Pipeline( + filters=(contains("error"), contains("disk")), transform=identity, sink=out.append + ) + assert pipe.process("error: bad password") is False + assert out == [] + + def test_filters_short_circuit_in_declaration_order(self) -> None: + # Order is behavior: a stateful filter placed after a veto must never + # even be consulted for the vetoed item. + recorder = Recording() + pipe: Pipeline[str, str] = Pipeline( + filters=(contains("error"), recorder), transform=identity, sink=lambda _line: None + ) + pipe.process("all fine") + assert recorder.asked == [] # vetoed before the recorder saw it + pipe.process("error: disk full") + assert recorder.asked == ["error: disk full"] + + def test_identity_is_a_real_transform(self) -> None: + assert identity("unchanged") == "unchanged" + + +class TestComposedLogger: + def test_behavior_comes_from_the_composed_pieces(self) -> None: + loud_errors = Logger(filters=(contains("error"),), transform=str.upper) + loud_errors.log("error: disk full") + loud_errors.log("all fine") + assert loud_errors.sink == ["ERROR: DISK FULL"] + + def test_a_new_combination_is_a_constructor_call_not_a_class(self) -> None: + quiet = Logger(filters=(contains("error"), contains("disk"))) + quiet.log("error: disk full") + quiet.log("error: bad password") # fails the second filter + assert quiet.sink == ["error: disk full"] + assert type(quiet) is Logger # same one class covers every combination + + def test_no_filters_means_everything_passes(self) -> None: + logger = Logger() + logger.log("anything") + assert logger.sink == ["anything"] # default transform is identity diff --git a/patterns/principle/composition_over_inheritance/tests/test_composition_over_inheritance.py b/patterns/principle/composition_over_inheritance/tests/test_composition_over_inheritance.py deleted file mode 100644 index 5681935..0000000 --- a/patterns/principle/composition_over_inheritance/tests/test_composition_over_inheritance.py +++ /dev/null @@ -1,45 +0,0 @@ -"""Behavioral tests for the composition-over-inheritance unit.""" - -from patterns.principle.composition_over_inheritance import naive, pythonic, real_world - - -class TestNaive: - def test_combination_class_works_but_had_to_exist(self) -> None: - sink: list[str] = [] - logger = naive.FilteredUppercaseLogger("error", sink) - logger.log("error: disk full") - logger.log("all fine") - assert sink == ["ERROR: DISK FULL"] - - def test_the_explosion_is_real(self) -> None: - # Four classes for two axes -- the M x N cost, in the flesh. - assert issubclass(naive.FilteredUppercaseLogger, naive.FilteredLogger) - assert issubclass(naive.FilteredLogger, naive.Logger) - - -class TestPythonic: - def test_composed_behavior_matches_the_combination_class(self) -> None: - logger = pythonic.Logger(filters=(pythonic.contains("error"),), transform=str.upper) - logger.log("error: disk full") - logger.log("all fine") - assert logger.sink == ["ERROR: DISK FULL"] - - def test_new_combination_is_a_constructor_call(self) -> None: - plain = pythonic.Logger(filters=(pythonic.contains("warn"),)) - plain.log("warn: low disk") - plain.log("error: ignored here") - assert plain.sink == ["warn: low disk"] - - def test_no_filters_means_log_everything(self) -> None: - logger = pythonic.Logger() - logger.log("anything") - assert logger.sink == ["anything"] - - -class TestRealWorld: - def test_stdlib_logging_composes_filter_and_handler(self) -> None: - sink: list[str] = [] - logger = real_world.build_error_logger("pdp-test", sink) - logger.info("error: disk full") - logger.info("all fine") - assert sink == ["error: disk full"] diff --git a/patterns/principle/composition_over_inheritance/tests/test_notification_router.py b/patterns/principle/composition_over_inheritance/tests/test_notification_router.py new file mode 100644 index 0000000..28d6dc1 --- /dev/null +++ b/patterns/principle/composition_over_inheritance/tests/test_notification_router.py @@ -0,0 +1,132 @@ +"""Behavioral tests for the notification-router mini-project.""" + +from __future__ import annotations + +import json +from collections.abc import Callable + +import pytest + +from patterns.principle.composition_over_inheritance.examples.notification_router.axes import ( + Dedup, + FakeWebhook, + MemorySink, + as_json, + console, + min_severity, + plain_text, +) +from patterns.principle.composition_over_inheritance.examples.notification_router.main import ( + main, +) +from patterns.principle.composition_over_inheritance.examples.notification_router.models import ( + Alert, +) +from patterns.principle.composition_over_inheritance.examples.notification_router.router import ( + Notifier, + Router, +) +from patterns.principle.composition_over_inheritance.pattern import Filter + + +def alert(severity: int, message: str = "m", source: str = "api") -> Alert: + return Alert(source, severity, message) + + +class TestAxes: + def test_min_severity_parameterizes_instead_of_subclassing(self) -> None: + assert min_severity(3)(alert(3)) is True + assert min_severity(3)(alert(2)) is False + + def test_dedup_passes_each_alert_once(self) -> None: + dedup = Dedup() + assert dedup(alert(5, "primary down", "db")) is True + assert dedup(alert(5, "primary down", "db")) is False + assert dedup(alert(5, "replica down", "db")) is True # different message + + +SAMPLE = Alert("db", 5, "primary down") + +#: Expected payload per format for SAMPLE — content, not just counts. +EXPECTED = { + "plain_text": "[5] db: primary down", + "as_json": json.dumps({"source": "db", "severity": 5, "message": "primary down"}), +} + + +class TestEveryCombination: + """The headline claim, demonstrated: 2 filters x 2 formats x 3 sinks = 12 + behaviors from 7 small pieces and zero subclasses.""" + + @pytest.mark.parametrize("filter_name", ["min_severity", "dedup"]) + @pytest.mark.parametrize("format_name", ["plain_text", "as_json"]) + @pytest.mark.parametrize("sink_name", ["memory", "webhook", "console"]) + def test_the_full_cross_product_delivers_the_right_content( + self, + filter_name: str, + format_name: str, + sink_name: str, + capsys: pytest.CaptureFixture[str], + ) -> None: + chosen_filter: Filter[Alert] = min_severity(4) if filter_name == "min_severity" else Dedup() + transform = {"plain_text": plain_text, "as_json": as_json}[format_name] + + sink: Callable[[str], None] + delivered: Callable[[], str] + if sink_name == "memory": + memory = MemorySink() + sink, delivered = memory, lambda: memory.lines[0] + elif sink_name == "webhook": + webhook = FakeWebhook("https://pager.example/hook") + sink, delivered = webhook, lambda: webhook.posted[0] + else: + sink, delivered = console, lambda: capsys.readouterr().out.rstrip("\n") + + notifier = Notifier(filters=(chosen_filter,), transform=transform, sink=sink) + assert notifier.process(SAMPLE) is True + assert delivered() == EXPECTED[format_name] + + +class TestComposition: + def test_two_behaviors_one_class_zero_subclasses(self) -> None: + chatty, pager = MemorySink(), MemorySink() + router = Router( + notifiers=( + Notifier(filters=(min_severity(2),), transform=plain_text, sink=chatty), + Notifier(filters=(min_severity(4), Dedup()), transform=as_json, sink=pager), + ) + ) + router.broadcast(alert(2, "latency rising")) + router.broadcast(alert(5, "primary down", "db")) + router.broadcast(alert(5, "primary down", "db")) # duplicate + assert chatty.lines == [ + "[2] api: latency rising", + "[5] db: primary down", + "[5] db: primary down", # console repeats; that is its policy + ] + assert pager.lines == [EXPECTED["as_json"]] # webhook policy dedups + assert type(router.notifiers[0]) is type(router.notifiers[1]) # one class + + def test_filter_order_is_policy_dedup_must_not_see_vetoed_alerts(self) -> None: + # min_severity runs before Dedup, so a sub-threshold alert must not + # poison the dedup memory: the later legitimate page still goes out. + pager = MemorySink() + notifier = Notifier(filters=(min_severity(4), Dedup()), transform=plain_text, sink=pager) + assert notifier.process(alert(1, "primary down", "db")) is False # vetoed + assert notifier.process(alert(5, "primary down", "db")) is True # delivered + assert pager.lines == ["[5] db: primary down"] + + def test_process_reports_whether_delivery_happened(self) -> None: + sink = MemorySink() + notifier = Notifier(filters=(min_severity(4),), transform=plain_text, sink=sink) + assert notifier.process(alert(5)) is True + assert notifier.process(alert(1)) is False + assert sink.lines == ["[5] api: m"] + + +class TestDemo: + def test_main_dedups_the_duplicate_page(self, capsys: pytest.CaptureFixture[str]) -> None: + main() + out = capsys.readouterr().out + assert "webhook received 1 page(s)" in out + assert "[5] db: primary down" in out # console saw it (twice, its policy) diff --git a/patterns/python/__init__.py b/patterns/python/__init__.py deleted file mode 100644 index 00d09a4..0000000 --- a/patterns/python/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Python-native patterns from python-patterns.guide.""" diff --git a/patterns/python/global_object/README.md b/patterns/python/global_object/README.md index 796b215..3d72c4e 100644 --- a/patterns/python/global_object/README.md +++ b/patterns/python/global_object/README.md @@ -14,32 +14,17 @@ stdlib_sightings: [os.environ, calendar.day_name, math.pi] # Global Object -## Problem - -Many parts of a program need the same value — a constant table, a compiled -regex, a configured client. Passing it through every call chain is noise; -building it repeatedly is waste. - -## Naive solution - -`naive.py` shows the two classic misuses: hidden *mutable* module state that -couples callers together, and import-time I/O that makes `import` slow, -fragile, and untestable. - -## Pythonic solution - -`pythonic.py` shows the pattern done well: immutable constants computed at -import time (cheap, deterministic), a pre-built global object whose -construction is pure, and lazy initialization for anything expensive — -so importing the module never costs more than defining functions. - -## In the wild - -`math.pi` is the Constant Pattern; `calendar.day_name` is an import-time -computed global object; `os.environ` is the rare *documented* mutable global, -mutation being its entire purpose. - -## Verdict - -**Use with care.** Constants and immutable pre-built objects: freely. Mutable -globals: only when shared mutation is the feature, not an accident. +Share a constant or a pre-built object program-wide by assigning it at module +level — Python's native singleton. **Verdict: use with care** — constants +freely, expensive things lazily, mutation only where it is the documented job. + +| Where | What | +|---|---| +| [`pattern/`](pattern/) | The importable code: `Lazy` (deferred construction + test-reset seam) | +| [`docs/`](docs/) | [Fundamentals](docs/fundamentals.md) · [Implementation guide](docs/implementation.md) · [External examples](docs/examples.md) | +| [`examples/settings_module/`](examples/settings_module/) | Mini-project: an app settings module with all three kinds of global | +| [`tests/`](tests/) | Behavioral tests for the pattern and the mini-project | + +```bash +uv run python -m patterns.python.global_object.examples.settings_module.main +``` diff --git a/patterns/python/global_object/__init__.py b/patterns/python/global_object/__init__.py index acbaef7..16746fc 100644 --- a/patterns/python/global_object/__init__.py +++ b/patterns/python/global_object/__init__.py @@ -1 +1 @@ -"""Global Object: module-level constants and shared instances.""" +from .pattern import Lazy as Lazy diff --git a/patterns/python/global_object/docs/examples.md b/patterns/python/global_object/docs/examples.md new file mode 100644 index 0000000..1c82075 --- /dev/null +++ b/patterns/python/global_object/docs/examples.md @@ -0,0 +1,34 @@ +# Global Object — where it lives outside this repo + +Cited, real implementations to study (or point an agent at) when designing or +reviewing module-global code. + +## Python standard library + +- **`math.pi`, `calendar.day_name`.** The Constant Pattern and the prebuilt + global object: computed at import, immutable in practice, shared by every + importer. [docs.python.org/3/library/calendar.html](https://docs.python.org/3/library/calendar.html) +- **`os.environ`.** The rare *documented* mutable global — mutation is its + entire job, which is exactly the bar a mutable module global must clear. + [docs.python.org/3/library/os.html#os.environ](https://docs.python.org/3/library/os.html#os.environ) +- **`logging.root` and the module-logger convention.** `logger = + logging.getLogger(__name__)` at module top is a per-module global object + built through a cached factory. + [docs.python.org/3/library/logging.html](https://docs.python.org/3/library/logging.html) + +## Major ecosystems + +- **`django.conf.settings`.** A lazy global object: importing it is free, the + wrapped settings module materializes on first attribute access — the same + deferral this unit's `Lazy` provides. + [docs.djangoproject.com/en/stable/topics/settings/](https://docs.djangoproject.com/en/stable/topics/settings/) +- **The guide chapter.** The import-time-I/O prohibition, the dunder-constant + conventions, and the Constant/Global Object distinction this unit encodes. + [python-patterns.guide/python/module-globals](https://python-patterns.guide/python/module-globals/) + +## What to notice across all of them + +Every healthy global is either immutable, lazily built, or mutable *by +documented design* — there is no fourth kind in the wild. When reviewing, +classify each module global into one of the three; whatever resists +classification is the bug. diff --git a/patterns/python/global_object/docs/fundamentals.md b/patterns/python/global_object/docs/fundamentals.md new file mode 100644 index 0000000..719827f --- /dev/null +++ b/patterns/python/global_object/docs/fundamentals.md @@ -0,0 +1,75 @@ +# Global Object — fundamentals + +## Intent + +Give a whole program shared access to a constant or a pre-built object by +assigning it once at module level. A Python module is created once and cached +in `sys.modules`, so a module-level name **is** the language's native shared +instance — this pattern is what Singleton actually wants to be in Python. +Source chapter: [python-patterns.guide/python/module-globals](https://python-patterns.guide/python/module-globals/). + +## Participants + +| Role | What it is | +|---|---| +| The module | The shared namespace, constructed exactly once per process | +| Constants | Immutable values named at module level (`RETRY_LIMIT = 3`) | +| Prebuilt objects | Cheap, pure import-time construction (a compiled regex, a parsed table) | +| The lazy global | Expensive construction deferred to first use — `Lazy` in [`pattern/lazy.py`](../pattern/lazy.py) | +| Importers | Every consumer; they share the one instance by importing the name | + +## Mechanism + +1. Truly constant values are assigned at module level and never mutated. +2. Objects that are cheap and *pure* to build (no I/O, deterministic) may be + built at import time. +3. Anything expensive — file parses, network, big tables — goes behind a lazy + accessor: importing costs nothing, the first `get()` pays once, and + `reset()` restores test order-independence. +4. Mutable module globals are reserved for objects whose mutation is their + documented job (`os.environ`), never an accident of convenience. + +## The classic misuses, and the discipline that replaces them + +The pattern is defined as much by what it forbids as what it allows. The two +classic misuses: + +```python +# Misuse 1: hidden mutable state — every caller is coupled to every other. +_counts: dict[str, int] = {} + + +def tally(word: str) -> int: + _counts[word] = _counts.get(word, 0) + 1 # tests now order-dependent + return _counts[word] + + +# Misuse 2: work at import time — every importer pays, before anyone asks. +CATALOG = json.load(open("catalog.json")) # I/O on import: slow, fragile +``` + +The first couples strangers through invisible state and makes test order +matter. The second makes `import` slow, order-dependent, and untestable — the +guide's hardest rule is **never do I/O at import time**. The discipline: +constants freely, pure-and-cheap prebuilt objects freely, everything else +lazily, mutation only where mutation is the documented contract. + +## When to use it + +- A value many modules need, whose identity should be shared (config, a + compiled regex, a lookup table, a client object). +- You are about to write a Singleton class — a module global is the same + guarantee without the ceremony. + +## When not to use it + +- The value differs per request/test/tenant → pass it explicitly (see + `modern/dependency_injection`). +- Callers need to mutate it and mutation is not the object's documented + purpose → the coupling will outlive whoever wrote it. + +## Verdict: use with care + +Constants and immutable prebuilt objects: freely. Expensive things: behind +`Lazy`. Mutable globals: only when shared mutation is the feature — and the +docs say so. diff --git a/patterns/python/global_object/docs/implementation.md b/patterns/python/global_object/docs/implementation.md new file mode 100644 index 0000000..25862b5 --- /dev/null +++ b/patterns/python/global_object/docs/implementation.md @@ -0,0 +1,69 @@ +# Global Object — putting it into a system + +## The smell it fixes + +The same value rebuilt everywhere, or threaded through every call chain as +noise: + +```python +def handler(request, config, slug_re, zone_table): # everyone carries the bags + ... +def other_handler(request): + zone_table = load_zone_table() # ... or rebuilds them +``` + +## Steps + +1. **Sort your globals into the three kinds.** Constant? Cheap-and-pure + prebuilt? Expensive? The kind decides the treatment; nothing else does. +2. **Assign constants and cheap prebuilt objects at module level.** Name them + in CAPS; make them immutable types (`frozenset`, `tuple`, compiled regex) + so mutation is impossible rather than discouraged. +3. **Wrap every expensive construction in `Lazy`.** + `TABLE: Lazy[dict[str, int]] = Lazy(_build_table)` — importing stays free, + the first `TABLE.get()` pays once. +4. **Give tests the reset seam.** A fixture calling `TABLE.reset()` restores + order-independence; a counter in the factory lets a test *prove* import + does no work (see the worked example's `FACTORY_RUNS`). +5. **Audit for the two misuses.** Anything mutable at module level needs a + sentence of justification; any import-time I/O is a bug, full stop. + +```python +from patterns.python.global_object import Lazy + +PRICES: Lazy[dict[str, int]] = Lazy(_load_prices) # import: free +PRICES.get()["basic"] # first use: built once +``` + +## Python idioms that keep it small + +- `frozenset`/`tuple`/`re.compile` make constants self-enforcing. +- The module *is* the singleton — resist wrapping globals in a class whose + only job is to hold them. +- A `_private` name plus a public accessor (`get_settings()`) is the escape + hatch when construction later needs parameters; `Lazy` gives you that + accessor for free. + +## Pitfalls + +- **Import-time I/O** — the cardinal sin: every importer pays, test runs + touch disk/network, and import order starts to matter. +- **The convenience mutable global.** `CACHE: dict = {}` at module level + couples every caller; if shared mutation is not the documented job, inject + the object instead. +- **Lazy globals that capture config.** If the factory reads other globals, + a test that tweaks config after first use sees stale state — reset in the + fixture, or pass config explicitly. +- **Hidden identity assumptions.** Two modules importing the name share one + object; if a consumer mutates a "constant" list, everyone sees it. Immutable + types close the hole. + +## Worked example + +[`examples/settings_module/`](../examples/settings_module/) ships one global +of each kind — constant, prebuilt regex, lazy zone table — and a test that +proves import does no work. Run it: + +```bash +uv run python -m patterns.python.global_object.examples.settings_module.main +``` diff --git a/patterns/python/global_object/examples/settings_module/main.py b/patterns/python/global_object/examples/settings_module/main.py new file mode 100644 index 0000000..ba6c051 --- /dev/null +++ b/patterns/python/global_object/examples/settings_module/main.py @@ -0,0 +1,22 @@ +"""Demo: the three kinds of global, and when each one pays its cost.""" + +from __future__ import annotations + +from patterns.python.global_object.examples.settings_module import settings +from patterns.python.global_object.examples.settings_module.shipping import ( + is_valid_slug, + shipping_zone, +) + + +def main() -> None: + print(f"constant: RETRY_LIMIT = {settings.RETRY_LIMIT}") + print(f"prebuilt regex: is_valid_slug('summer-sale') = {is_valid_slug('summer-sale')}") + print(f"lazy built at import? {settings.ZONE_TABLE.initialized}") + print(f"shipping_zone('FR'): {shipping_zone('FR')}") + print(f"lazy built after use? {settings.ZONE_TABLE.initialized}") + print(f"factory ran: {settings.FACTORY_RUNS} time(s)") + + +if __name__ == "__main__": + main() diff --git a/patterns/python/global_object/examples/settings_module/settings.py b/patterns/python/global_object/examples/settings_module/settings.py new file mode 100644 index 0000000..70bff71 --- /dev/null +++ b/patterns/python/global_object/examples/settings_module/settings.py @@ -0,0 +1,35 @@ +"""The application's shared globals, one of each legitimate kind. + +Constants and a cheap prebuilt object are assigned at import time; the one +expensive resource hides behind ``Lazy`` so importing this module never does +more work than defining names. ``FACTORY_RUNS`` exists so tests can *prove* +that discipline instead of trusting it. +""" + +from __future__ import annotations + +import re + +from patterns.python.global_object.pattern import Lazy + +#: The Constant Pattern: immutable, named, computed once. +RETRY_LIMIT = 3 +SUPPORTED_LOCALES = frozenset({"en", "fr", "de"}) + +#: A cheap, pure prebuilt object — a compiled regex is fine at import time. +SLUG = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*") + +#: Counts factory runs; tests assert it stays 0 until first use, then 1. +FACTORY_RUNS = 0 + + +def _build_zone_table() -> dict[str, int]: + """Stand-in for the expensive load (file parse, DNS, warehouse query).""" + global FACTORY_RUNS + FACTORY_RUNS += 1 + zones = {"CA": 1, "US": 1, "MX": 2, "FR": 3, "DE": 3} + return {country: zone for country, zone in sorted(zones.items())} + + +#: The expensive global: constructed on first ``ZONE_TABLE.get()``, never at import. +ZONE_TABLE: Lazy[dict[str, int]] = Lazy(_build_zone_table) diff --git a/patterns/python/global_object/examples/settings_module/shipping.py b/patterns/python/global_object/examples/settings_module/shipping.py new file mode 100644 index 0000000..63429f9 --- /dev/null +++ b/patterns/python/global_object/examples/settings_module/shipping.py @@ -0,0 +1,18 @@ +"""A consumer module: uses the shared globals, never rebuilds them.""" + +from __future__ import annotations + +from patterns.python.global_object.examples.settings_module import settings + + +def is_valid_slug(candidate: str) -> bool: + return settings.SLUG.fullmatch(candidate) is not None + + +def shipping_zone(country: str) -> int: + """Zone lookup pays the table's construction cost on the first call only.""" + table = settings.ZONE_TABLE.get() + try: + return table[country] + except KeyError: + raise ValueError(f"no shipping zone for {country!r}") from None diff --git a/patterns/python/global_object/naive.py b/patterns/python/global_object/naive.py deleted file mode 100644 index 579dbd2..0000000 --- a/patterns/python/global_object/naive.py +++ /dev/null @@ -1,33 +0,0 @@ -"""The two classic misuses of module globals. - -1. Hidden mutable state: every caller of ``tally`` is coupled to every other. -2. Import-time I/O (simulated): importing becomes slow, order-dependent, and - untestable. Real code that does ``open()``/network at module level fails - in exactly the ways this pretends to. -""" - -from __future__ import annotations - -# Misuse 1: a mutable global that functions quietly share. -_counts: dict[str, int] = {} - - -def tally(word: str) -> int: - """Two callers who have never met now share state through _counts.""" - _counts[word] = _counts.get(word, 0) + 1 - return _counts[word] - - -# Misuse 2: work at import time. Here it is only a computation standing in -# for the real sin (reading files, opening sockets) -- but note that it runs -# before any caller has asked for anything. -IMPORT_TIME_WORK: list[int] = [n * n for n in range(1000)] - - -def main() -> None: - print(f"tally('a') twice: {tally('a')}, {tally('a')}") - print(f"import already paid for {len(IMPORT_TIME_WORK)} squares nobody asked for") - - -if __name__ == "__main__": - main() diff --git a/patterns/python/global_object/pattern/__init__.py b/patterns/python/global_object/pattern/__init__.py new file mode 100644 index 0000000..8fd5d1e --- /dev/null +++ b/patterns/python/global_object/pattern/__init__.py @@ -0,0 +1 @@ +from .lazy import Lazy as Lazy diff --git a/patterns/python/global_object/pattern/lazy.py b/patterns/python/global_object/pattern/lazy.py new file mode 100644 index 0000000..68ce510 --- /dev/null +++ b/patterns/python/global_object/pattern/lazy.py @@ -0,0 +1,54 @@ +"""The Global Object pattern's one reusable tool: deferred construction. + +Constants and cheap prebuilt objects need no machinery — assign them at +module level and stop. What the pattern *does* need code for is the expensive +global: ``Lazy`` defers construction to first use, keeps ``import`` free of +I/O, and gives tests a reset seam. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Generic, TypeVar, cast + +T = TypeVar("T") + +_UNSET = object() # a sentinel (see python/sentinel_object): None may be a value + + +class Lazy(Generic[T]): + """A module-global built on first ``get()``, not at import time. + + Why not ``functools.cache`` on the factory? Two reasons this class earns + its ~15 lines: the ``_UNSET`` sentinel means a factory that legitimately + returns ``None`` is still cached exactly once (an ``is None`` check would + rebuild it forever), and ``reset()``/``initialized`` give tests the seam + and the proof that the import stayed cheap. + + Not thread-safe: two threads racing the first ``get()`` may both run the + factory. Fine for the import-time-globals use this pattern serves; wrap + ``get()`` in a lock if a threaded first touch is real for you. + + >>> table = Lazy(load_expensive_table) # import: nothing happens + >>> table.get() # first use: built once + >>> table.reset() # tests: order-independence back + """ + + def __init__(self, factory: Callable[[], T]) -> None: + self._factory = factory + self._value: object = _UNSET + + @property + def initialized(self) -> bool: + """Whether the factory has run — importable proof of a cheap import.""" + return self._value is not _UNSET + + def get(self) -> T: + """Return the value, constructing it on the first call only.""" + if self._value is _UNSET: + self._value = self._factory() + return cast("T", self._value) + + def reset(self) -> None: + """Discard the value so the next ``get()`` rebuilds — the test seam.""" + self._value = _UNSET diff --git a/patterns/python/global_object/pythonic.py b/patterns/python/global_object/pythonic.py deleted file mode 100644 index a030ae6..0000000 --- a/patterns/python/global_object/pythonic.py +++ /dev/null @@ -1,44 +0,0 @@ -"""The Global Object pattern done well. - -Constants, cheap deterministic import-time computation, and lazy -initialization for anything expensive. Importing this module does no I/O -and mutates nothing observable. -""" - -from __future__ import annotations - -import re - -#: The Constant Pattern: immutable, named, computed once. -MONTHS_PER_YEAR = 12 -VOWELS = frozenset("aeiou") - -#: Import-time computation is fine when it is cheap and pure: -#: a compiled regex is the guide's own example of a good global object. -IDENTIFIER = re.compile(r"[A-Za-z_][A-Za-z0-9_]*") - - -def count_vowels(text: str) -> int: - return sum(1 for ch in text if ch in VOWELS) - - -# Lazy initialization: pay for expensive construction on first use, not import. -_big_table: dict[int, int] | None = None - - -def big_table() -> dict[int, int]: - global _big_table - if _big_table is None: - _big_table = {n: n * n for n in range(10_000)} - return _big_table - - -def main() -> None: - print(f"constant: {MONTHS_PER_YEAR}") - print(f"regex global: {bool(IDENTIFIER.fullmatch('valid_name'))}") - print(f"vowels in text: {count_vowels('global object')}") - print(f"lazy table size: {len(big_table())}") - - -if __name__ == "__main__": - main() diff --git a/patterns/python/global_object/real_world.py b/patterns/python/global_object/real_world.py deleted file mode 100644 index 7293ec1..0000000 --- a/patterns/python/global_object/real_world.py +++ /dev/null @@ -1,39 +0,0 @@ -"""Global objects the stdlib ships. - -``math.pi``: the Constant Pattern. ``calendar.day_name``: an import-time -built global object. ``os.environ``: the rare mutable global whose mutation -is its documented job. -""" - -from __future__ import annotations - -import calendar -import math -import os - - -def midweek_day() -> str: - return str(calendar.day_name[2]) - - -def circle_area(radius: float) -> float: - return math.pi * radius**2 - - -def with_temp_env(key: str, value: str) -> str: - """os.environ is mutable by design; clean up what you touch.""" - os.environ[key] = value - try: - return os.environ[key] - finally: - del os.environ[key] - - -def main() -> None: - print(f"constant pattern: math.pi = {math.pi}") - print(f"global object: day_name[2] = {midweek_day()}") - print(f"mutable global: {with_temp_env('DEMO_KEY', 'demo')}") - - -if __name__ == "__main__": - main() diff --git a/patterns/python/global_object/tests/__init__.py b/patterns/python/global_object/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/patterns/python/global_object/tests/test_global_object.py b/patterns/python/global_object/tests/test_global_object.py deleted file mode 100644 index a94d31a..0000000 --- a/patterns/python/global_object/tests/test_global_object.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Behavioral tests for all three global-object variants.""" - -import math -import os - -from patterns.python.global_object import naive, pythonic, real_world - - -class TestNaive: - def test_mutable_global_couples_callers(self) -> None: - naive._counts.clear() - naive.tally("x") - # A "different caller" is affected by the first one's state: - assert naive.tally("x") == 2 - - def test_import_time_work_already_happened(self) -> None: - assert len(naive.IMPORT_TIME_WORK) == 1000 - - -class TestPythonic: - def test_constants_are_immutable_types(self) -> None: - assert isinstance(pythonic.VOWELS, frozenset) - assert pythonic.count_vowels("aeiou xyz") == 5 - - def test_compiled_regex_global(self) -> None: - assert pythonic.IDENTIFIER.fullmatch("valid_name") - assert not pythonic.IDENTIFIER.fullmatch("1bad") - - def test_lazy_table_builds_once(self) -> None: - assert pythonic.big_table() is pythonic.big_table() - assert pythonic.big_table()[99] == 9801 - - -class TestRealWorld: - def test_stdlib_globals(self) -> None: - assert real_world.midweek_day() == "Wednesday" - assert real_world.circle_area(1.0) == math.pi - - def test_environ_mutation_cleans_up(self) -> None: - assert real_world.with_temp_env("PDP_TEST_KEY", "v") == "v" - assert "PDP_TEST_KEY" not in os.environ diff --git a/patterns/python/global_object/tests/test_lazy.py b/patterns/python/global_object/tests/test_lazy.py new file mode 100644 index 0000000..e2f8a26 --- /dev/null +++ b/patterns/python/global_object/tests/test_lazy.py @@ -0,0 +1,50 @@ +"""Behavioral tests for the pattern's Lazy global.""" + +from __future__ import annotations + +from patterns.python.global_object import Lazy + + +class TestLazy: + def test_construction_does_not_run_the_factory(self) -> None: + runs: list[int] = [] + + def factory() -> str: + runs.append(1) + return "value" + + lazy = Lazy(factory) + assert runs == [] + assert lazy.initialized is False + + def test_first_get_builds_exactly_once(self) -> None: + runs: list[int] = [] + + def factory() -> str: + runs.append(1) + return "value" + + lazy = Lazy(factory) + assert lazy.get() == "value" + assert lazy.get() == "value" + assert runs == [1] + assert lazy.initialized is True + + def test_reset_forces_a_rebuild(self) -> None: + counter = iter(range(100)) + lazy = Lazy(lambda: next(counter)) + assert lazy.get() == 0 + lazy.reset() + assert lazy.initialized is False + assert lazy.get() == 1 + + def test_none_is_a_legitimate_lazy_value(self) -> None: + runs: list[int] = [] + + def factory() -> None: + runs.append(1) + + lazy: Lazy[None] = Lazy(factory) + assert lazy.get() is None + assert lazy.get() is None + assert runs == [1] # a stored None is not mistaken for "unbuilt" diff --git a/patterns/python/global_object/tests/test_settings_module.py b/patterns/python/global_object/tests/test_settings_module.py new file mode 100644 index 0000000..0f7f1b2 --- /dev/null +++ b/patterns/python/global_object/tests/test_settings_module.py @@ -0,0 +1,90 @@ +"""Behavioral tests for the settings-module mini-project. + +The load-bearing assertion: importing the settings module does no expensive +work — the zone table's factory runs zero times until first use, once ever. +""" + +from __future__ import annotations + +import subprocess +import sys +from collections.abc import Iterator +from pathlib import Path + +import pytest + +from patterns.python.global_object.examples.settings_module import settings +from patterns.python.global_object.examples.settings_module.main import main +from patterns.python.global_object.examples.settings_module.shipping import ( + is_valid_slug, + shipping_zone, +) + + +@pytest.fixture(autouse=True) +def fresh_lazy_state() -> Iterator[None]: + """The pattern's test seam: order-independence via reset().""" + settings.ZONE_TABLE.reset() + settings.FACTORY_RUNS = 0 + yield + settings.ZONE_TABLE.reset() + settings.FACTORY_RUNS = 0 + + +REPO_ROOT = Path(__file__).resolve().parents[4] + + +class TestImportStaysCheap: + def test_import_did_not_build_the_expensive_table(self) -> None: + # A fresh subprocess is the only honest witness: in-process, this + # module is already imported and the autouse reset fixture would have + # erased the evidence either way. Here nothing can reset before the + # assertion reads the counter. + probe = ( + "from patterns.python.global_object.examples.settings_module import settings; " + "assert settings.FACTORY_RUNS == 0, settings.FACTORY_RUNS; " + "assert settings.ZONE_TABLE.initialized is False" + ) + result = subprocess.run( + [sys.executable, "-c", probe], + cwd=REPO_ROOT, + capture_output=True, + text=True, + timeout=30, + check=False, + ) + assert result.returncode == 0, result.stderr + + def test_first_use_builds_once_and_only_once(self) -> None: + assert shipping_zone("CA") == 1 + assert shipping_zone("DE") == 3 + assert settings.FACTORY_RUNS == 1 + + +class TestSettingsBehavior: + def test_constants_are_immutable_types(self) -> None: + assert isinstance(settings.SUPPORTED_LOCALES, frozenset) + assert settings.RETRY_LIMIT == 3 + + def test_prebuilt_regex_validates_slugs(self) -> None: + assert is_valid_slug("summer-sale") + assert not is_valid_slug("Summer Sale!") + # Fails past position 0: fullmatch must reject what match would accept. + assert not is_valid_slug("summer sale!") + + def test_unknown_country_is_a_domain_error(self) -> None: + with pytest.raises(ValueError, match="no shipping zone"): + shipping_zone("ZZ") + + def test_consumers_share_one_table_instance(self) -> None: + assert settings.ZONE_TABLE.get() is settings.ZONE_TABLE.get() + + +class TestDemo: + def test_demo_tells_the_lazy_story(self, capsys: pytest.CaptureFixture[str]) -> None: + main() + out = capsys.readouterr().out + assert "lazy built at import? False" in out + assert "shipping_zone('FR'): 3" in out + assert "lazy built after use? True" in out + assert "factory ran: 1 time(s)" in out diff --git a/patterns/python/prebound_method/README.md b/patterns/python/prebound_method/README.md index e007341..2c518b6 100644 --- a/patterns/python/prebound_method/README.md +++ b/patterns/python/prebound_method/README.md @@ -9,36 +9,22 @@ verdict: pythonic caveats: - "Build the hidden instance cheaply and without I/O — it is constructed at import time." - "Keep the class public too, so users needing isolated state can instantiate their own (exactly as random.Random allows)." -stdlib_sightings: [random.random, random.seed, secrets.token_hex] +stdlib_sightings: [random.random, random.seed, secrets.choice] --- # Prebound Method -## Problem +A `random.random()`-style module API: one hidden instance built at import, +its bound methods published as module functions, the class kept public for +isolation. **Verdict: pythonic** — the stdlib's own favorite move. -You want the ergonomic module-level API — `random.random()`, not -`random.get_default_generator().random()` — but the functions must share -state (a seed, a counter, a connection). +| Where | What | +|---|---| +| [`pattern/`](pattern/) | The importable code: canonical `Counter` + prebound `increment`/`peek`, and `shares_instance` (proves the wiring) | +| [`docs/`](docs/) | [Fundamentals](docs/fundamentals.md) · [Implementation guide](docs/implementation.md) · [External examples](docs/examples.md) | +| [`examples/metrics/`](examples/metrics/) | Mini-project: process-wide metrics API prebound from a hidden collector | +| [`tests/`](tests/) | Behavioral tests for the pattern and the mini-project | -## Naive solution - -`naive.py` shows the alternatives the guide rejects: bare module functions -mutating a loose module global (state and behavior drift apart), or making -every caller instantiate the class themselves (ergonomics lost). - -## Pythonic solution - -Define a normal class, build **one instance** at module level, then assign -its bound methods to module-global names: `roll = _instance.roll`. Callers -get plain functions; the instance travels along inside each bound method. - -## In the wild - -`random.random`, `random.seed`, and friends are exactly this — bound methods -of a hidden `random.Random()` built at import; `random.Random` stays public -for anyone needing isolated streams. - -## Verdict - -**Pythonic.** The stdlib's own favorite way to put a friendly face on shared -state. +```bash +uv run python -m patterns.python.prebound_method.examples.metrics.main +``` diff --git a/patterns/python/prebound_method/__init__.py b/patterns/python/prebound_method/__init__.py index d8c039d..1ec8f07 100644 --- a/patterns/python/prebound_method/__init__.py +++ b/patterns/python/prebound_method/__init__.py @@ -1 +1,4 @@ -"""Prebound Method: module functions that are bound methods of one hidden instance.""" +from .pattern import Counter as Counter +from .pattern import increment as increment +from .pattern import peek as peek +from .pattern import shares_instance as shares_instance diff --git a/patterns/python/prebound_method/docs/examples.md b/patterns/python/prebound_method/docs/examples.md new file mode 100644 index 0000000..fed2a8a --- /dev/null +++ b/patterns/python/prebound_method/docs/examples.md @@ -0,0 +1,34 @@ +# Prebound Method — where it lives outside this repo + +Cited, real implementations to study (or point an agent at) when designing or +reviewing module-API code. + +## Python standard library + +- **`random`.** The flagship: `random.random`, `random.seed`, and friends are + bound methods of one hidden `Random()` built at import, and `random.Random` + stays public for isolated streams. + [docs.python.org/3/library/random.html](https://docs.python.org/3/library/random.html) · + [source](https://github.com/python/cpython/blob/main/Lib/random.py) +- **`secrets`.** `choice` and `randbits` are prebound from a module-level + `SystemRandom` instance — the same wiring with a different engine. (The + useful contrast: `token_hex`/`token_bytes` are plain module functions that + merely *call* it — they have no `__self__` and would fail this unit's own + `shares_instance()` check.) + [docs.python.org/3/library/secrets.html](https://docs.python.org/3/library/secrets.html) +- **`calendar`.** Module functions like `calendar.month` delegate to a + module-level `TextCalendar` instance. + [source](https://github.com/python/cpython/blob/main/Lib/calendar.py) + +## The chapter + +- The naming conventions, the import-time-cost warning, and the + keep-the-class-public rule this unit encodes: + [python-patterns.guide/python/prebound-methods](https://python-patterns.guide/python/prebound-methods/) + +## What to notice across all of them + +Every stdlib example keeps **both doors open**: the convenient module +functions for the common case, the public class for isolation. When reviewing +a module-level API over shared state, check for the second door — its absence +is tomorrow's monkeypatch. diff --git a/patterns/python/prebound_method/docs/fundamentals.md b/patterns/python/prebound_method/docs/fundamentals.md new file mode 100644 index 0000000..354b46c --- /dev/null +++ b/patterns/python/prebound_method/docs/fundamentals.md @@ -0,0 +1,75 @@ +# Prebound Method — fundamentals + +## Intent + +Offer module-level functions that share state, by building **one instance** at +module import and binding its methods to module-global names. Callers get the +ergonomic API — `random.random()`, not +`random.get_default_generator().random()` — while the shared state rides along +inside each bound method. Source chapter: +[python-patterns.guide/python/prebound-methods](https://python-patterns.guide/python/prebound-methods/). + +## Participants + +| Role | What it is | +|---|---| +| The public class | An ordinary class holding the shared state (`Counter`, `random.Random`) — public so isolation stays possible | +| The hidden instance | One module-private instance, built at import (`_instance = Counter()`) | +| The prebound names | Module globals assigned from bound methods (`increment = _instance.increment`) | +| Callers | Import and call plain functions, never seeing the instance | + +## Mechanism + +1. Write the class as if the pattern did not exist: state in `__init__`, + behavior in methods, fully testable in isolation. +2. At module level, build one instance — cheaply and without I/O, because + this line runs at import time. +3. Assign the instance's bound methods to module-global names. A bound method + carries its `__self__`, so every call reaches the same state. +4. Leave the class public. Anyone needing an isolated copy instantiates it — + exactly as `random.Random` stays available beside `random.random`. + +## The alternatives it replaces + +The two shapes the guide rejects, side by side: + +```python +# Alternative A: bare functions over a loose module global — the state and +# the functions guarding it drift apart, and a second counter later means +# rewriting every caller. +_count = 0 + + +def increment() -> int: + global _count + _count += 1 + return _count + + +# Alternative B: no module API at all — every caller, everywhere, forever: +counter = Counter() +counter.increment() +``` + +The prebound form keeps A's ergonomics and B's design: the state lives in a +real class; only the *default instance* is module-level. Migrating to +per-caller instances later is a class already shipped, not a rewrite. + +## When to use it + +- A module-level convenience API over genuinely shared state: metrics, + default RNG, a default registry, a process-wide clock. +- You are tempted by Alternative A — this is the same ergonomics without + orphaned state. + +## When not to use it + +- Construction is expensive or does I/O → it would run at import; use a lazy + accessor instead (see `python/global_object`). +- The state should not be shared by default (per-request, per-tenant) → + instantiate explicitly or inject (see `modern/dependency_injection`). + +## Verdict: pythonic + +The stdlib's own favorite way to put a friendly face on shared state — +`random`, `secrets`, and `calendar` all ship it. diff --git a/patterns/python/prebound_method/docs/implementation.md b/patterns/python/prebound_method/docs/implementation.md new file mode 100644 index 0000000..1382ce7 --- /dev/null +++ b/patterns/python/prebound_method/docs/implementation.md @@ -0,0 +1,69 @@ +# Prebound Method — putting it into a system + +## The smell it fixes + +Module functions guarding a loose global, or a "helper instance" every caller +must construct and thread: + +```python +_registry: dict[str, Handler] = {} # state ... + + +def register(name, handler): ... # ... and its functions, drifting apart +``` + +## Steps + +1. **Write the class first.** State in `__init__`, behavior in methods, its + own unit tests. If the class is not worth testing alone, the pattern is + overkill — keep plain functions. +2. **Build one module-private instance** (`_collector = MetricsCollector()`). + The construction runs at import: it must be cheap, pure, and I/O-free. +3. **Prebind the public surface**, one line per name: + + ```python + increment = _collector.increment + timing = _collector.timing + snapshot = _collector.snapshot + ``` + + Bind only what callers need — the instance's full surface stays behind + the underscore. +4. **Keep the class exported.** The isolation escape hatch is half the + pattern; tests and libraries build their own instance instead of fighting + the shared one. +5. **Give tests a seam.** Either prebind a `reset()` (as the metrics example + does) or have fixtures instantiate a fresh class — never let tests depend + on the shared instance's accumulated state. + +## Python idioms that keep it small + +- A bound method is just an object: assignment is the whole mechanism, no + wrapper functions, no `functools`. +- `shares_instance(f, g)` (in this unit's `pattern/`) proves the wiring in a + test: every prebound name carries the same `__self__`. +- Docstring the *module*, not each prebound name — the class's docstrings + already travel with the bound methods. + +## Pitfalls + +- **Import-time construction that grows up.** The instance starts cheap; a + refactor adds a config read and suddenly every import does I/O. Guard it + with a test, or switch to `Lazy` from `python/global_object`. +- **Hiding the class.** Making `Counter` private forces monkeypatching where + instantiation would have done — the escape hatch is load-bearing. +- **Shared state leaking across tests.** The prebound API is process-global + by design; tests that use it must reset it (or use their own instance). +- **Prebinding mutable attributes** instead of methods — attribute access + copies the reference once; later rebinding on the instance is invisible to + importers. + +## Worked example + +[`examples/metrics/`](../examples/metrics/) ships a process-wide metrics API +(`increment`/`timing`/`snapshot`/`reset`) prebound from a hidden +`MetricsCollector`, with the class public for isolated collectors. Run it: + +```bash +uv run python -m patterns.python.prebound_method.examples.metrics.main +``` diff --git a/patterns/python/prebound_method/examples/metrics/api.py b/patterns/python/prebound_method/examples/metrics/api.py new file mode 100644 index 0000000..1979c32 --- /dev/null +++ b/patterns/python/prebound_method/examples/metrics/api.py @@ -0,0 +1,19 @@ +"""The pattern applied: a ``random``-style module API over shared state. + +One hidden collector is built at import time (cheap: two empty dicts); its +bound methods become the module's public functions. Callers write +``metrics.increment("orders")`` — no instance in sight — while the instance +rides along inside each bound method. +""" + +from __future__ import annotations + +from patterns.python.prebound_method.examples.metrics.collector import MetricsCollector + +_collector = MetricsCollector() + +#: The pattern: module-level names bound to the hidden instance's methods. +increment = _collector.increment +timing = _collector.timing +snapshot = _collector.snapshot +reset = _collector.reset diff --git a/patterns/python/prebound_method/examples/metrics/collector.py b/patterns/python/prebound_method/examples/metrics/collector.py new file mode 100644 index 0000000..3337957 --- /dev/null +++ b/patterns/python/prebound_method/examples/metrics/collector.py @@ -0,0 +1,36 @@ +"""The public class behind the metrics module's friendly face. + +Stays public on purpose: libraries and tests build their own isolated +collector, exactly as ``random.Random`` stays public beside ``random.random``. +""" + +from __future__ import annotations + + +class MetricsCollector: + """Counts and timings for one scope (the process, or one test).""" + + def __init__(self) -> None: + self._counts: dict[str, int] = {} + self._timings: dict[str, list[float]] = {} + + def increment(self, name: str, by: int = 1) -> int: + """Bump a counter; returns its new value.""" + self._counts[name] = self._counts.get(name, 0) + by + return self._counts[name] + + def timing(self, name: str, milliseconds: float) -> None: + """Record one duration observation.""" + self._timings.setdefault(name, []).append(milliseconds) + + def snapshot(self) -> dict[str, object]: + """An immutable-ish view: counters plus per-name timing averages.""" + averages = { + name: sum(values) / len(values) for name, values in self._timings.items() if values + } + return {"counts": dict(self._counts), "timing_avg_ms": averages} + + def reset(self) -> None: + """Forget everything — the seam tests use between cases.""" + self._counts.clear() + self._timings.clear() diff --git a/patterns/python/prebound_method/examples/metrics/main.py b/patterns/python/prebound_method/examples/metrics/main.py new file mode 100644 index 0000000..5eb323a --- /dev/null +++ b/patterns/python/prebound_method/examples/metrics/main.py @@ -0,0 +1,28 @@ +"""Demo: modules that never met, reporting into one hidden collector.""" + +from __future__ import annotations + +from patterns.python.prebound_method.examples.metrics import api +from patterns.python.prebound_method.pattern import shares_instance + + +def take_order(order_id: str) -> None: + api.increment("orders") + api.timing("checkout_ms", 12.5 if order_id.endswith("2") else 8.0) + + +def failed_payment() -> None: + api.increment("payment_errors") + + +def main() -> None: + api.reset() + for order_id in ("o-1", "o-2", "o-3"): + take_order(order_id) + failed_payment() + print(f"one hidden instance: {shares_instance(api.increment, api.timing, api.snapshot)}") + print(f"snapshot: {api.snapshot()}") + + +if __name__ == "__main__": + main() diff --git a/patterns/python/prebound_method/naive.py b/patterns/python/prebound_method/naive.py deleted file mode 100644 index 7ec58e4..0000000 --- a/patterns/python/prebound_method/naive.py +++ /dev/null @@ -1,39 +0,0 @@ -"""The alternatives the guide rejects. - -Option A: bare functions over a loose module global -- state and the -functions that guard it are separated, and moving to two independent -counters later means rewriting every caller. - -Option B: no module-level API at all -- every caller instantiates. -""" - -from __future__ import annotations - -# Option A: the state is just ... lying there. -_count = 0 - - -def increment() -> int: - global _count - _count += 1 - return _count - - -# Option B: callers must build and thread their own instance. -class Counter: - def __init__(self) -> None: - self.count = 0 - - def increment(self) -> int: - self.count += 1 - return self.count - - -def main() -> None: - print(f"loose global: {increment()}, {increment()}") - counter = Counter() # every caller, everywhere, forever - print(f"DIY instance: {counter.increment()}") - - -if __name__ == "__main__": - main() diff --git a/patterns/python/prebound_method/pattern/__init__.py b/patterns/python/prebound_method/pattern/__init__.py new file mode 100644 index 0000000..d6f901b --- /dev/null +++ b/patterns/python/prebound_method/pattern/__init__.py @@ -0,0 +1,4 @@ +from .prebound import Counter as Counter +from .prebound import increment as increment +from .prebound import peek as peek +from .prebound import shares_instance as shares_instance diff --git a/patterns/python/prebound_method/pattern/prebound.py b/patterns/python/prebound_method/pattern/prebound.py new file mode 100644 index 0000000..fb9f3fe --- /dev/null +++ b/patterns/python/prebound_method/pattern/prebound.py @@ -0,0 +1,43 @@ +"""The Prebound Method pattern as importable, typed building blocks. + +The pattern itself is one line — ``name = _instance.method`` at module level — +so the library code here is the canonical minimal instance plus the one +verification helper worth sharing: ``shares_instance`` proves that a set of +module functions really are bound methods of a single hidden object (the +check ``random.random`` and ``random.seed`` would pass). +""" + +from __future__ import annotations + +from collections.abc import Callable + + +class Counter: + """The canonical shape: an ordinary class, instantiable for isolation.""" + + def __init__(self) -> None: + self.count = 0 + + def increment(self) -> int: + self.count += 1 + return self.count + + def peek(self) -> int: + return self.count + + +#: The pattern, applied to itself: one hidden instance built at import +#: (cheaply, no I/O), its bound methods published as module functions. +_instance = Counter() +increment = _instance.increment +peek = _instance.peek + + +def shares_instance(*functions: Callable[..., object]) -> bool: + """True if every function is a bound method of one identical instance. + + The introspective proof of the pattern: ``shares_instance(random.random, + random.seed)`` holds because both carry the same ``__self__``. + """ + owners = [getattr(function, "__self__", None) for function in functions] + return len(owners) > 0 and owners[0] is not None and all(o is owners[0] for o in owners) diff --git a/patterns/python/prebound_method/pythonic.py b/patterns/python/prebound_method/pythonic.py deleted file mode 100644 index c99ea67..0000000 --- a/patterns/python/prebound_method/pythonic.py +++ /dev/null @@ -1,38 +0,0 @@ -"""The Prebound Method pattern. - -One hidden instance built at import time; its bound methods become the -module's public functions. The class stays public for isolated state. -""" - -from __future__ import annotations - - -class Counter: - """An ordinary class; instantiable by anyone needing isolation.""" - - def __init__(self) -> None: - self.count = 0 - - def increment(self) -> int: - self.count += 1 - return self.count - - def peek(self) -> int: - return self.count - - -_instance = Counter() - -#: The pattern: module-level names bound to one instance's methods. -increment = _instance.increment -peek = _instance.peek - - -def main() -> None: - print(f"module API: {increment()}, {increment()}, peek={peek()}") - isolated = Counter() - print(f"isolated instance unaffected: {isolated.peek()}") - - -if __name__ == "__main__": - main() diff --git a/patterns/python/prebound_method/real_world.py b/patterns/python/prebound_method/real_world.py deleted file mode 100644 index e6aef94..0000000 --- a/patterns/python/prebound_method/real_world.py +++ /dev/null @@ -1,31 +0,0 @@ -"""``random``: the stdlib's flagship prebound methods. - -``random.random`` and ``random.seed`` are bound methods of one hidden -``Random`` instance built when the module is imported. -""" - -from __future__ import annotations - -import random - - -def module_functions_share_one_instance() -> bool: - """Both prebound methods carry the same __self__.""" - a = getattr(random.random, "__self__", None) - b = getattr(random.seed, "__self__", None) - return a is not None and a is b and isinstance(a, random.Random) - - -def seeded_sequence(seed: int, n: int) -> list[float]: - """Seeding through one prebound method changes what the other returns.""" - random.seed(seed) - return [random.random() for _ in range(n)] - - -def main() -> None: - print(f"one hidden instance: {module_functions_share_one_instance()}") - print(f"reproducible: {seeded_sequence(42, 2) == seeded_sequence(42, 2)}") - - -if __name__ == "__main__": - main() diff --git a/patterns/python/prebound_method/tests/__init__.py b/patterns/python/prebound_method/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/patterns/python/prebound_method/tests/test_metrics.py b/patterns/python/prebound_method/tests/test_metrics.py new file mode 100644 index 0000000..3f64595 --- /dev/null +++ b/patterns/python/prebound_method/tests/test_metrics.py @@ -0,0 +1,57 @@ +"""Behavioral tests for the metrics mini-project.""" + +from __future__ import annotations + +from collections.abc import Iterator + +import pytest + +from patterns.python.prebound_method.examples.metrics import api +from patterns.python.prebound_method.examples.metrics.collector import MetricsCollector +from patterns.python.prebound_method.examples.metrics.main import main +from patterns.python.prebound_method.pattern import shares_instance + + +@pytest.fixture(autouse=True) +def clean_shared_collector() -> Iterator[None]: + api.reset() + yield + api.reset() + + +class TestModuleApi: + def test_all_prebound_names_share_the_hidden_collector(self) -> None: + assert shares_instance(api.increment, api.timing, api.snapshot, api.reset) + + def test_modules_that_never_met_report_into_one_place(self) -> None: + api.increment("orders") + api.increment("orders", by=2) + api.timing("checkout_ms", 10.0) + api.timing("checkout_ms", 20.0) + snap = api.snapshot() + assert snap["counts"] == {"orders": 3} + assert snap["timing_avg_ms"] == {"checkout_ms": 15.0} + + def test_reset_is_the_test_seam(self) -> None: + api.increment("orders") + api.reset() + assert api.snapshot() == {"counts": {}, "timing_avg_ms": {}} + + +class TestIsolation: + def test_an_isolated_collector_leaves_the_shared_one_alone(self) -> None: + own = MetricsCollector() + own.increment("private") + assert api.snapshot() == {"counts": {}, "timing_avg_ms": {}} + assert own.snapshot()["counts"] == {"private": 1} + + +class TestDemo: + def test_main_reports_orders_and_the_shared_instance( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + main() + out = capsys.readouterr().out + assert "one hidden instance: True" in out + assert "'orders': 3" in out + assert "'payment_errors': 1" in out diff --git a/patterns/python/prebound_method/tests/test_prebound.py b/patterns/python/prebound_method/tests/test_prebound.py new file mode 100644 index 0000000..2204b81 --- /dev/null +++ b/patterns/python/prebound_method/tests/test_prebound.py @@ -0,0 +1,41 @@ +"""Behavioral tests for the pattern's canonical prebound Counter.""" + +from __future__ import annotations + +import random + +from patterns.python.prebound_method import Counter, increment, peek, shares_instance + + +class TestPreboundCounter: + def test_module_functions_share_one_instance(self) -> None: + assert shares_instance(increment, peek) + + def test_calls_through_either_name_hit_the_same_state(self) -> None: + before = peek() + value = increment() + assert value == before + 1 + assert peek() == value + + def test_isolated_instances_do_not_touch_the_shared_one(self) -> None: + shared_before = peek() + isolated = Counter() + assert isolated.increment() == 1 + assert peek() == shared_before + + +class TestSharesInstance: + def test_the_stdlib_flagship_passes(self) -> None: + assert shares_instance(random.random, random.seed) + + def test_plain_functions_fail(self) -> None: + def f() -> None: ... + def g() -> None: ... + + assert not shares_instance(f, g) + + def test_methods_of_different_instances_fail(self) -> None: + assert not shares_instance(Counter().increment, Counter().increment) + + def test_empty_call_is_not_a_vacuous_pass(self) -> None: + assert not shares_instance() diff --git a/patterns/python/prebound_method/tests/test_prebound_method.py b/patterns/python/prebound_method/tests/test_prebound_method.py deleted file mode 100644 index 9f87f68..0000000 --- a/patterns/python/prebound_method/tests/test_prebound_method.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Behavioral tests for all three prebound-method variants.""" - -from typing import Any - -from patterns.python.prebound_method import naive, pythonic, real_world - - -class TestNaive: - def test_loose_global_counts(self) -> None: - start = naive.increment() - assert naive.increment() == start + 1 - - def test_diy_instances_are_isolated(self) -> None: - a, b = naive.Counter(), naive.Counter() - a.increment() - assert b.count == 0 - - -class TestPythonic: - def test_module_functions_share_the_hidden_instance(self) -> None: - before = pythonic.peek() - pythonic.increment() - assert pythonic.peek() == before + 1 - - def test_functions_are_bound_methods_of_one_instance(self) -> None: - increment: Any = pythonic.increment - peek: Any = pythonic.peek - assert increment.__self__ is peek.__self__ - - def test_public_class_gives_isolation(self) -> None: - isolated = pythonic.Counter() - pythonic.increment() - assert isolated.peek() == 0 - - -class TestRealWorld: - def test_random_module_is_prebound(self) -> None: - assert real_world.module_functions_share_one_instance() - - def test_seeding_is_shared_state(self) -> None: - assert real_world.seeded_sequence(7, 3) == real_world.seeded_sequence(7, 3) diff --git a/patterns/python/sentinel_object/README.md b/patterns/python/sentinel_object/README.md index 6c377b2..5f44259 100644 --- a/patterns/python/sentinel_object/README.md +++ b/patterns/python/sentinel_object/README.md @@ -1,7 +1,7 @@ --- id: python/sentinel_object name: Sentinel Object -aliases: [sentinel, missing-marker, null-object] +aliases: [sentinel, missing-marker] guide_url: https://python-patterns.guide/python/sentinel-object/ problem: "Mark 'no value here' unambiguously when None itself is a legitimate value." symptoms: ["None is a valid value", "distinguish missing from null", "default argument that could be None", "str.find returns -1"] @@ -10,36 +10,22 @@ caveats: - "A sentinel must be compared with `is`, never `==` — its identity is its meaning." - "Sentinel *values* like -1 (str.find) live inside the value's own type and eventually collide; a fresh object() cannot." - "Fowler's Null Object pattern — a do-nothing stand-in with real methods — is the neighboring cure when callers would otherwise be littered with None checks." -stdlib_sightings: [dataclasses.MISSING, iter(callable, sentinel), str.find] +stdlib_sightings: [dataclasses.MISSING, iter(callable, sentinel)] --- # Sentinel Object -## Problem +Mark "no value here" with an unforgeable object, so a legitimate `None` and +a genuine absence stop colliding. **Verdict: pythonic** — one named marker +per meaning, compared with `is`. -A cache stores `None` as a legitimate value; a keyword argument treats `None` -as meaningful. Now "the value is None" and "there is no value" collide, and -`get(...) or default` bugs follow. +| Where | What | +|---|---| +| [`pattern/`](pattern/) | The importable code: `Sentinel` (named marker) and `MISSING` | +| [`docs/`](docs/) | [Fundamentals](docs/fundamentals.md) · [Implementation guide](docs/implementation.md) · [External examples](docs/examples.md) | +| [`examples/layered_config/`](examples/layered_config/) | Mini-project: CLI ← file ← defaults config where `None` means "explicitly disabled", plus a `NullNotifier` | +| [`tests/`](tests/) | Behavioral tests for the pattern and the mini-project | -## Naive solution - -`naive.py` shows both classic failures: the in-band sentinel *value* -(`str.find`-style `-1` that arithmetic happily consumes), and `None`-as-missing -in a cache that stores `None`. - -## Pythonic solution - -A fresh `_MISSING = object()` is unforgeable: it lives in no domain, equals -nothing but itself, and is checked by identity. `pythonic.py` uses it for a -cache and a default argument, and includes a small Null Object — a real -do-nothing logger — for the case where callers shouldn't branch at all. - -## In the wild - -`dataclasses.MISSING` distinguishes "no default" from "default is None"; -two-argument `iter(read, b"")` takes an explicit sentinel that terminates -iteration; `str.find`'s `-1` survives as a cautionary in-band sentinel value. - -## Verdict - -**Pythonic.** One module-private `object()` per meaning, compared with `is`. +```bash +uv run python -m patterns.python.sentinel_object.examples.layered_config.main +``` diff --git a/patterns/python/sentinel_object/__init__.py b/patterns/python/sentinel_object/__init__.py index 965bf2f..7ddc4ae 100644 --- a/patterns/python/sentinel_object/__init__.py +++ b/patterns/python/sentinel_object/__init__.py @@ -1 +1,2 @@ -"""Sentinel Object: an unforgeable marker for missing, when None is a real value.""" +from .pattern import MISSING as MISSING +from .pattern import Sentinel as Sentinel diff --git a/patterns/python/sentinel_object/docs/examples.md b/patterns/python/sentinel_object/docs/examples.md new file mode 100644 index 0000000..78d480e --- /dev/null +++ b/patterns/python/sentinel_object/docs/examples.md @@ -0,0 +1,36 @@ +# Sentinel Object — where it lives outside this repo + +Cited, real implementations to study (or point an agent at) when designing or +reviewing absence-handling code. + +## Python standard library + +- **`dataclasses.MISSING`.** The stdlib's public missing-marker: it lets + introspection distinguish "no default" from "default is None". + [docs.python.org/3/library/dataclasses.html](https://docs.python.org/3/library/dataclasses.html) +- **`inspect.Parameter.empty`.** The sentinel for "no default / no + annotation" in signature introspection. + [docs.python.org/3/library/inspect.html](https://docs.python.org/3/library/inspect.html) +- **`unittest.mock.sentinel` and `mock.DEFAULT`.** Named sentinels offered + *as* API — mint-a-marker as a service. + [docs.python.org/3/library/unittest.mock.html](https://docs.python.org/3/library/unittest.mock.html) +- **Two-argument `iter(callable, sentinel)`.** A sentinel baked into a + builtin's signature: iteration stops when the sentinel appears. + [docs.python.org/3/library/functions.html#iter](https://docs.python.org/3/library/functions.html#iter) +- **`str.find`'s `-1`.** The cautionary in-band sentinel *value* — legal + integer, silently consumable by arithmetic. + +## Language evolution + +- **PEP 661 — Sentinel Values.** The problem is real enough to have a PEP: + naming, repr, and copy/pickle semantics for sentinels. + [peps.python.org/pep-0661](https://peps.python.org/pep-0661/) +- **The guide chapter** — sentinel values vs the sentinel object vs the Null + Object, with history. + [python-patterns.guide/python/sentinel-object](https://python-patterns.guide/python/sentinel-object/) + +## What to notice across all of them + +The healthy examples are all **out-of-band** (a fresh object no domain can +produce) and **named** (debuggable). The stdlib's one in-band survivor, +`str.find`, is the pattern's standing warning label. diff --git a/patterns/python/sentinel_object/docs/fundamentals.md b/patterns/python/sentinel_object/docs/fundamentals.md new file mode 100644 index 0000000..9a69fb3 --- /dev/null +++ b/patterns/python/sentinel_object/docs/fundamentals.md @@ -0,0 +1,68 @@ +# Sentinel Object — fundamentals + +## Intent + +Mark "no value here" unambiguously when `None` itself is a legitimate value. +A fresh object exists in no domain: it equals nothing but itself, cannot be +forged, and is checked by identity — so "the value is None" and "there is no +value" stop colliding. Source chapter: +[python-patterns.guide/python/sentinel-object](https://python-patterns.guide/python/sentinel-object/). + +## Participants + +| Role | What it is | +|---|---| +| The sentinel | One module-level marker — `Sentinel`/`MISSING` in [`pattern/sentinel.py`](../pattern/sentinel.py) | +| The APIs | Functions/containers that accept or return it in place of "absent" | +| The identity check | `value is MISSING` — identity *is* the meaning | +| The Null Object | The neighboring cure (Fowler/Woolf): a do-nothing stand-in with real methods, for when callers should not branch at all | + +## Mechanism + +1. Create one marker per distinct meaning, module-level, named: + `MISSING = Sentinel("MISSING")`. +2. Use it wherever "absent" must be distinguishable from every legal value — + dict lookups (`d.get(k, MISSING)`), default arguments, cache slots. +3. Check with `is`, never `==` — equality can be overloaded; identity cannot. +4. Where absence would make every caller branch, upgrade to a Null Object: an + implementation of the real interface that intentionally does nothing. + +## The failure modes it replaces + +```python +# Failure 1: the in-band sentinel VALUE. str.find's -1 is a legal integer, +# so forgetting the check produces plausible garbage, not an error: +position = text.find(needle) # -1 when absent ... +return text[position - 1] # ... silently becomes text[-2] + +# Failure 2: None-as-missing where None is storable. A cache holding a +# legitimate None cannot tell a hit from a miss: +value = self._data.get(key) +if value is None: # ... but None might BE the cached value! + value = compute() +``` + +An in-band sentinel lives inside the value's own type and eventually +collides; `None` is just the most common in-band sentinel of all. A fresh +`object()` closes both holes — which is why the problem earned a PEP +([PEP 661](https://peps.python.org/pep-0661/)). + +## When to use it + +- `None` (or `-1`, or `""`) is a legitimate stored/passed value and "not + provided" must remain distinct. +- Default arguments where "caller passed None" and "caller passed nothing" + behave differently. + +## When not to use it + +- `None` genuinely means absent and nothing stores it → `None` is simpler + and idiomatic; do not invent markers for their own sake. +- Callers branch on the sentinel everywhere → that is the Null Object's job; + hand back a do-nothing implementation instead. + +## Verdict: pythonic + +One module-private marker per meaning, compared with `is`. The stdlib ships +it as `dataclasses.MISSING`, `inspect.Parameter.empty`, and two-argument +`iter`. diff --git a/patterns/python/sentinel_object/docs/implementation.md b/patterns/python/sentinel_object/docs/implementation.md new file mode 100644 index 0000000..84aeb4e --- /dev/null +++ b/patterns/python/sentinel_object/docs/implementation.md @@ -0,0 +1,77 @@ +# Sentinel Object — putting it into a system + +## The smell it fixes + +`get(...) or default` bugs, and absence checks that quietly eat legal values: + +```python +timeout = config.get("timeout") or 30 # a configured 0 becomes 30 +if cached is None: # a cached None recomputes forever + cached = expensive() +``` + +## Steps + +1. **Find the collision.** Which legal value is doubling as "absent"? + (`None`, `0`, `""`, `-1` are the usual suspects.) +2. **Mint one named sentinel per meaning** — `MISSING = Sentinel("MISSING")` + from this unit's `pattern/`, or a bare `_MISSING = object()` when a repr + does not matter. One marker per *meaning*, not per call site. +3. **Thread it through the boundary**: `layer.get(key, MISSING)` inside, + a clean `Value` type outside. The sentinel should not leak into public + return types — resolve it (raise, or apply the caller's default) before + returning. +4. **Check by identity** — `value is MISSING`, the rule the pattern lives + by: equality is overloadable, and a type check (`isinstance(value, + Sentinel)`) would swallow any *other* sentinel stored as a legitimate + value. Under `mypy --strict` a `cast` at the return keeps the public + type clean; identity remains the semantic guard. +5. **Upgrade chronic branching to a Null Object.** If many callers test the + sentinel just to skip work, return a do-nothing implementation of the real + interface instead (`NullNotifier` in the worked example). + +```python +from patterns.python.sentinel_object import MISSING, Sentinel + + +def get(self, key: str, default: Value | Sentinel = MISSING) -> Value: + for layer in self._layers: + value = layer.get(key, MISSING) + if value is not MISSING: + return cast("Value", value) # a stored None wins here + if default is MISSING: + raise KeyError(key) + return cast("Value", default) +``` + +## Python idioms that keep it small + +- `dict.get(key, MISSING)` turns "key present?" plus "value None?" into one + identity check. +- A keyword default of `MISSING` distinguishes "not passed" from "passed + None" without `**kwargs` games. +- `__slots__` and a `__repr__` on a tiny `Sentinel` class cost three lines + and make debugger output say `` instead of ``. + +## Pitfalls + +- **`==` instead of `is`.** Equality is overloadable; identity is the + contract. `value == MISSING` invites a `__eq__` to lie. +- **Sentinels escaping the API.** A public function returning `MISSING` + forces every caller to import your marker — resolve absence at the + boundary. +- **Pickle/copy round-trips.** A copied sentinel is a different object; + identity checks fail across process boundaries. Keep sentinels inside one + process's logic (PEP 661 discusses the fix). +- **In-band "improvements".** Replacing the sentinel with `-1`/`""` to avoid + the import reintroduces the original bug one type away. + +## Worked example + +[`examples/layered_config/`](../examples/layered_config/) resolves settings +CLI ← file ← defaults where a stored `None` means "explicitly disabled", and +hands back a `NullNotifier` so callers never branch. Run it: + +```bash +uv run python -m patterns.python.sentinel_object.examples.layered_config.main +``` diff --git a/patterns/python/sentinel_object/examples/layered_config/config.py b/patterns/python/sentinel_object/examples/layered_config/config.py new file mode 100644 index 0000000..6d0ead6 --- /dev/null +++ b/patterns/python/sentinel_object/examples/layered_config/config.py @@ -0,0 +1,55 @@ +"""Layered configuration where ``None`` is a value, not an absence. + +Settings resolve CLI ← file ← defaults. A stored ``None`` means "explicitly +disabled" — a real decision someone made — so "missing" needs its own marker: +the sentinel, checked by identity. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import cast + +from patterns.python.sentinel_object.pattern import MISSING, Sentinel + +Value = str | int | None + + +class LayeredConfig: + """Lookup through override layers, None-safe at every step.""" + + def __init__( + self, + defaults: Mapping[str, Value], + file: Mapping[str, Value] | None = None, + cli: Mapping[str, Value] | None = None, + ) -> None: + # Highest priority first. + self._layers: tuple[tuple[str, Mapping[str, Value]], ...] = ( + ("cli", dict(cli or {})), + ("file", dict(file or {})), + ("defaults", dict(defaults)), + ) + + def get(self, key: str, default: Value | Sentinel = MISSING) -> Value: + """The first layer that *has* the key wins — even when its value is None. + + Checks are by identity (``is MISSING``), the unit's own rule: equality + is overloadable and a type check would swallow any *other* sentinel a + caller legitimately stored as a value. The ``cast``s are for the type + checker only — identity is the semantic guard. + """ + for _, layer in self._layers: + value = layer.get(key, MISSING) + if value is not MISSING: + return cast("Value", value) + if default is MISSING: + raise KeyError(f"{key!r} not set in any layer and no default given") + return cast("Value", default) + + def source_of(self, key: str) -> str: + """Which layer answers for a key — 'unset' if none does.""" + for name, layer in self._layers: + if key in layer: + return name + return "unset" diff --git a/patterns/python/sentinel_object/examples/layered_config/main.py b/patterns/python/sentinel_object/examples/layered_config/main.py new file mode 100644 index 0000000..c036860 --- /dev/null +++ b/patterns/python/sentinel_object/examples/layered_config/main.py @@ -0,0 +1,23 @@ +"""Demo: None-vs-missing through three config layers.""" + +from __future__ import annotations + +from patterns.python.sentinel_object.examples.layered_config.config import LayeredConfig +from patterns.python.sentinel_object.examples.layered_config.notifier import notifier_for + + +def main() -> None: + config = LayeredConfig( + defaults={"timeout_s": 30, "alert_email": "ops@example.com", "proxy": None}, + file={"timeout_s": 60, "alert_email": None}, # None: someone turned alerts OFF + cli={"timeout_s": 10}, + ) + for key in ("timeout_s", "alert_email", "proxy"): + print(f"{key:12} = {config.get(key)!r:22} (from {config.source_of(key)})") + notifier = notifier_for(config) + notifier.notify("disk almost full") + print(f"notifier = {type(notifier).__name__} (file layer's None disabled alerts)") + + +if __name__ == "__main__": + main() diff --git a/patterns/python/sentinel_object/examples/layered_config/notifier.py b/patterns/python/sentinel_object/examples/layered_config/notifier.py new file mode 100644 index 0000000..8576421 --- /dev/null +++ b/patterns/python/sentinel_object/examples/layered_config/notifier.py @@ -0,0 +1,36 @@ +"""The neighboring Null Object cure, in the same domain. + +``alert_email = None`` means notifications are explicitly disabled. Instead +of every caller branching on that, ``notifier_for`` hands back a NullNotifier +— a real object that intentionally does nothing — and callers stop checking. +""" + +from __future__ import annotations + +from patterns.python.sentinel_object.examples.layered_config.config import LayeredConfig + + +class EmailNotifier: + """Fake outbound email; records sends so behavior is testable.""" + + def __init__(self, address: str) -> None: + self.address = address + self.sent: list[str] = [] + + def notify(self, message: str) -> None: + self.sent.append(f"to {self.address}: {message}") + + +class NullNotifier: + """The Null Object: same interface, deliberate no-op, no None checks.""" + + def notify(self, message: str) -> None: + pass + + +def notifier_for(config: LayeredConfig) -> EmailNotifier | NullNotifier: + """None (explicitly disabled) and unset both mean: the silent notifier.""" + address = config.get("alert_email", default=None) + if isinstance(address, str): + return EmailNotifier(address) + return NullNotifier() diff --git a/patterns/python/sentinel_object/naive.py b/patterns/python/sentinel_object/naive.py deleted file mode 100644 index 899e61b..0000000 --- a/patterns/python/sentinel_object/naive.py +++ /dev/null @@ -1,48 +0,0 @@ -"""The failure modes sentinels fix. - -1. The in-band sentinel value: str.find's -1 is a legal integer, so forgetting - the check produces a *plausible* wrong answer instead of an error. -2. None-as-missing: a cache that stores None cannot tell a hit from a miss. -""" - -from __future__ import annotations - - -def last_char_before(text: str, needle: str) -> str: - """BUG (deliberate): when needle is absent, find() returns -1 and the - index silently becomes text[-2] -- plausible garbage, no exception.""" - position = text.find(needle) - return text[position - 1] - - -class NoneCache: - """A cache where storing None is indistinguishable from a miss.""" - - def __init__(self) -> None: - self._data: dict[str, object | None] = {} - - def put(self, key: str, value: object | None) -> None: - self._data[key] = value - - def get_or_compute(self, key: str, compute_calls: list[str]) -> object | None: - value = self._data.get(key) - if value is None: # ... but None might BE the cached value! - compute_calls.append(key) - value = None # pretend we recomputed - self._data[key] = value - return value - - -def main() -> None: - print(f"present: {last_char_before('hello', 'e')!r}") - print(f"absent -- plausible garbage: {last_char_before('hello', 'z')!r}") - cache = NoneCache() - calls: list[str] = [] - cache.put("k", None) - cache.get_or_compute("k", calls) - cache.get_or_compute("k", calls) - print(f"cached None recomputed every time: {calls}") - - -if __name__ == "__main__": - main() diff --git a/patterns/python/sentinel_object/pattern/__init__.py b/patterns/python/sentinel_object/pattern/__init__.py new file mode 100644 index 0000000..8aa0d89 --- /dev/null +++ b/patterns/python/sentinel_object/pattern/__init__.py @@ -0,0 +1,2 @@ +from .sentinel import MISSING as MISSING +from .sentinel import Sentinel as Sentinel diff --git a/patterns/python/sentinel_object/pattern/sentinel.py b/patterns/python/sentinel_object/pattern/sentinel.py new file mode 100644 index 0000000..f16e7e4 --- /dev/null +++ b/patterns/python/sentinel_object/pattern/sentinel.py @@ -0,0 +1,29 @@ +"""The Sentinel Object pattern as importable, typed building blocks. + +``Sentinel`` is a named, unforgeable marker (a PEP 661-inspired shape; +unlike the PEP's proposal, two same-named sentinels here are deliberately +distinct objects — tested). ``MISSING`` is the one most APIs need. A +sentinel's identity is its meaning: compare with ``is``, never ``==``. +""" + +from __future__ import annotations + + +class Sentinel: + """A unique marker object with a readable repr. + + >>> MISSING = Sentinel("MISSING") + >>> value is MISSING # identity is the only correct check + """ + + __slots__ = ("_name",) + + def __init__(self, name: str) -> None: + self._name = name + + def __repr__(self) -> str: + return f"<{self._name}>" + + +#: The workhorse: "no value here", even where None is a legitimate value. +MISSING = Sentinel("MISSING") diff --git a/patterns/python/sentinel_object/pythonic.py b/patterns/python/sentinel_object/pythonic.py deleted file mode 100644 index e094626..0000000 --- a/patterns/python/sentinel_object/pythonic.py +++ /dev/null @@ -1,57 +0,0 @@ -"""Sentinel objects, done right -- plus a small Null Object. - -``_MISSING = object()`` is unforgeable and out-of-band; identity comparison -makes the miss check exact even when None is stored. -""" - -from __future__ import annotations - -from collections.abc import Callable - -_MISSING = object() - - -class Cache: - """A cache where None is an ordinary, cacheable value.""" - - def __init__(self) -> None: - self._data: dict[str, object] = {} - - def put(self, key: str, value: object) -> None: - self._data[key] = value - - def get_or_compute(self, key: str, compute: Callable[[], object]) -> object: - value = self._data.get(key, _MISSING) - if value is _MISSING: # identity: the only correct sentinel check - value = compute() - self._data[key] = value - return value - - -def greet(name: str, greeting: object = _MISSING) -> str: - """Distinguish 'not passed' from 'passed None' in a default argument.""" - if greeting is _MISSING: - return f"hello {name}" - return f"{greeting} {name}" if greeting is not None else name - - -class NullLogger: - """Fowler's Null Object: a real object that intentionally does nothing, - so callers never branch on 'is there a logger?'.""" - - def log(self, message: str) -> None: - pass - - -def main() -> None: - cache = Cache() - cache.put("k", None) - calls: list[str] = [] - cache.get_or_compute("k", lambda: calls.append("computed")) - print(f"cached None respected (no recompute): {calls == []}") - print(greet("ada"), "|", greet("ada", None), "|", greet("ada", "yo")) - NullLogger().log("silently fine") - - -if __name__ == "__main__": - main() diff --git a/patterns/python/sentinel_object/real_world.py b/patterns/python/sentinel_object/real_world.py deleted file mode 100644 index fc2bfbd..0000000 --- a/patterns/python/sentinel_object/real_world.py +++ /dev/null @@ -1,43 +0,0 @@ -"""Sentinels in the stdlib. - -``dataclasses.MISSING`` separates "no default" from "default is None"; -two-argument ``iter(callable, sentinel)`` stops when the sentinel appears. -""" - -from __future__ import annotations - -import dataclasses -from dataclasses import dataclass, field, fields - - -@dataclass -class Config: - name: str - retries: int | None = None - tags: list[str] = field(default_factory=list) - - -def has_default(field_name: str) -> bool: - """MISSING lets introspection distinguish no-default from None-default.""" - for f in fields(Config): - if f.name == field_name: - return ( - f.default is not dataclasses.MISSING or f.default_factory is not dataclasses.MISSING - ) - raise KeyError(field_name) - - -def read_until_blank(chunks: list[str]) -> list[str]: - """iter(callable, sentinel): the empty string terminates the stream.""" - supply = iter(chunks).__next__ - return list(iter(supply, "")) - - -def main() -> None: - print(f"'name' has default: {has_default('name')}") - print(f"'retries' has default: {has_default('retries')}") - print(f"read until blank: {read_until_blank(['a', 'b', '', 'c'])}") - - -if __name__ == "__main__": - main() diff --git a/patterns/python/sentinel_object/tests/__init__.py b/patterns/python/sentinel_object/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/patterns/python/sentinel_object/tests/test_layered_config.py b/patterns/python/sentinel_object/tests/test_layered_config.py new file mode 100644 index 0000000..f3180e5 --- /dev/null +++ b/patterns/python/sentinel_object/tests/test_layered_config.py @@ -0,0 +1,108 @@ +"""Behavioral tests for the layered-config mini-project.""" + +from __future__ import annotations + +import pytest + +from patterns.python.sentinel_object.examples.layered_config.config import LayeredConfig +from patterns.python.sentinel_object.examples.layered_config.main import main +from patterns.python.sentinel_object.examples.layered_config.notifier import ( + EmailNotifier, + NullNotifier, + notifier_for, +) +from patterns.python.sentinel_object.pattern import Sentinel + + +def build_config() -> LayeredConfig: + return LayeredConfig( + defaults={"timeout_s": 30, "alert_email": "ops@example.com", "proxy": None}, + file={"timeout_s": 60, "alert_email": None}, + cli={"timeout_s": 10}, + ) + + +class TestLayering: + def test_highest_layer_with_the_key_wins(self) -> None: + config = build_config() + assert config.get("timeout_s") == 10 + assert config.source_of("timeout_s") == "cli" + + def test_a_stored_none_wins_over_lower_layers(self) -> None: + # The file layer explicitly disabled alerts; the default email below + # it must NOT shine through — None is a value, not a hole. + config = build_config() + assert config.get("alert_email") is None + assert config.source_of("alert_email") == "file" + + def test_none_in_defaults_is_still_a_value(self) -> None: + config = build_config() + assert config.get("proxy") is None + assert config.source_of("proxy") == "defaults" + + def test_missing_key_without_default_raises(self) -> None: + with pytest.raises(KeyError, match="not set in any layer"): + build_config().get("nope") + + def test_missing_key_with_default_returns_it(self) -> None: + config = build_config() + assert config.get("nope", default=42) == 42 + assert config.get("nope", default=None) is None # None is a usable default + assert config.source_of("nope") == "unset" + + +class TestNullObject: + def test_disabled_alerts_yield_the_null_notifier(self) -> None: + notifier = notifier_for(build_config()) + assert isinstance(notifier, NullNotifier) + notifier.notify("nobody hears this") # and that is fine — no branching + + def test_configured_email_yields_a_real_notifier(self) -> None: + config = LayeredConfig(defaults={"alert_email": "oncall@example.com"}) + notifier = notifier_for(config) + assert isinstance(notifier, EmailNotifier) + notifier.notify("disk almost full") + assert notifier.sent == ["to oncall@example.com: disk almost full"] + + +class TestDemo: + def test_main_shows_provenance_and_the_null_notifier( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + main() + out = capsys.readouterr().out + assert "(from cli)" in out + assert "(from file)" in out + assert "NullNotifier" in out + + +class _EqualsEverything: + """A value whose __eq__ lies — only identity checks survive it.""" + + def __eq__(self, other: object) -> bool: + return True + + def __hash__(self) -> int: + return 0 + + +class TestIdentityIsTheGuard: + def test_get_checks_identity_not_equality(self) -> None: + liar = _EqualsEverything() + config = LayeredConfig(defaults={"trap": liar}) # type: ignore[dict-item] + got: object = config.get("trap") + assert got is liar # `==` would treat the liar as MISSING + + def test_a_different_sentinel_stored_as_a_value_is_not_swallowed(self) -> None: + other = Sentinel("OTHER") + config = LayeredConfig(defaults={"marker": other}) # type: ignore[dict-item] + stored: object = config.get("marker") + assert stored is other # an isinstance check would eat it + assert config.source_of("marker") == "defaults" + + +class TestNullNotifierPaths: + def test_absent_key_also_means_the_silent_notifier(self) -> None: + # The default=None argument exists exactly for the fully-unset case. + config = LayeredConfig(defaults={}) + assert isinstance(notifier_for(config), NullNotifier) diff --git a/patterns/python/sentinel_object/tests/test_sentinel.py b/patterns/python/sentinel_object/tests/test_sentinel.py new file mode 100644 index 0000000..d431dff --- /dev/null +++ b/patterns/python/sentinel_object/tests/test_sentinel.py @@ -0,0 +1,43 @@ +"""Behavioral tests for the pattern's Sentinel and MISSING.""" + +from __future__ import annotations + +from patterns.python.sentinel_object import MISSING, Sentinel + + +class TestSentinel: + def test_identity_is_the_meaning(self) -> None: + assert MISSING is MISSING + assert Sentinel("MISSING") is not MISSING # same name, different marker + + def test_a_sentinel_lives_in_no_value_domain(self) -> None: + store: dict[str, object] = {"none": None, "zero": 0, "empty": ""} + assert all(store.get(k, MISSING) is not MISSING for k in store) + assert store.get("absent", MISSING) is MISSING + + def test_repr_is_debuggable(self) -> None: + assert repr(MISSING) == "" + assert repr(Sentinel("NOT_GIVEN")) == "" + + def test_distinguishes_stored_none_from_absence(self) -> None: + cache: dict[str, str | None] = {"hit": None} + assert cache.get("hit", MISSING) is None + assert cache.get("miss", MISSING) is MISSING + + def test_equality_cannot_forge_a_sentinel(self) -> None: + # No name-based __eq__: a same-named marker is a different marker. + assert (Sentinel("MISSING") == MISSING) is False + assert Sentinel("MISSING") != MISSING + + def test_a_sentinel_is_truthy(self) -> None: + # "if value:" must never mistake the marker for an absence/falsy value. + assert bool(MISSING) is True + + def test_every_import_path_yields_the_same_marker(self) -> None: + from patterns.python.sentinel_object import MISSING as unit_missing + from patterns.python.sentinel_object.pattern import MISSING as pkg_missing + from patterns.python.sentinel_object.pattern.sentinel import ( + MISSING as module_missing, + ) + + assert unit_missing is pkg_missing is module_missing diff --git a/patterns/python/sentinel_object/tests/test_sentinel_object.py b/patterns/python/sentinel_object/tests/test_sentinel_object.py deleted file mode 100644 index f9fcadc..0000000 --- a/patterns/python/sentinel_object/tests/test_sentinel_object.py +++ /dev/null @@ -1,56 +0,0 @@ -"""Behavioral tests for all three sentinel-object variants.""" - -from patterns.python.sentinel_object import naive, pythonic, real_world - - -class TestNaive: - def test_in_band_sentinel_produces_plausible_garbage(self) -> None: - # The bug on display: absent needle silently indexes text[-2]. - assert naive.last_char_before("hello", "z") == "l" - - def test_none_cache_cannot_hold_none(self) -> None: - cache = naive.NoneCache() - calls: list[str] = [] - cache.put("k", None) - cache.get_or_compute("k", calls) - cache.get_or_compute("k", calls) - assert calls == ["k", "k"] # recomputed on every access - - -class TestPythonic: - def test_cache_distinguishes_stored_none_from_miss(self) -> None: - cache = pythonic.Cache() - cache.put("k", None) - calls: list[str] = [] - assert cache.get_or_compute("k", lambda: calls.append("x")) is None - assert calls == [] - - def test_miss_computes_once(self) -> None: - cache = pythonic.Cache() - calls: list[str] = [] - - def compute() -> object: - calls.append("x") - return 42 - - assert cache.get_or_compute("k", compute) == 42 - assert cache.get_or_compute("k", compute) == 42 - assert calls == ["x"] - - def test_default_argument_three_ways(self) -> None: - assert pythonic.greet("ada") == "hello ada" - assert pythonic.greet("ada", None) == "ada" - assert pythonic.greet("ada", "yo") == "yo ada" - - def test_null_object_never_raises(self) -> None: - pythonic.NullLogger().log("anything") - - -class TestRealWorld: - def test_missing_separates_no_default_from_none_default(self) -> None: - assert not real_world.has_default("name") - assert real_world.has_default("retries") - assert real_world.has_default("tags") - - def test_iter_with_sentinel_stops_at_blank(self) -> None: - assert real_world.read_until_blank(["a", "b", "", "c"]) == ["a", "b"] diff --git a/patterns/structural/__init__.py b/patterns/structural/__init__.py deleted file mode 100644 index 248d229..0000000 --- a/patterns/structural/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""structural patterns.""" diff --git a/patterns/structural/adapter/README.md b/patterns/structural/adapter/README.md index e83cda4..cda289c 100644 --- a/patterns/structural/adapter/README.md +++ b/patterns/structural/adapter/README.md @@ -14,31 +14,17 @@ stdlib_sightings: [io.TextIOWrapper, socket.makefile, functools.cmp_to_key] # Adapter -## Problem - -Your code speaks one interface; a class you cannot edit speaks another. A -sensor library reports Fahrenheit; your thermostat logic is written against -`celsius()`. - -## Naive solution - -`naive.py` is the GoF object adapter: a class implementing the target -interface, holding the adaptee, translating every call. - -## Pythonic solution - -Duck typing shrinks the job: adapt *only* what your code calls, and when -that's one method, a plain function is the whole adapter. `pythonic.py` shows -both the one-function adapter and a `__getattr__`-forwarding class for wider -surfaces. - -## In the wild - -`io.TextIOWrapper` adapts a binary stream to the text-file interface — -the stdlib's flagship adapter. `socket.makefile()` adapts a socket to a -file-like object; `functools.cmp_to_key` adapts old comparator functions to -the `key=` interface. - -## Verdict - -**Pythonic.** The honest way to reconcile interfaces you don't control. +Make a class you can't edit speak the interface your code expects — translate +what differs, forward the rest. **Verdict: pythonic** — the honest way to +reconcile interfaces you don't control. + +| Where | What | +|---|---| +| [`pattern/`](pattern/) | The importable code: `DelegatingAdapter` | +| [`docs/`](docs/) | [Fundamentals](docs/fundamentals.md) · [Implementation guide](docs/implementation.md) · [External examples](docs/examples.md) | +| [`examples/payment_gateways/`](examples/payment_gateways/) | Mini-project: one checkout over two mismatched vendor SDKs | +| [`tests/`](tests/) | Behavioral tests for the pattern and the mini-project | + +```bash +uv run python -m patterns.structural.adapter.examples.payment_gateways.main +``` diff --git a/patterns/structural/adapter/__init__.py b/patterns/structural/adapter/__init__.py index 362c561..09a933a 100644 --- a/patterns/structural/adapter/__init__.py +++ b/patterns/structural/adapter/__init__.py @@ -1 +1 @@ -"""Adapter: make a given class speak the interface your code expects.""" +from .pattern.adapter import DelegatingAdapter as DelegatingAdapter diff --git a/patterns/structural/adapter/docs/examples.md b/patterns/structural/adapter/docs/examples.md new file mode 100644 index 0000000..c4c6030 --- /dev/null +++ b/patterns/structural/adapter/docs/examples.md @@ -0,0 +1,36 @@ +# Adapter — where it lives outside this repo + +Cited, real implementations to study (or point an agent at) when designing or +reviewing adapter-shaped code. + +## Python standard library + +- **`io.TextIOWrapper`** — the stdlib's flagship adapter: wraps a binary + stream and exposes the text-file interface; your code reads `str` while + bytes flow underneath. + [docs.python.org/3/library/io.html#io.TextIOWrapper](https://docs.python.org/3/library/io.html#io.TextIOWrapper) +- **`functools.cmp_to_key`** — adapts an old-style two-argument comparator to + the one-argument `key=` interface; an entire adapter in function form. + [docs.python.org/3/library/functools.html#functools.cmp_to_key](https://docs.python.org/3/library/functools.html#functools.cmp_to_key) +- **`socket.makefile()`** — adapts a socket to a file-like object so + file-consuming code can speak to the network. + [docs.python.org/3/library/socket.html#socket.socket.makefile](https://docs.python.org/3/library/socket.html#socket.socket.makefile) + +## Major ecosystems + +- **`requests` transport adapters.** `HTTPAdapter` adapts urllib3's + connection machinery to the `Session` API, and users mount custom adapters + per URL prefix — the pattern offered as a public extension point. + [requests.readthedocs.io/en/latest/user/advanced/#transport-adapters](https://requests.readthedocs.io/en/latest/user/advanced/#transport-adapters) +- **SQLAlchemy dialects.** Each dialect adapts one DBAPI driver's quirks + (paramstyles, type handling) to a single Core interface, which is why one + query API spans many databases. + [docs.sqlalchemy.org/en/20/dialects/](https://docs.sqlalchemy.org/en/20/dialects/) + +## What to notice across all of them + +Every production adapter translates *conventions*, not just method names: +`cmp_to_key` bridges calling conventions, `TextIOWrapper` bridges data +models (bytes vs text), dialects bridge error hierarchies. When reviewing an +adapter, ask what happens to the adaptee's failure modes — an adapter that +only renames methods has usually left the hard mismatch in the client. diff --git a/patterns/structural/adapter/docs/fundamentals.md b/patterns/structural/adapter/docs/fundamentals.md new file mode 100644 index 0000000..df011f5 --- /dev/null +++ b/patterns/structural/adapter/docs/fundamentals.md @@ -0,0 +1,69 @@ +# Adapter — fundamentals + +## Intent + +Convert the interface of a class into the interface clients expect, so +classes that could not otherwise work together can — without editing either +side. You control neither the caller's shape nor the callee's; the adapter is +the one piece you do control. + +## Participants + +| Role | Classic (GoF) form | Python form | +|---|---|---| +| Target | Abstract class the client is written against | A `Protocol` (or just the duck-typed calls the client makes) | +| Adaptee | The class with the wrong interface | Same — the vendor SDK, legacy module, stdlib object you can't edit | +| Adapter | A class implementing Target, holding the Adaptee | A plain function when one method differs; a small class (see [`pattern/adapter.py`](../pattern/adapter.py)) for wider surfaces | +| Client | Calls Target methods only | Same — and never imports the adaptee | + +## Mechanism + +1. Write the target interface from the *client's* needs — only the calls it + actually makes. +2. The adapter holds the adaptee and translates each target call: units, + argument shapes, naming, and failure conventions. +3. The client is constructed with any adapter; swapping adaptees is now a + wiring change, not an edit. + +## The classic form, and what Python absorbs + +The textbook object adapter builds a full class triangle even for one method: + +```python +class Thermometer(ABC): # Target, as an abstract class + @abstractmethod + def celsius(self) -> float: ... + + +class SensorAdapter(Thermometer): # Adapter subclasses the Target + def __init__(self, sensor: FahrenheitSensor) -> None: + self._sensor = sensor + + def celsius(self) -> float: + return (self._sensor.get_fahrenheit() - 32) * 5 / 9 +``` + +Python absorbs most of that ceremony. Duck typing means there is no Target +class to subclass — the adapter only needs the methods the client calls. A +one-method mismatch collapses to a function returning a closure. And for wide +surfaces, `__getattr__` forwarding (what `DelegatingAdapter` packages) means +the adapter lists only the *differences*, never the whole interface. + +## When to use it + +- A third-party or legacy interface has the wrong shape and you can't (or + shouldn't) edit it. +- Two vendors do the same job differently and the rest of the system should + not know which one is wired in. + +## When not to use it + +- You own both sides — change one of them instead of adding a layer. +- The "adapter" starts adding behavior (retries, caching, validation) — that + is Decorator or Proxy territory; keep translation pure. + +## Verdict: pythonic + +The honest way to reconcile interfaces you don't control. Size it to the +mismatch: function for one method, `DelegatingAdapter` subclass for a few, +and stop before it becomes a facade over many objects. diff --git a/patterns/structural/adapter/docs/implementation.md b/patterns/structural/adapter/docs/implementation.md new file mode 100644 index 0000000..afb4d50 --- /dev/null +++ b/patterns/structural/adapter/docs/implementation.md @@ -0,0 +1,78 @@ +# Adapter — putting it into a system + +## The smell it fixes + +Vendor-specific shapes leaking through code that shouldn't care: + +```python +def checkout(order, vendor, client): + if vendor == "stripe": + outcome = client.create_charge(order.total_cents, "usd") + paid = outcome["status"] == "succeeded" + elif vendor == "paypal": + try: + ref = client.submit_payment(f"{order.total_cents / 100:.2f}", "USD") + paid = True + except ValueError: + paid = False + ... +``` + +Every vendor difference — units, naming, error convention — is re-decided at +every call site. The adapter moves each vendor's translation into one class, +and the call sites shrink to a single target interface. + +## Steps + +1. **Define the target from the client's needs.** List the calls the client + actually makes; type them as a `Protocol`. Do not copy either vendor's + surface — the target belongs to *your* domain. +2. **Write one adapter per adaptee.** Translate units and argument shapes, + and — the step most often missed — translate **failure conventions** + (status dict vs exception) into one result type. +3. **Pick the adapter's size.** One method → a plain function or tiny class. + A few methods over a wide surface → subclass + `DelegatingAdapter` and define only what differs; the rest forwards. +4. **Construct at the edge.** Adapters are wired where the app is assembled + (config, DI, factory) — client modules import the target type only. +5. **Test through the target.** One test suite, parameterized over every + adapter, pins that all vendors behave identically from the client's seat. + +```python +from patterns.structural.adapter.pattern import DelegatingAdapter + + +class StripeAdapter(DelegatingAdapter[StripeLikeClient]): + def charge(self, amount_cents: int, currency: str) -> PaymentResult: + outcome = self.adaptee.create_charge(amount_cents, currency.lower()) + ... +``` + +## Python idioms that keep it small + +- **`Protocol` for the target** — the client gets type-checked without any + runtime base class, and adapters satisfy it structurally. +- **A closure as the whole adapter** when the target is one callable: + `lambda: (sensor.get_fahrenheit() - 32) * 5 / 9`. +- **`__getattr__` forwarding** for pass-through surfaces — never re-list + methods you aren't translating. + +## Pitfalls + +- **Adapting the whole surface** instead of what the client calls — you end + up maintaining a second copy of the vendor's API. +- **Leaking adaptee types** through the adapter's returns (a vendor result + dict escaping to the client re-couples everything the adapter decoupled). +- **Unifying calls but not failures.** If one vendor raises and the other + returns an error status, the client is still vendor-aware. Normalize both. +- **Translation with opinions.** Retry, cache, or validation logic hiding in + an adapter belongs in a Decorator/Proxy where it is visible and reusable. + +## Worked example + +[`examples/payment_gateways/`](../examples/payment_gateways/) integrates two +mismatched fake vendor SDKs behind one `PaymentProcessor` — run it with: + +```bash +uv run python -m patterns.structural.adapter.examples.payment_gateways.main +``` diff --git a/patterns/structural/adapter/examples/payment_gateways/adapters.py b/patterns/structural/adapter/examples/payment_gateways/adapters.py new file mode 100644 index 0000000..8018f37 --- /dev/null +++ b/patterns/structural/adapter/examples/payment_gateways/adapters.py @@ -0,0 +1,57 @@ +"""One ``PaymentProcessor`` target; one adapter per vendor shape. + +The target interface is defined by what *checkout* needs — not by either +vendor. Each adapter translates amounts, currencies, and (crucially) the +vendors' different failure conventions into one ``PaymentResult``. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Protocol + +from patterns.structural.adapter.examples.payment_gateways.vendors import ( + PayPalLikeGateway, + StripeLikeClient, +) +from patterns.structural.adapter.pattern import DelegatingAdapter + + +@dataclass(frozen=True) +class PaymentResult: + """The one result shape checkout understands.""" + + ok: bool + reference: str + reason: str = "" + + +class PaymentProcessor(Protocol): + """The target interface — everything checkout will ever call.""" + + def charge(self, amount_cents: int, currency: str) -> PaymentResult: ... + + +class StripeAdapter(DelegatingAdapter[StripeLikeClient]): + """Translate ``charge``; the vendor's extras still reachable by forwarding.""" + + def charge(self, amount_cents: int, currency: str) -> PaymentResult: + outcome = self.adaptee.create_charge(amount_cents, currency.lower()) + if outcome["status"] == "succeeded": + return PaymentResult(ok=True, reference=outcome["id"]) + return PaymentResult(ok=False, reference=outcome["id"], reason=outcome["status"]) + + +class PayPalAdapter: + """A hand-rolled adapter: cents -> decimal string, exception -> result.""" + + def __init__(self, gateway: PayPalLikeGateway) -> None: + self._gateway = gateway + + def charge(self, amount_cents: int, currency: str) -> PaymentResult: + amount = f"{amount_cents / 100:.2f}" + try: + confirmation = self._gateway.submit_payment(amount, currency.upper()) + except ValueError as refusal: + return PaymentResult(ok=False, reference="", reason=str(refusal)) + return PaymentResult(ok=True, reference=confirmation) diff --git a/patterns/structural/adapter/examples/payment_gateways/checkout.py b/patterns/structural/adapter/examples/payment_gateways/checkout.py new file mode 100644 index 0000000..2fa5fb2 --- /dev/null +++ b/patterns/structural/adapter/examples/payment_gateways/checkout.py @@ -0,0 +1,23 @@ +"""The client code: written once against the target, never per vendor.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from patterns.structural.adapter.examples.payment_gateways.adapters import PaymentProcessor + + +@dataclass(frozen=True) +class Receipt: + order_id: str + paid: bool + reference: str + note: str = "" + + +def checkout(order_id: str, total_cents: int, processor: PaymentProcessor) -> Receipt: + """Charge an order through whichever vendor the adapter hides.""" + result = processor.charge(total_cents, "usd") + if result.ok: + return Receipt(order_id, paid=True, reference=result.reference) + return Receipt(order_id, paid=False, reference=result.reference, note=result.reason) diff --git a/patterns/structural/adapter/examples/payment_gateways/main.py b/patterns/structural/adapter/examples/payment_gateways/main.py new file mode 100644 index 0000000..f3ba24d --- /dev/null +++ b/patterns/structural/adapter/examples/payment_gateways/main.py @@ -0,0 +1,30 @@ +"""Demo: the same checkout code through two mismatched vendor SDKs.""" + +from __future__ import annotations + +from patterns.structural.adapter.examples.payment_gateways.adapters import ( + PaymentProcessor, + PayPalAdapter, + StripeAdapter, +) +from patterns.structural.adapter.examples.payment_gateways.checkout import checkout +from patterns.structural.adapter.examples.payment_gateways.vendors import ( + PayPalLikeGateway, + StripeLikeClient, +) + + +def main() -> None: + processors: dict[str, PaymentProcessor] = { + "stripe-like": StripeAdapter(StripeLikeClient()), + "paypal-like": PayPalAdapter(PayPalLikeGateway()), + } + for vendor, processor in processors.items(): + ok = checkout("A-1", 2_499, processor) + declined = checkout("A-2", 999_999, processor) + print(f"{vendor}: A-1 paid={ok.paid} ({ok.reference})") + print(f"{vendor}: A-2 paid={declined.paid} ({declined.note})") + + +if __name__ == "__main__": + main() diff --git a/patterns/structural/adapter/examples/payment_gateways/vendors.py b/patterns/structural/adapter/examples/payment_gateways/vendors.py new file mode 100644 index 0000000..230fbd0 --- /dev/null +++ b/patterns/structural/adapter/examples/payment_gateways/vendors.py @@ -0,0 +1,34 @@ +"""Two fake vendor SDKs we must integrate but cannot edit. + +Each has its own idea of amounts, currencies, and results — exactly the +mismatch the adapters in :mod:`adapters` reconcile. +""" + +from __future__ import annotations + + +class StripeLikeClient: + """Charges in integer cents; answers with a result dict.""" + + def create_charge(self, amount_cents: int, currency: str) -> dict[str, str]: + if amount_cents <= 0: + return {"id": "", "status": "invalid_amount"} + if amount_cents > 500_000: + return {"id": "ch_declined", "status": "card_declined"} + return {"id": f"ch_{amount_cents}", "status": "succeeded"} + + def diagnostics(self) -> str: + """Vendor extra our checkout never calls — but support scripts do.""" + return "stripe-like: all systems normal" + + +class PayPalLikeGateway: + """Charges via decimal strings; failure is an exception, not a status.""" + + def submit_payment(self, amount: str, currency_code: str) -> str: + value = float(amount) + if value <= 0: + raise ValueError("PAYPAL_INVALID_AMOUNT") + if value > 5000.0: + raise ValueError("PAYPAL_DECLINED") + return f"PAYPAL-OK-{amount}-{currency_code}" diff --git a/patterns/structural/adapter/naive.py b/patterns/structural/adapter/naive.py deleted file mode 100644 index 9f8b20f..0000000 --- a/patterns/structural/adapter/naive.py +++ /dev/null @@ -1,43 +0,0 @@ -"""The Gang of Four object adapter, translated literally. - -The adapter implements the target interface and holds the adaptee, -translating call by call. -""" - -from __future__ import annotations - -from abc import ABC, abstractmethod - - -class FahrenheitSensor: - """The adaptee: a class we cannot edit, with the wrong interface.""" - - def get_fahrenheit(self) -> float: - return 68.0 - - -class Thermometer(ABC): - """The target interface our code is written against.""" - - @abstractmethod - def celsius(self) -> float: ... - - -class SensorAdapter(Thermometer): - def __init__(self, sensor: FahrenheitSensor) -> None: - self._sensor = sensor - - def celsius(self) -> float: - return (self._sensor.get_fahrenheit() - 32) * 5 / 9 - - -def describe(thermometer: Thermometer) -> str: - return f"{thermometer.celsius():.1f} °C" - - -def main() -> None: - print(describe(SensorAdapter(FahrenheitSensor()))) - - -if __name__ == "__main__": - main() diff --git a/patterns/structural/adapter/pattern/__init__.py b/patterns/structural/adapter/pattern/__init__.py new file mode 100644 index 0000000..ab5efa6 --- /dev/null +++ b/patterns/structural/adapter/pattern/__init__.py @@ -0,0 +1 @@ +from .adapter import DelegatingAdapter as DelegatingAdapter diff --git a/patterns/structural/adapter/pattern/adapter.py b/patterns/structural/adapter/pattern/adapter.py new file mode 100644 index 0000000..4da873b --- /dev/null +++ b/patterns/structural/adapter/pattern/adapter.py @@ -0,0 +1,36 @@ +"""Adapter as an importable, typed building block. + +An adapter translates the calls your code makes into the calls a class you +cannot edit understands. Python needs less machinery than the classic form: +a one-method mismatch is just a function, and for wider surfaces +``DelegatingAdapter`` translates what differs and forwards the rest. +""" + +from __future__ import annotations + +from typing import Any, Generic, TypeVar + +Adaptee = TypeVar("Adaptee") + + +class DelegatingAdapter(Generic[Adaptee]): + """Translate the methods that differ; forward everything else. + + Subclass it, store nothing yourself, and define only the target-interface + methods your callers actually use. Attributes you don't define fall + through to the adaptee via ``__getattr__`` — the adapter never has to + re-list a surface it isn't changing. + """ + + def __init__(self, adaptee: Adaptee) -> None: + self._adaptee = adaptee + + @property + def adaptee(self) -> Adaptee: + """The wrapped object, for callers that need to reach past the adapter.""" + return self._adaptee + + def __getattr__(self, name: str) -> Any: + # Only called for names not found on the adapter itself, so a + # translated method always wins over the adaptee's original. + return getattr(self._adaptee, name) diff --git a/patterns/structural/adapter/pythonic.py b/patterns/structural/adapter/pythonic.py deleted file mode 100644 index d681bd4..0000000 --- a/patterns/structural/adapter/pythonic.py +++ /dev/null @@ -1,49 +0,0 @@ -"""Adapters at the right size. - -A single-method mismatch needs a function, not a class. A wider surface can -forward wholesale with ``__getattr__`` and translate only what differs. -""" - -from __future__ import annotations - -from collections.abc import Callable -from typing import Any - - -class FahrenheitSensor: - """The adaptee, unchanged.""" - - def get_fahrenheit(self) -> float: - return 68.0 - - def vendor_id(self) -> str: - return "acme-42" - - -def celsius_reader(sensor: FahrenheitSensor) -> Callable[[], float]: - """The one-function adapter: all the pattern that's needed here.""" - return lambda: (sensor.get_fahrenheit() - 32) * 5 / 9 - - -class CelsiusAdapter: - """Translate the one differing method; forward everything else.""" - - def __init__(self, sensor: FahrenheitSensor) -> None: - self._sensor = sensor - - def celsius(self) -> float: - return (self._sensor.get_fahrenheit() - 32) * 5 / 9 - - def __getattr__(self, name: str) -> Any: - return getattr(self._sensor, name) - - -def main() -> None: - read = celsius_reader(FahrenheitSensor()) - print(f"function adapter: {read():.1f} °C") - adapter = CelsiusAdapter(FahrenheitSensor()) - print(f"class adapter: {adapter.celsius():.1f} °C from {adapter.vendor_id()}") - - -if __name__ == "__main__": - main() diff --git a/patterns/structural/adapter/real_world.py b/patterns/structural/adapter/real_world.py deleted file mode 100644 index 4f177ea..0000000 --- a/patterns/structural/adapter/real_world.py +++ /dev/null @@ -1,24 +0,0 @@ -"""``io.TextIOWrapper``: the stdlib's flagship adapter. - -It wraps a binary stream and exposes the text-file interface -- your code -reads ``str`` while bytes flow underneath. -""" - -from __future__ import annotations - -import io - - -def read_as_text(binary_stream: io.BytesIO) -> str: - """Adapt any binary stream to the text interface.""" - return io.TextIOWrapper(binary_stream, encoding="utf-8").read() - - -def main() -> None: - binary = io.BytesIO("héllo bytes\n".encode()) - text = read_as_text(binary) - print(f"adapted read -> {type(text).__name__}: {text!r}") - - -if __name__ == "__main__": - main() diff --git a/patterns/structural/adapter/tests/__init__.py b/patterns/structural/adapter/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/patterns/structural/adapter/tests/test_adapter.py b/patterns/structural/adapter/tests/test_adapter.py index 11e1be4..179c003 100644 --- a/patterns/structural/adapter/tests/test_adapter.py +++ b/patterns/structural/adapter/tests/test_adapter.py @@ -1,32 +1,43 @@ -"""Behavioral tests for all three adapter variants.""" +"""Behavioral tests for the DelegatingAdapter building block.""" -import io +from __future__ import annotations -from patterns.structural.adapter import naive, pythonic, real_world +import pytest +from patterns.structural.adapter import DelegatingAdapter -class TestNaive: - def test_adapter_translates_the_interface(self) -> None: - adapter = naive.SensorAdapter(naive.FahrenheitSensor()) - assert adapter.celsius() == 20.0 - def test_client_code_sees_only_the_target_interface(self) -> None: - assert naive.describe(naive.SensorAdapter(naive.FahrenheitSensor())) == "20.0 °C" +class Legacy: + def speed_mph(self) -> float: + return 62.0 + def vendor_id(self) -> str: + return "acme-42" -class TestPythonic: - def test_function_adapter(self) -> None: - read = pythonic.celsius_reader(pythonic.FahrenheitSensor()) - assert read() == 20.0 - def test_class_adapter_translates_and_forwards(self) -> None: - adapter = pythonic.CelsiusAdapter(pythonic.FahrenheitSensor()) - assert adapter.celsius() == 20.0 - assert adapter.vendor_id() == "acme-42" # forwarded untouched +class MetricAdapter(DelegatingAdapter[Legacy]): + def speed_kmh(self) -> float: + return self.adaptee.speed_mph() * 1.609344 + def vendor_id(self) -> str: # deliberately shadows the adaptee's method + return "translated" -class TestRealWorld: - def test_textiowrapper_adapts_bytes_to_str(self) -> None: - text = real_world.read_as_text(io.BytesIO("héllo\n".encode())) - assert text == "héllo\n" - assert isinstance(text, str) + +class TestDelegatingAdapter: + def test_translated_method_converts(self) -> None: + assert MetricAdapter(Legacy()).speed_kmh() == pytest.approx(99.78, abs=0.01) + + def test_untranslated_methods_forward_to_the_adaptee(self) -> None: + adapter = MetricAdapter(Legacy()) + assert adapter.speed_mph() == 62.0 + + def test_a_defined_method_always_beats_forwarding(self) -> None: + assert MetricAdapter(Legacy()).vendor_id() == "translated" + + def test_missing_names_raise_attribute_error_not_silence(self) -> None: + with pytest.raises(AttributeError): + MetricAdapter(Legacy()).warp_drive() + + def test_the_adaptee_stays_reachable(self) -> None: + legacy = Legacy() + assert MetricAdapter(legacy).adaptee is legacy diff --git a/patterns/structural/adapter/tests/test_payment_gateways.py b/patterns/structural/adapter/tests/test_payment_gateways.py new file mode 100644 index 0000000..3a0d58b --- /dev/null +++ b/patterns/structural/adapter/tests/test_payment_gateways.py @@ -0,0 +1,93 @@ +"""Behavioral tests for the payment-gateways mini-project. + +The point under test: checkout is written once against ``PaymentProcessor`` +and every vendor behaves identically from its seat — including failures. +""" + +from __future__ import annotations + +from collections.abc import Callable + +import pytest + +from patterns.structural.adapter.examples.payment_gateways.adapters import ( + PaymentProcessor, + PayPalAdapter, + StripeAdapter, +) +from patterns.structural.adapter.examples.payment_gateways.checkout import checkout +from patterns.structural.adapter.examples.payment_gateways.main import main +from patterns.structural.adapter.examples.payment_gateways.vendors import ( + PayPalLikeGateway, + StripeLikeClient, +) + + +def stripe() -> PaymentProcessor: + return StripeAdapter(StripeLikeClient()) + + +def paypal() -> PaymentProcessor: + return PayPalAdapter(PayPalLikeGateway()) + + +@pytest.mark.parametrize("make_processor", [stripe, paypal], ids=["stripe-like", "paypal-like"]) +class TestAnyVendor: + """One suite, every adapter: the client contract is vendor-independent. + + Adapters are built inside each test — construction at collection time + would share instances across the class and break if a vendor gains state. + """ + + def test_a_normal_charge_pays_the_order( + self, make_processor: Callable[[], PaymentProcessor] + ) -> None: + receipt = checkout("A-1", 2_499, make_processor()) + assert receipt.paid + assert receipt.reference != "" + + def test_a_huge_charge_is_declined_not_raised( + self, make_processor: Callable[[], PaymentProcessor] + ) -> None: + receipt = checkout("A-2", 999_999, make_processor()) + assert not receipt.paid + assert receipt.note != "" + + def test_a_zero_charge_is_refused(self, make_processor: Callable[[], PaymentProcessor]) -> None: + assert not checkout("A-3", 0, make_processor()).paid + + +class TestTranslationDetails: + def test_paypal_amounts_become_decimal_strings(self) -> None: + gateway = PayPalLikeGateway() + result = PayPalAdapter(gateway).charge(2_499, "usd") + assert result.reference == "PAYPAL-OK-24.99-USD" + + def test_paypal_exceptions_become_results(self) -> None: + result = PayPalAdapter(PayPalLikeGateway()).charge(999_999, "usd") + assert not result.ok + assert "DECLINED" in result.reason + + def test_stripe_currency_is_normalized_to_lowercase(self) -> None: + seen: list[str] = [] + + class RecordingStripe(StripeLikeClient): + def create_charge(self, amount_cents: int, currency: str) -> dict[str, str]: + seen.append(currency) + return super().create_charge(amount_cents, currency) + + StripeAdapter(RecordingStripe()).charge(2_499, "USD") + assert seen == ["usd"] + + def test_stripe_extras_stay_reachable_through_forwarding(self) -> None: + adapter = StripeAdapter(StripeLikeClient()) + assert "all systems normal" in adapter.diagnostics() + + +class TestDemo: + def test_main_charges_both_vendors(self, capsys: pytest.CaptureFixture[str]) -> None: + main() + out = capsys.readouterr().out + assert "stripe-like: A-1 paid=True" in out + assert "paypal-like: A-1 paid=True" in out + assert "paid=False" in out diff --git a/patterns/structural/bridge/README.md b/patterns/structural/bridge/README.md index b26d87f..984534d 100644 --- a/patterns/structural/bridge/README.md +++ b/patterns/structural/bridge/README.md @@ -14,34 +14,17 @@ stdlib_sightings: [logging.Logger with logging.Handler] # Bridge -## Problem - -Shapes (circle, square) need rendering backends (vector, raster). Inheriting -`VectorCircle`, `RasterCircle`, `VectorSquare`… multiplies the two axes into -one hierarchy — the same explosion Composition-Over-Inheritance warns about, -seen from the structural side. - -## Naive solution - -`naive.py` is the book's shape: an abstraction hierarchy (`Shape`) holding a -reference to an implementor hierarchy (`Renderer`), each extensible without -touching the other. - -## Pythonic solution - -Strip the ceremony and the Bridge is *composition with an injected -dependency* — which is why the verdict points there. `pythonic.py` bridges -notifiers (alerts, digests) over delivery transports (email, Slack, SMS): -the transport is a `Protocol`, notifiers are dataclasses holding one, and -"outage alert to Slack" is a constructor call, not a class. - -## In the wild - -`logging` is a Bridge you already use: `Logger` (the abstraction callers see) -delegates to interchangeable `Handler` implementations, and both sides grow -independently. - -## Verdict - -**Prefer an alternative** — plain composition/DI *is* the bridge. Keep the -lesson (name your axes), skip the taxonomy. +Two independent axes (what to do × how to carry it out) joined by one injected +reference, instead of a subclass per combination. **Verdict: prefer an +alternative** — composition with dependency injection *is* the bridge. + +| Where | What | +|---|---| +| [`pattern/`](pattern/) | The importable code: `Transport` protocol, transports, `AlertNotifier`, `DigestNotifier` | +| [`docs/`](docs/) | [Fundamentals](docs/fundamentals.md) · [Implementation guide](docs/implementation.md) · [External examples](docs/examples.md) | +| [`examples/notification_center/`](examples/notification_center/) | Mini-project: team alert/digest routing over per-team transports | +| [`tests/`](tests/) | Behavioral tests for the pattern and the mini-project | + +```bash +uv run python -m patterns.structural.bridge.examples.notification_center.main +``` diff --git a/patterns/structural/bridge/__init__.py b/patterns/structural/bridge/__init__.py index 7676ef1..3e44219 100644 --- a/patterns/structural/bridge/__init__.py +++ b/patterns/structural/bridge/__init__.py @@ -1 +1,6 @@ -"""Bridge: decouple abstraction from implementation. Verdict: it is composition + DI.""" +from .pattern.bridge import AlertNotifier as AlertNotifier +from .pattern.bridge import DigestNotifier as DigestNotifier +from .pattern.bridge import EmailTransport as EmailTransport +from .pattern.bridge import SlackTransport as SlackTransport +from .pattern.bridge import SmsTransport as SmsTransport +from .pattern.bridge import Transport as Transport diff --git a/patterns/structural/bridge/docs/examples.md b/patterns/structural/bridge/docs/examples.md new file mode 100644 index 0000000..560c225 --- /dev/null +++ b/patterns/structural/bridge/docs/examples.md @@ -0,0 +1,35 @@ +# Bridge — where it lives outside this repo + +Cited, real implementations to study (or point an agent at) when designing or +reviewing bridge-shaped code. + +## Python standard library + +- **`logging.Logger` × `logging.Handler`.** The logger is the abstraction + callers hold; handlers are the interchangeable implementation hierarchy — + one `logger.info()` call fans out to console, file, or syslog backends, and + both sides grow independently. + [docs.python.org/3/library/logging.html](https://docs.python.org/3/library/logging.html) + +## Major ecosystems + +- **Matplotlib figures over rendering backends.** The `Figure`/`Artist` + layer is one stable abstraction; Agg, SVG, PDF, and GUI canvases are + swappable implementors selected at runtime — the canonical large-scale + bridge. + [matplotlib.org/stable/users/explain/figure/backends.html](https://matplotlib.org/stable/users/explain/figure/backends.html) +- **Django ORM over database backends.** One `QuerySet` abstraction compiles + through per-database implementor packages (PostgreSQL, MySQL, SQLite…); + application code never learns which. + [docs.djangoproject.com/en/stable/ref/databases/](https://docs.djangoproject.com/en/stable/ref/databases/) +- **SQLAlchemy `Engine` over `Dialect`/DBAPI.** The same split one level + down: Core's execution abstraction bridges to per-driver dialects. + [docs.sqlalchemy.org/en/20/core/engines.html](https://docs.sqlalchemy.org/en/20/core/engines.html) + +## What to notice across all of them + +The implementor interface is always *narrow and stable* — `Handler.emit`, +the backend canvas API, the dialect contract — while both sides multiply +freely behind it. When reviewing bridge-shaped code, check which axis a new +requirement lands on: if most changes touch both sides at once, the axes were +drawn in the wrong place. diff --git a/patterns/structural/bridge/docs/fundamentals.md b/patterns/structural/bridge/docs/fundamentals.md new file mode 100644 index 0000000..d283c17 --- /dev/null +++ b/patterns/structural/bridge/docs/fundamentals.md @@ -0,0 +1,80 @@ +# Bridge — fundamentals + +## Intent + +Decouple an abstraction from its implementation so the two can vary +independently. When one family of things (notifiers, shapes, reports) must +work over another family (transports, renderers, backends), inheritance +multiplies the axes into one hierarchy; the bridge keeps them as two, joined +by a single reference. + +## Participants + +| Role | Classic (GoF) form | Python form | +|---|---|---| +| Abstraction | Base class holding an implementor reference | A dataclass holding an injected dependency | +| RefinedAbstraction | Subclasses adding behavior | More dataclasses on the same bridge | +| Implementor | Abstract implementation interface | A `Protocol` — `Transport` in [`pattern/bridge.py`](../pattern/bridge.py) | +| ConcreteImplementor | Subclasses per backend | Any object satisfying the Protocol | + +## Mechanism + +1. Name the two axes explicitly (what varies about *what you do* vs *how it + is carried out*). +2. Type the implementor axis as a small interface the abstraction calls. +3. The abstraction holds one implementor, received at construction. +4. Each axis now grows without touching the other: M abstractions + N + implementors give M × N combinations from M + N classes. + +## The classic form, and what Python absorbs + +The textbook bridge builds two parallel class hierarchies and an abstract +base on each side: + +```python +class Renderer(ABC): # Implementor interface + @abstractmethod + def render_circle(self, radius: float) -> str: ... + + +class VectorRenderer(Renderer): ... # one subclass per backend + + +class RasterRenderer(Renderer): ... + + +class Shape(ABC): # Abstraction holds the bridge + def __init__(self, renderer: Renderer) -> None: + self.renderer = renderer + + +class Circle(Shape): + def draw(self) -> str: + return self.renderer.render_circle(self.radius) +``` + +Python absorbs nearly all of it: the implementor interface becomes a +`Protocol` (no base class for backends to inherit), the abstraction becomes a +frozen dataclass, and "connect abstraction to implementation" is just an +injected attribute. What survives is the design move, not the class diagram: +**name the two axes and join them with one reference** instead of subclassing +across both. + +## When to use it + +- Two independent dimensions are multiplying subclasses + (`VectorCircle`, `RasterCircle`, `VectorSquare`…). +- A stable front must run over swappable backends, and both sides are still + growing. + +## When not to use it + +- Only one axis actually varies — plain composition already covers it, no + naming ceremony needed. +- The "implementations" are one function each — pass callables, skip the + Protocol. + +## Verdict: prefer an alternative + +Composition with an injected dependency *is* the bridge in Python. Keep the +lesson (two named axes, one reference), skip the four-role taxonomy. diff --git a/patterns/structural/bridge/docs/implementation.md b/patterns/structural/bridge/docs/implementation.md new file mode 100644 index 0000000..915f054 --- /dev/null +++ b/patterns/structural/bridge/docs/implementation.md @@ -0,0 +1,81 @@ +# Bridge — putting it into a system + +## The smell it fixes + +A class name with two axes baked into it — and a hierarchy that doubles every +time either axis grows: + +```python +class EmailAlert: ... + + +class SlackAlert: ... + + +class SmsAlert: ... + + +class EmailDigest: ... + + +class SlackDigest: ... + + +class SmsDigest: ... # 2 kinds x 3 transports = 6 classes, and counting +``` + +Adding WhatsApp means three new classes; adding a weekly-report kind means +four. The bridge cuts the product into a sum: kinds hold a transport, and +"Slack digest" becomes `DigestNotifier(SlackTransport(), "#ops")`. + +## Steps + +1. **Find the two axes.** Ask "what varies about what we *say*?" and "what + varies about how it's *delivered*?" If you can't fill both blanks, you + don't need a bridge. +2. **Type the implementor axis as a `Protocol`.** Keep it minimal — one or + two methods the abstraction actually calls (`deliver(recipient, text)`). +3. **Make abstractions hold, not inherit.** Each kind is a dataclass with a + `transport` field; behavior methods call through it. +4. **Inject at the edge.** Which transport a given notifier gets is wiring — + configuration, DI, or a registry — never a hard-coded constructor default. +5. **Test the axes separately, then one combination.** Transports get their + own tests; kinds are tested against a recording fake; a single M × N + sweep pins that any pair composes. + +```python +from patterns.structural.bridge import AlertNotifier, SlackTransport + +notifier = AlertNotifier(SlackTransport(), "#ops") # any kind x any transport +notifier.alert("critical", "db pool exhausted") +``` + +## Python idioms that keep it small + +- **`Protocol` on the implementor axis** — backends satisfy it structurally; + third parties can add transports without importing your base class. +- **Frozen dataclasses for abstractions** — the bridge reference is visible + in the signature and immutable after wiring. +- **A callable as the degenerate implementor.** When the interface is one + method, `Callable[[str, str], None]` may replace the Protocol entirely. + +## Pitfalls + +- **Bridging one axis.** If every "abstraction" is the same class with a + different name, you only had implementors — use plain injection and stop. +- **A fat implementor interface.** The Protocol should carry what all + backends share; per-backend extras belong on the backend, reached + explicitly, or the axes are lying. +- **Leaking backend types through the abstraction** (returning a Slack + response object from `alert`) re-couples what the bridge separated. +- **Hard-coding a default transport** in the abstraction — it silently turns + the bridge back into a single-axis class. + +## Worked example + +[`examples/notification_center/`](../examples/notification_center/) routes +alerts and digests for three teams over three transports — run it with: + +```bash +uv run python -m patterns.structural.bridge.examples.notification_center.main +``` diff --git a/patterns/structural/bridge/examples/notification_center/center.py b/patterns/structural/bridge/examples/notification_center/center.py new file mode 100644 index 0000000..456d979 --- /dev/null +++ b/patterns/structural/bridge/examples/notification_center/center.py @@ -0,0 +1,57 @@ +"""Routing app over the bridge: teams choose transports, code stays put. + +Each team registers a channel — a preferred transport plus an address. The +center fans alerts and digests out to every team through whatever transport +each one picked. Adding a transport touches zero routing code; adding a +notifier kind touches zero transports. That independence *is* the bridge. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from patterns.structural.bridge.pattern import AlertNotifier, DigestNotifier, Transport + + +@dataclass(frozen=True) +class TeamChannel: + """One team's delivery preference.""" + + team: str + transport: Transport + address: str + + +class NotificationCenter: + """Holds the routing table; notifiers do the talking.""" + + def __init__(self) -> None: + self._channels: dict[str, TeamChannel] = {} + + def register(self, channel: TeamChannel, *, replace: bool = False) -> None: + """Add a team's channel; refuses to silently drop an existing one. + + Pass ``replace=True`` to intentionally swap a team's transport. + """ + if channel.team in self._channels and not replace: + raise ValueError( + f"team {channel.team!r} already has a channel; pass replace=True to swap it" + ) + self._channels[channel.team] = channel + + @property + def teams(self) -> list[str]: + return sorted(self._channels) + + def alert(self, teams: list[str], severity: str, message: str) -> None: + """Page specific teams through their chosen transports.""" + for team in teams: + if team not in self._channels: + raise KeyError(f"unknown team {team!r}; registered teams: {self.teams}") + channel = self._channels[team] + AlertNotifier(channel.transport, channel.address).alert(severity, message) + + def broadcast_digest(self, items: list[str]) -> None: + """Every team gets the digest, each on its own transport.""" + for channel in self._channels.values(): + DigestNotifier(channel.transport, channel.address).digest(items) diff --git a/patterns/structural/bridge/examples/notification_center/main.py b/patterns/structural/bridge/examples/notification_center/main.py new file mode 100644 index 0000000..a4b89f3 --- /dev/null +++ b/patterns/structural/bridge/examples/notification_center/main.py @@ -0,0 +1,28 @@ +"""Demo: one incident and one digest, three teams, three transports.""" + +from __future__ import annotations + +from patterns.structural.bridge.examples.notification_center.center import ( + NotificationCenter, + TeamChannel, +) +from patterns.structural.bridge.pattern import EmailTransport, SlackTransport, SmsTransport + + +def main() -> None: + slack, email, sms = SlackTransport(), EmailTransport(), SmsTransport() + + center = NotificationCenter() + center.register(TeamChannel("platform", slack, "#platform-ops")) + center.register(TeamChannel("payments", sms, "+1-555-0100")) + center.register(TeamChannel("support", email, "support@example.com")) + + center.alert(["platform", "payments"], "critical", "db connection pool exhausted") + center.broadcast_digest(["3 deploys", "1 rollback", "error budget at 92%"]) + + for line in (*slack.posts, *sms.messages, *email.outbox): + print(line) + + +if __name__ == "__main__": + main() diff --git a/patterns/structural/bridge/naive.py b/patterns/structural/bridge/naive.py deleted file mode 100644 index 6080d6a..0000000 --- a/patterns/structural/bridge/naive.py +++ /dev/null @@ -1,54 +0,0 @@ -"""The Gang of Four Bridge, translated literally. - -Abstraction hierarchy (Shape) holds a reference to the implementor -hierarchy (Renderer); each side can grow without touching the other. -""" - -from __future__ import annotations - -from abc import ABC, abstractmethod - - -class Renderer(ABC): - """The implementor interface.""" - - @abstractmethod - def render_circle(self, radius: float) -> str: ... - - -class VectorRenderer(Renderer): - def render_circle(self, radius: float) -> str: - return f"" - - -class RasterRenderer(Renderer): - def render_circle(self, radius: float) -> str: - return f"pixels for a circle of radius {radius}" - - -class Shape(ABC): - """The abstraction: holds the bridge reference.""" - - def __init__(self, renderer: Renderer) -> None: - self.renderer = renderer - - @abstractmethod - def draw(self) -> str: ... - - -class Circle(Shape): - def __init__(self, renderer: Renderer, radius: float) -> None: - super().__init__(renderer) - self.radius = radius - - def draw(self) -> str: - return self.renderer.render_circle(self.radius) - - -def main() -> None: - print(Circle(VectorRenderer(), 2.0).draw()) - print(Circle(RasterRenderer(), 2.0).draw()) - - -if __name__ == "__main__": - main() diff --git a/patterns/structural/bridge/pattern/__init__.py b/patterns/structural/bridge/pattern/__init__.py new file mode 100644 index 0000000..3f25a9c --- /dev/null +++ b/patterns/structural/bridge/pattern/__init__.py @@ -0,0 +1,6 @@ +from .bridge import AlertNotifier as AlertNotifier +from .bridge import DigestNotifier as DigestNotifier +from .bridge import EmailTransport as EmailTransport +from .bridge import SlackTransport as SlackTransport +from .bridge import SmsTransport as SmsTransport +from .bridge import Transport as Transport diff --git a/patterns/structural/bridge/pythonic.py b/patterns/structural/bridge/pattern/bridge.py similarity index 67% rename from patterns/structural/bridge/pythonic.py rename to patterns/structural/bridge/pattern/bridge.py index 2d27762..7d0c45b 100644 --- a/patterns/structural/bridge/pythonic.py +++ b/patterns/structural/bridge/pattern/bridge.py @@ -1,8 +1,9 @@ -"""The Bridge without ceremony: composition plus an injected dependency. +"""The Bridge without ceremony: composition plus an injected implementor. -The two real axes: what to say (alert severities, digest summaries) and how -to deliver it (email, Slack, SMS). M notifiers + N transports cover M x N -combinations, and "send the outage alert to Slack" is a constructor call. +Two independent axes — what to say (alert, digest) and how to deliver it +(email, Slack, SMS). The transport is a ``Protocol`` injected into dataclass +notifiers: M notifiers + N transports cover M x N combinations, and "outage +alert to Slack" is a constructor call, not a class. """ from __future__ import annotations @@ -55,7 +56,7 @@ def alert(self, severity: str, message: str) -> None: @dataclass(frozen=True) class DigestNotifier: - """A second abstraction on the same bridge -- no transport changes needed.""" + """A second abstraction on the same bridge — no transport changes needed.""" transport: Transport recipient: str @@ -63,16 +64,3 @@ class DigestNotifier: def digest(self, items: list[str]) -> None: summary = f"{len(items)} updates: " + "; ".join(items) self.transport.deliver(self.recipient, summary) - - -def main() -> None: - slack = SlackTransport() - email = EmailTransport() - AlertNotifier(slack, "#ops").alert("critical", "db connection pool exhausted") - DigestNotifier(email, "team@example.com").digest(["3 deploys", "1 rollback"]) - print(slack.posts) - print(email.outbox) - - -if __name__ == "__main__": - main() diff --git a/patterns/structural/bridge/real_world.py b/patterns/structural/bridge/real_world.py deleted file mode 100644 index cb7b6c9..0000000 --- a/patterns/structural/bridge/real_world.py +++ /dev/null @@ -1,39 +0,0 @@ -"""``logging``: a Bridge in daily use. - -Logger is the abstraction callers hold; Handlers are the interchangeable -implementation hierarchy on the far side of the bridge. -""" - -from __future__ import annotations - -import logging - - -def logger_with_two_backends(name: str, sink_a: list[str], sink_b: list[str]) -> logging.Logger: - """One abstraction, two implementations receiving the same calls.""" - - def handler_for(sink: list[str]) -> logging.Handler: - class ListHandler(logging.Handler): - def emit(self, record: logging.LogRecord) -> None: - sink.append(record.getMessage()) - - return ListHandler() - - logger = logging.getLogger(name) - logger.handlers.clear() - logger.propagate = False - logger.setLevel(logging.INFO) - logger.addHandler(handler_for(sink_a)) - logger.addHandler(handler_for(sink_b)) - return logger - - -def main() -> None: - a: list[str] = [] - b: list[str] = [] - logger_with_two_backends("bridge-demo", a, b).info("one call") - print(f"backend a: {a}, backend b: {b}") - - -if __name__ == "__main__": - main() diff --git a/patterns/structural/bridge/tests/__init__.py b/patterns/structural/bridge/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/patterns/structural/bridge/tests/test_bridge.py b/patterns/structural/bridge/tests/test_bridge.py index 82c37df..0b6ab10 100644 --- a/patterns/structural/bridge/tests/test_bridge.py +++ b/patterns/structural/bridge/tests/test_bridge.py @@ -1,49 +1,57 @@ -"""Behavioral tests for all three bridge variants.""" - -from patterns.structural.bridge import naive, pythonic, real_world - - -class TestNaive: - def test_same_abstraction_different_implementations(self) -> None: - assert naive.Circle(naive.VectorRenderer(), 2.0).draw() == "" - assert "pixels" in naive.Circle(naive.RasterRenderer(), 2.0).draw() - - -class TestPythonic: - def test_one_abstraction_over_two_transports(self) -> None: - slack, email = pythonic.SlackTransport(), pythonic.EmailTransport() - pythonic.AlertNotifier(slack, "#ops").alert("critical", "disk full") - pythonic.AlertNotifier(email, "ops@x.com").alert("critical", "disk full") - assert slack.posts == ["slack #ops: [CRITICAL] disk full"] - assert email.outbox == ["email to ops@x.com: [CRITICAL] disk full"] - - def test_two_abstractions_over_one_transport(self) -> None: - slack = pythonic.SlackTransport() - pythonic.AlertNotifier(slack, "#ops").alert("warn", "slow queries") - pythonic.DigestNotifier(slack, "#ops").digest(["a", "b"]) - assert len(slack.posts) == 2 # both sides vary independently - - def test_transport_specific_behavior_stays_in_the_transport(self) -> None: - sms = pythonic.SmsTransport() - pythonic.AlertNotifier(sms, "+1555").alert("info", "x" * 200) - assert len(sms.messages[0]) <= len("sms +1555: ") + sms.MAX_LEN - - def test_any_duck_typed_transport_works(self) -> None: - class Collector: - def __init__(self) -> None: - self.seen: list[str] = [] - +"""Behavioral tests for the bridge building block: axes compose freely.""" + +from __future__ import annotations + +from patterns.structural.bridge import ( + AlertNotifier, + DigestNotifier, + EmailTransport, + SlackTransport, + SmsTransport, + Transport, +) + + +class TestAxesCompose: + def test_any_notifier_works_over_any_transport(self) -> None: + # 2 notifiers x 3 transports: every combination must actually deliver. + email, slack, sms = EmailTransport(), SlackTransport(), SmsTransport() + channels: list[tuple[Transport, list[str]]] = [ + (email, email.outbox), + (slack, slack.posts), + (sms, sms.messages), + ] + for transport, delivered in channels: + AlertNotifier(transport, "ops").alert("critical", "disk full") + DigestNotifier(transport, "ops").digest(["a", "b"]) + assert len(delivered) == 2 + assert "[CRITICAL] disk full" in delivered[0] + assert "2 updates" in delivered[1] + + def test_alert_formats_severity_upfront(self) -> None: + slack = SlackTransport() + AlertNotifier(slack, "#ops").alert("critical", "db pool exhausted") + assert slack.posts == ["slack #ops: [CRITICAL] db pool exhausted"] + + def test_digest_summarizes_item_count(self) -> None: + email = EmailTransport() + DigestNotifier(email, "team@example.com").digest(["3 deploys", "1 rollback"]) + assert email.outbox == ["email to team@example.com: 2 updates: 3 deploys; 1 rollback"] + + def test_sms_transport_truncates_long_texts(self) -> None: + sms = SmsTransport() + AlertNotifier(sms, "+15550100").alert("info", "x" * 200) + (message,) = sms.messages + assert len(message) <= len("sms +15550100: ") + SmsTransport.MAX_LEN + + +class TestBridgeIsOneReference: + def test_a_new_transport_needs_no_notifier_changes(self) -> None: + received: list[tuple[str, str]] = [] + + class PagerTransport: def deliver(self, recipient: str, text: str) -> None: - self.seen.append(text) - - collector = Collector() - pythonic.AlertNotifier(collector, "anyone").alert("info", "hello") - assert collector.seen == ["[INFO] hello"] - + received.append((recipient, text)) -class TestRealWorld: - def test_one_logger_call_reaches_both_implementations(self) -> None: - a: list[str] = [] - b: list[str] = [] - real_world.logger_with_two_backends("bridge-test", a, b).info("msg") - assert a == ["msg"] and b == ["msg"] + AlertNotifier(PagerTransport(), "oncall").alert("critical", "it's down") + assert received == [("oncall", "[CRITICAL] it's down")] diff --git a/patterns/structural/bridge/tests/test_notification_center.py b/patterns/structural/bridge/tests/test_notification_center.py new file mode 100644 index 0000000..732186c --- /dev/null +++ b/patterns/structural/bridge/tests/test_notification_center.py @@ -0,0 +1,68 @@ +"""Behavioral tests for the notification-center mini-project.""" + +from __future__ import annotations + +import pytest + +from patterns.structural.bridge.examples.notification_center.center import ( + NotificationCenter, + TeamChannel, +) +from patterns.structural.bridge.examples.notification_center.main import main +from patterns.structural.bridge.pattern import EmailTransport, SlackTransport, SmsTransport + + +def build_center() -> tuple[NotificationCenter, SlackTransport, SmsTransport, EmailTransport]: + slack, sms, email = SlackTransport(), SmsTransport(), EmailTransport() + center = NotificationCenter() + center.register(TeamChannel("platform", slack, "#platform-ops")) + center.register(TeamChannel("payments", sms, "+1-555-0100")) + center.register(TeamChannel("support", email, "support@example.com")) + return center, slack, sms, email + + +class TestRouting: + def test_alerts_reach_only_the_paged_teams(self) -> None: + center, slack, sms, email = build_center() + center.alert(["platform"], "critical", "db pool exhausted") + assert len(slack.posts) == 1 + assert sms.messages == [] + assert email.outbox == [] + + def test_each_team_hears_through_its_own_transport(self) -> None: + center, slack, sms, _ = build_center() + center.alert(["platform", "payments"], "critical", "db pool exhausted") + assert "slack #platform-ops" in slack.posts[0] + assert "sms +1-555-0100" in sms.messages[0] + + def test_digest_broadcasts_to_every_registered_team(self) -> None: + center, slack, sms, email = build_center() + center.broadcast_digest(["3 deploys"]) + assert len(slack.posts) == len(sms.messages) == len(email.outbox) == 1 + + def test_reregistering_a_team_requires_explicit_replace(self) -> None: + center, slack, _, email = build_center() + with pytest.raises(ValueError, match="platform"): + center.register(TeamChannel("platform", email, "platform@example.com")) + center.register(TeamChannel("platform", email, "platform@example.com"), replace=True) + center.alert(["platform"], "warn", "retrying") + assert slack.posts == [] + assert "platform@example.com" in email.outbox[0] + + def test_alerting_an_unregistered_team_names_the_known_ones(self) -> None: + center, *_ = build_center() + with pytest.raises(KeyError, match="payments"): + center.alert(["nope"], "critical", "who hears this?") + + def test_teams_lists_registrations(self) -> None: + center, *_ = build_center() + assert center.teams == ["payments", "platform", "support"] + + +class TestDemo: + def test_main_prints_all_three_transports(self, capsys: pytest.CaptureFixture[str]) -> None: + main() + out = capsys.readouterr().out + assert "slack #platform-ops" in out + assert "sms +1-555-0100" in out + assert "email to support@example.com" in out diff --git a/patterns/structural/composite/README.md b/patterns/structural/composite/README.md index f05657c..1fbcc6f 100644 --- a/patterns/structural/composite/README.md +++ b/patterns/structural/composite/README.md @@ -14,33 +14,17 @@ stdlib_sightings: [pathlib.Path, xml.etree.ElementTree.Element] # Composite -## Problem - -File systems, GUI widget trees, org charts: structures where a container holds -items that may themselves be containers, and callers want one operation — -size, render, total — that works on any node without asking which kind it is. - -## Naive solution - -`naive.py` mirrors the book: an abstract `Graphic` component, a `Circle` leaf, -and a `Group` composite whose operation recurses over its children. Note the -book's contested move — putting `add`/`remove` on the *component* interface so -leaves must refuse them at runtime. - -## Pythonic solution - -Duck typing removes the need for the abstract base: a leaf and a container -that both offer `total()` are already substitutable. `pythonic.py` keeps a -`Protocol` for the type checker only, and leaves child management where it -honestly belongs — on the container. - -## In the wild - -`pathlib.Path` is the classic: files and directories share one interface, and -`iterdir()`/`rglob()` recurse the composite. `xml.etree.ElementTree.Element` -is a composite of elements all the way down. - -## Verdict - -**Pythonic.** Trees are everywhere and this is the right shape for them; just -keep the leaf's interface honest. +Part-whole trees where a leaf and a whole subtree answer the same operation — +and only containers manage children. **Verdict: pythonic** — the right shape +for trees; keep the leaf's interface honest. + +| Where | What | +|---|---| +| [`pattern/`](pattern/) | The importable code: `Composite`, `HasTotal` | +| [`docs/`](docs/) | [Fundamentals](docs/fundamentals.md) · [Implementation guide](docs/implementation.md) · [External examples](docs/examples.md) | +| [`examples/org_chart/`](examples/org_chart/) | Mini-project: headcount/cost rollups over a nested org chart | +| [`tests/`](tests/) | Behavioral tests for the pattern and the mini-project | + +```bash +uv run python -m patterns.structural.composite.examples.org_chart.main +``` diff --git a/patterns/structural/composite/__init__.py b/patterns/structural/composite/__init__.py index 5f8a108..0f9cbd2 100644 --- a/patterns/structural/composite/__init__.py +++ b/patterns/structural/composite/__init__.py @@ -1 +1,2 @@ -"""Composite: one interface for an object and a tree of objects.""" +from .pattern.tree import Composite as Composite +from .pattern.tree import HasTotal as HasTotal diff --git a/patterns/structural/composite/docs/examples.md b/patterns/structural/composite/docs/examples.md new file mode 100644 index 0000000..089fb84 --- /dev/null +++ b/patterns/structural/composite/docs/examples.md @@ -0,0 +1,40 @@ +# Composite — where it lives outside this repo + +Cited, real implementations to study (or point an agent at) when designing or +reviewing composite-shaped code. + +## Python standard library + +- **`pathlib.Path`** — files and directories behind one interface; + `iterdir()` walks one level, `rglob()` recurses the whole composite. The + operations every node answers (`exists()`, `stat()`, `name`) coexist with + directory-only ones (`iterdir()`), an interface-honesty compromise worth + studying. [docs.python.org/3/library/pathlib.html](https://docs.python.org/3/library/pathlib.html) +- **`xml.etree.ElementTree.Element`** — elements holding child elements, one + API all the way down; `iter()` is the uniform deep traversal. + [docs.python.org/3/library/xml.etree.elementtree.html](https://docs.python.org/3/library/xml.etree.elementtree.html) +- **`ast`** — Python source as a uniform node tree; `ast.walk` and + `NodeVisitor` traverse without asking node kinds for structure. + [docs.python.org/3/library/ast.html](https://docs.python.org/3/library/ast.html) + +## Major ecosystems + +- **Qt object trees (PyQt/PySide).** Every `QObject` may parent children; + ownership, event propagation, and deletion all recurse the tree — a + composite carrying lifecycle semantics, not just totals. + [doc.qt.io/qt-6/objecttrees.html](https://doc.qt.io/qt-6/objecttrees.html) + +## Design discussion + +- **python-patterns.guide, Composite chapter** — the argument this unit's + caveat encodes: side with interface honesty (child management on + containers only) over the classic form's uniform-but-lying component. + [python-patterns.guide/gang-of-four/composite/](https://python-patterns.guide/gang-of-four/composite/) + +## What to notice across all of them + +None of the production composites make leaves carry child management: +`ElementTree` leaves are just elements with no children, `ast` leaves are +nodes whose fields hold no lists, and Qt children live on the parent. The +uniformity that matters to callers is the *operation* (walk, size, iterate), +not the mutation API — which is exactly the guide's honesty argument. diff --git a/patterns/structural/composite/docs/fundamentals.md b/patterns/structural/composite/docs/fundamentals.md new file mode 100644 index 0000000..b80d978 --- /dev/null +++ b/patterns/structural/composite/docs/fundamentals.md @@ -0,0 +1,73 @@ +# Composite — fundamentals + +## Intent + +Compose objects into part-whole trees, and let clients treat a single object +and a whole composition through one interface. A caller holding "something +with a size" should never need to ask whether it holds a file or a directory +of ten thousand of them. + +## Participants + +| Role | Classic (GoF) form | Python form | +|---|---|---| +| Component | Abstract base declaring the operation — and, contentiously, child management | A `Protocol` with just the operation (`HasTotal` in [`pattern/tree.py`](../pattern/tree.py)) — or nothing at all, duck typing suffices | +| Leaf | Subclass that must *refuse* `add()` at runtime | A plain frozen dataclass with the operation and no child API | +| Composite | Subclass holding children, recursing the operation | `Composite`: children + `add`/`remove` + the same operation | +| Client | Calls the component interface | Same — never asks a node which kind it is | + +## Mechanism + +1. Leaves and containers share one operation (`total()`). +2. A container's implementation combines its children's results; children may + themselves be containers, so the recursion walks the whole tree. +3. Child management (`add`/`remove`) exists only on containers. +4. The client holds "a node" and calls the operation — uniformly at every + depth. + +## The classic form, and what Python absorbs + +The textbook version declares the operation *and child management* on the +abstract component, so leaves must refuse children at runtime: + +```python +class Graphic(ABC): + @abstractmethod + def render(self, indent: int = 0) -> str: ... + + def add(self, child: Graphic) -> None: # on EVERY node... + raise TypeError("cannot hold children") # ...so leaves must refuse + + +class Circle(Graphic): # leaf: inherits the trap + def render(self, indent: int = 0) -> str: ... + + +class Group(Graphic): # composite: overrides add() + ... +``` + +Python absorbs the base class entirely: a leaf and a container that both +offer `total()` are already substitutable, so the shared ABC becomes at most +a `Protocol` for the type checker. That also dissolves the book's dilemma — +python-patterns.guide argues for **interface honesty over uniformity** +([guide chapter](https://python-patterns.guide/gang-of-four/composite/)): +with no forced base class, `add()` simply lives where it's true, on the +container, and a `TypeError`-at-runtime trap never exists. + +## When to use it + +- Genuine part-whole trees: file systems, org charts, GUI widget trees, + nested groupings — anywhere "a thing or a group of things" recurses. +- Callers need one aggregate operation over arbitrary nesting. + +## When not to use it + +- The structure is flat — a list and a `sum()` need no pattern. +- Nodes need many unrelated operations — consider keeping the tree as data + and writing traversals separately (see the Visitor unit's verdict). + +## Verdict: pythonic + +Trees are everywhere and this is the right shape for them. Keep the leaf's +interface honest, and share a base type only when it earns its keep. diff --git a/patterns/structural/composite/docs/implementation.md b/patterns/structural/composite/docs/implementation.md new file mode 100644 index 0000000..468877e --- /dev/null +++ b/patterns/structural/composite/docs/implementation.md @@ -0,0 +1,90 @@ +# Composite — putting it into a system + +## The smell it fixes + +Type-switching every time a structure nests: + +```python +def org_cost(node): + if isinstance(node, Employee): + return node.salary + if isinstance(node, Department): + total = 0 + for member in node.members: + total += org_cost(member) # and every new node kind edits this + return total +``` + +Every aggregate operation re-implements the traversal, and every new node +kind edits every operation. The composite moves the recursion into the +container once; operations become one method both node kinds answer. + +## Steps + +1. **Pick the rollup value type.** One number is fine; several measures that + should travel together become a small frozen dataclass with `__add__` + (the org example's `OrgMetrics` carries headcount *and* cost in one pass). +2. **Make leaves plain frozen dataclasses** with the operation and nothing + else — no child API, ever. +3. **Use `Composite` for containers** (or subclass it to add a name and + domain methods). Pass its `combine` explicitly — `sum` with a `start` + value is usually all you need. +4. **Keep child mutation on the container** and let `remove` raise on absent + children — silent no-ops hide reorg bugs. +5. **Test the rollups through nesting**, not just one level: build a small + tree in a fixture, assert totals at every depth, and assert leaves have + no `add` (interface honesty is a testable property — + `not hasattr(leaf, "add")`). + +```python +from dataclasses import dataclass + +from patterns.structural.composite import Composite + + +@dataclass(frozen=True) +class Task: # a leaf: totals itself, has no child API + hours: int + + def total(self) -> int: + return self.hours + + +team = Composite(sum, [Task(3), Task(5)]) +project = Composite(sum, [team, Task(8)]) +assert project.total() == 16 +``` + +## Python idioms that keep it small + +- **`Protocol` instead of an ABC** — the type checker enforces the shared + operation; nodes stay free of inheritance. +- **Frozen dataclass leaves** — hashable, comparable, safe to share between + branches. +- **A metrics dataclass with `__add__`** rolls several measures up in one + traversal instead of one walk per measure. +- **Generators for traversal**: `iter(composite)` walks one level; recursive + generators (`yield from`) give you `rglob`-style deep iteration when you + need node access rather than totals. + +## Pitfalls + +- **Child management on the component interface** — the classic form's trap: + leaves inherit an `add()` they must refuse at runtime. Keep it on the + container only. +- **Parent pointers by default.** They turn a value tree into a mutable graph + with invalidation puzzles; add them only when navigation truly needs them. +- **Unbounded recursion trust.** Deep or user-built trees can hit recursion + limits and cycles; if inputs are hostile, traverse iteratively and track + visited nodes. +- **Mixing structure and presentation** (a `render()` that formats *and* + recurses *and* sorts) — keep the tree operation minimal and format outside. + +## Worked example + +[`examples/org_chart/`](../examples/org_chart/) rolls headcount and annual +cost up a nested org chart — run it with: + +```bash +uv run python -m patterns.structural.composite.examples.org_chart.main +``` diff --git a/patterns/structural/composite/examples/org_chart/main.py b/patterns/structural/composite/examples/org_chart/main.py new file mode 100644 index 0000000..835f273 --- /dev/null +++ b/patterns/structural/composite/examples/org_chart/main.py @@ -0,0 +1,25 @@ +"""Demo: headcount and cost rollups over a nested org chart.""" + +from __future__ import annotations + +from patterns.structural.composite.examples.org_chart.org import Department, Employee + + +def main() -> None: + platform = Department( + "platform", + [Employee("Ada", 190_000), Employee("Grace", 185_000)], + ) + payments = Department( + "payments", + [Employee("Alan", 175_000), platform], # a department inside a department + ) + company = Department("engineering", [payments, Employee("Barbara", 210_000)]) + + for unit in (platform, payments, company): + metrics = unit.total() + print(f"{unit.name}: {metrics.headcount} people, ${metrics.annual_cost:,}/yr") + + +if __name__ == "__main__": + main() diff --git a/patterns/structural/composite/examples/org_chart/org.py b/patterns/structural/composite/examples/org_chart/org.py new file mode 100644 index 0000000..49f3e34 --- /dev/null +++ b/patterns/structural/composite/examples/org_chart/org.py @@ -0,0 +1,51 @@ +"""Departments hold teams hold people; one ``total()`` serves every level. + +The interface-honesty rule in practice: ``Employee`` is a frozen leaf with no +child management — only ``Department`` (a ``Composite``) can ``add``/``remove``. +Both answer ``total()``, so headcount and cost roll up through any nesting +without ever asking a node what kind it is. +""" + +from __future__ import annotations + +from collections.abc import Iterable +from dataclasses import dataclass + +from patterns.structural.composite.pattern import Composite, HasTotal + + +@dataclass(frozen=True) +class OrgMetrics: + """The rollup value: both measures travel up the tree together.""" + + headcount: int + annual_cost: int + + def __add__(self, other: OrgMetrics) -> OrgMetrics: + return OrgMetrics(self.headcount + other.headcount, self.annual_cost + other.annual_cost) + + +ZERO = OrgMetrics(0, 0) + + +def combine(parts: Iterable[OrgMetrics]) -> OrgMetrics: + return sum(parts, start=ZERO) + + +@dataclass(frozen=True) +class Employee: + """A leaf. No ``add()`` — people honestly cannot hold reports here.""" + + name: str + salary: int + + def total(self) -> OrgMetrics: + return OrgMetrics(headcount=1, annual_cost=self.salary) + + +class Department(Composite[OrgMetrics]): + """A named container node; child management lives here, where it belongs.""" + + def __init__(self, name: str, members: Iterable[HasTotal[OrgMetrics]] = ()) -> None: + super().__init__(combine, members) + self.name = name diff --git a/patterns/structural/composite/naive.py b/patterns/structural/composite/naive.py deleted file mode 100644 index de1738d..0000000 --- a/patterns/structural/composite/naive.py +++ /dev/null @@ -1,60 +0,0 @@ -"""The Gang of Four Composite, translated literally. - -Abstract component, leaf, and composite -- including the book's contested -choice of declaring child management on the component so the leaf must -refuse it at runtime. -""" - -from __future__ import annotations - -from abc import ABC, abstractmethod - - -class Graphic(ABC): - """The component interface every node implements.""" - - @abstractmethod - def render(self, indent: int = 0) -> str: ... - - def add(self, child: Graphic) -> None: - raise TypeError(f"{type(self).__name__} cannot hold children") - - -class Circle(Graphic): - """A leaf: no children, and add() raises per the base default.""" - - def __init__(self, name: str) -> None: - self.name = name - - def render(self, indent: int = 0) -> str: - return " " * indent + f"circle({self.name})" - - -class Group(Graphic): - """A composite: renders by recursing over children.""" - - def __init__(self, name: str) -> None: - self.name = name - self._children: list[Graphic] = [] - - def add(self, child: Graphic) -> None: - self._children.append(child) - - def render(self, indent: int = 0) -> str: - lines = [" " * indent + f"group({self.name})"] - lines.extend(child.render(indent + 2) for child in self._children) - return "\n".join(lines) - - -def main() -> None: - scene = Group("scene") - scene.add(Circle("sun")) - inner = Group("cluster") - inner.add(Circle("a")) - inner.add(Circle("b")) - scene.add(inner) - print(scene.render()) - - -if __name__ == "__main__": - main() diff --git a/patterns/structural/composite/pattern/__init__.py b/patterns/structural/composite/pattern/__init__.py new file mode 100644 index 0000000..1a842f8 --- /dev/null +++ b/patterns/structural/composite/pattern/__init__.py @@ -0,0 +1,2 @@ +from .tree import Composite as Composite +from .tree import HasTotal as HasTotal diff --git a/patterns/structural/composite/pattern/tree.py b/patterns/structural/composite/pattern/tree.py new file mode 100644 index 0000000..9e8189c --- /dev/null +++ b/patterns/structural/composite/pattern/tree.py @@ -0,0 +1,56 @@ +"""Composite as an importable, typed building block — with honest interfaces. + +A tree node is anything with ``total() -> V``; leaves are your own frozen +domain objects. ``Composite`` is the one container: it manages children +(that's where ``add``/``remove`` honestly belong — never on leaves) and rolls +totals up by combining its children's. Any value that can be summed works as +``V`` — an ``int``, or a metrics dataclass with ``__add__``. +""" + +from __future__ import annotations + +from collections.abc import Callable, Iterable, Iterator +from typing import Generic, Protocol, TypeVar + +V = TypeVar("V") +V_co = TypeVar("V_co", covariant=True) + + +class HasTotal(Protocol[V_co]): + """What every node — leaf or subtree — must offer: one rollup value.""" + + def total(self) -> V_co: ... + + +class Composite(Generic[V]): + """A container node: holds children, rolls their totals up.""" + + def __init__( + self, + combine: Callable[[Iterable[V]], V], + children: Iterable[HasTotal[V]] = (), + ) -> None: + self._combine = combine + self._children: list[HasTotal[V]] = list(children) + + def add(self, child: HasTotal[V]) -> None: + """Child management lives here, on the container — not on leaves.""" + self._children.append(child) + + def remove(self, child: HasTotal[V]) -> None: + """Remove the first ``==``-equal direct child; ``ValueError`` if none. + + With value-equal leaves (frozen dataclasses), "first equal" may not + be the identical object you hold a reference to. + """ + self._children.remove(child) + + def total(self) -> V: + """Same interface as a leaf: callers never ask which kind they hold.""" + return self._combine(child.total() for child in self._children) + + def __iter__(self) -> Iterator[HasTotal[V]]: + return iter(self._children) + + def __len__(self) -> int: + return len(self._children) diff --git a/patterns/structural/composite/pythonic.py b/patterns/structural/composite/pythonic.py deleted file mode 100644 index 8434007..0000000 --- a/patterns/structural/composite/pythonic.py +++ /dev/null @@ -1,53 +0,0 @@ -"""Composite with duck typing: no abstract base, honest interfaces. - -The leaf and the container simply share a method. A ``Protocol`` gives the -type checker the same guarantee the ABC gave, without forcing leaves to -inherit -- or to carry child management they cannot honor. -""" - -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import Protocol - - -class Sized(Protocol): - def total_bytes(self) -> int: ... - - -@dataclass(frozen=True) -class File: - """A leaf. It has no add() -- files honestly cannot hold children.""" - - name: str - size: int - - def total_bytes(self) -> int: - return self.size - - -@dataclass -class Directory: - """A composite. Child management lives here, where it belongs.""" - - name: str - entries: list[Sized] = field(default_factory=list) - - def add(self, entry: Sized) -> None: - self.entries.append(entry) - - def total_bytes(self) -> int: - return sum(entry.total_bytes() for entry in self.entries) - - -def main() -> None: - root = Directory("root") - root.add(File("a.txt", 100)) - sub = Directory("sub") - sub.add(File("b.bin", 400)) - root.add(sub) - print(f"total: {root.total_bytes()} bytes") - - -if __name__ == "__main__": - main() diff --git a/patterns/structural/composite/real_world.py b/patterns/structural/composite/real_world.py deleted file mode 100644 index ec002cd..0000000 --- a/patterns/structural/composite/real_world.py +++ /dev/null @@ -1,34 +0,0 @@ -"""The stdlib's composite: ``xml.etree.ElementTree``. - -An ``Element`` holds child ``Element`` objects; ``iter()`` walks the whole -tree through one interface, never asking a node whether it is a leaf. -(``pathlib.Path`` is the same idea over the file system.) -""" - -from __future__ import annotations - -import xml.etree.ElementTree as ET - - -def build_scene() -> ET.Element: - scene = ET.Element("scene") - ET.SubElement(scene, "circle", name="sun") - cluster = ET.SubElement(scene, "group", name="cluster") - ET.SubElement(cluster, "circle", name="a") - ET.SubElement(cluster, "circle", name="b") - return scene - - -def count_circles(root: ET.Element) -> int: - """One recursive traversal, uniform over leaves and containers.""" - return sum(1 for _ in root.iter("circle")) - - -def main() -> None: - scene = build_scene() - print(ET.tostring(scene, encoding="unicode")) - print(f"circles in tree: {count_circles(scene)}") - - -if __name__ == "__main__": - main() diff --git a/patterns/structural/composite/tests/__init__.py b/patterns/structural/composite/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/patterns/structural/composite/tests/test_composite.py b/patterns/structural/composite/tests/test_composite.py index 4965088..685ca49 100644 --- a/patterns/structural/composite/tests/test_composite.py +++ b/patterns/structural/composite/tests/test_composite.py @@ -1,40 +1,63 @@ -"""Behavioral tests for all three composite variants.""" +"""Behavioral tests for the Composite building block.""" + +from __future__ import annotations + +from dataclasses import dataclass import pytest -from patterns.structural.composite import naive, pythonic, real_world +from patterns.structural.composite import Composite + + +@dataclass(frozen=True) +class Task: + hours: int + + def total(self) -> int: + return self.hours + + +class TestRollup: + def test_a_container_totals_its_leaves(self) -> None: + team = Composite[int](sum, [Task(3), Task(5)]) + assert team.total() == 8 + def test_nesting_rolls_up_through_every_level(self) -> None: + team = Composite[int](sum, [Task(3), Task(5)]) + project = Composite[int](sum, [team, Task(8)]) + portfolio = Composite[int](sum, [project]) + assert portfolio.total() == 16 -class TestNaive: - def test_nested_render_recurses(self) -> None: - scene = naive.Group("scene") - scene.add(naive.Circle("sun")) - inner = naive.Group("g") - inner.add(naive.Circle("a")) - scene.add(inner) - assert scene.render() == "group(scene)\n circle(sun)\n group(g)\n circle(a)" + def test_an_empty_container_totals_the_combine_identity(self) -> None: + assert Composite[int](sum).total() == 0 - def test_leaf_refuses_children(self) -> None: - with pytest.raises(TypeError): - naive.Circle("sun").add(naive.Circle("moon")) + def test_leaf_and_subtree_are_interchangeable_to_callers(self) -> None: + def describe(node: Task | Composite[int]) -> str: + return f"{node.total()}h" # never asks which kind it holds + assert describe(Task(4)) == "4h" + assert describe(Composite[int](sum, [Task(4)])) == "4h" -class TestPythonic: - def test_totals_recurse_through_nesting(self) -> None: - root = pythonic.Directory("root") - root.add(pythonic.File("a", 100)) - sub = pythonic.Directory("sub") - sub.add(pythonic.File("b", 400)) - root.add(sub) - assert root.total_bytes() == 500 - def test_leaf_has_no_child_management(self) -> None: - assert not hasattr(pythonic.File("a", 1), "add") +class TestHonestInterfaces: + def test_child_management_lives_only_on_the_container(self) -> None: + assert not hasattr(Task(1), "add") + assert not hasattr(Task(1), "remove") - def test_empty_directory_totals_zero(self) -> None: - assert pythonic.Directory("empty").total_bytes() == 0 + def test_add_and_remove_change_the_rollup(self) -> None: + team = Composite[int](sum, [Task(3)]) + extra = Task(5) + team.add(extra) + assert team.total() == 8 + team.remove(extra) + assert team.total() == 3 + def test_removing_a_stranger_raises(self) -> None: + with pytest.raises(ValueError): + Composite[int](sum).remove(Task(1)) -class TestRealWorld: - def test_uniform_traversal_counts_all_depths(self) -> None: - assert real_world.count_circles(real_world.build_scene()) == 3 + def test_iteration_walks_direct_children_in_order(self) -> None: + first, second = Task(1), Task(2) + team = Composite[int](sum, [first, second]) + assert list(team) == [first, second] + assert len(team) == 2 diff --git a/patterns/structural/composite/tests/test_org_chart.py b/patterns/structural/composite/tests/test_org_chart.py new file mode 100644 index 0000000..8521805 --- /dev/null +++ b/patterns/structural/composite/tests/test_org_chart.py @@ -0,0 +1,48 @@ +"""Behavioral tests for the org-chart mini-project.""" + +from __future__ import annotations + +import pytest + +from patterns.structural.composite.examples.org_chart.main import main +from patterns.structural.composite.examples.org_chart.org import Department, Employee, OrgMetrics + + +def build_company() -> tuple[Department, Department]: + platform = Department("platform", [Employee("Ada", 190_000), Employee("Grace", 185_000)]) + company = Department("engineering", [platform, Employee("Barbara", 210_000)]) + return company, platform + + +class TestRollups: + def test_both_measures_travel_up_in_one_pass(self) -> None: + company, _ = build_company() + assert company.total() == OrgMetrics(headcount=3, annual_cost=585_000) + + def test_a_subtree_reports_only_its_own_people(self) -> None: + _, platform = build_company() + assert platform.total() == OrgMetrics(headcount=2, annual_cost=375_000) + + def test_an_empty_department_is_zero_not_an_error(self) -> None: + assert Department("new-team").total() == OrgMetrics(0, 0) + + def test_a_reorg_moves_cost_between_departments(self) -> None: + company, platform = build_company() + hire = Employee("Edsger", 200_000) + platform.add(hire) + assert company.total().headcount == 4 + platform.remove(hire) + assert company.total().headcount == 3 + + +class TestHonesty: + def test_employees_cannot_hold_reports(self) -> None: + assert not hasattr(Employee("Ada", 190_000), "add") + + +class TestDemo: + def test_main_prints_rollups_per_level(self, capsys: pytest.CaptureFixture[str]) -> None: + main() + out = capsys.readouterr().out + assert "platform: 2 people" in out + assert "engineering: 4 people" in out diff --git a/patterns/structural/decorator/README.md b/patterns/structural/decorator/README.md index 9ee0248..d76070d 100644 --- a/patterns/structural/decorator/README.md +++ b/patterns/structural/decorator/README.md @@ -15,33 +15,17 @@ stdlib_sightings: [functools.wraps, functools.lru_cache, contextlib.contextmanag # Decorator -## Problem - -You want cross-cutting behavior — logging, caching, retries, access control — -around existing behavior, without editing the original and without a subclass -per combination. - -## Naive solution - -`naive.py` is the GoF object wrapper: a class that holds the wrapped object, -adds its twist, and forwards everything else. Faithful, and it carries the -book's real cost — you must forward *every* method, and the wrapper still -fails `isinstance` checks against the original. - -## Pythonic solution - -For callables, the language absorbed the pattern into `@decorator` syntax. -`pythonic.py` builds a proper function decorator (with `functools.wraps`) and -a parameterized one — the three-layer form that trips everyone up once. - -## In the wild - -`functools.lru_cache` is a decorator adding caching; `functools.wraps` is a -decorator that fixes decorators; `contextlib.contextmanager` turns a generator -into a context manager. You use this pattern daily whether you notice or not. - -## Verdict - -**Pythonic** — for callables, idiomatically so. GoF-style object wrapping is -rarer; when you need it, `__getattr__` forwarding (shown in `naive.py`) keeps -it tolerable. +Add one cross-cutting concern at a time — logging, timing, retries, limits — +by wrapping, then stack the wrappers. **Verdict: pythonic** — for callables the +language absorbed the pattern into `@decorator` syntax. + +| Where | What | +|---|---| +| [`pattern/`](pattern/) | The importable code: `logged`, `timed`, `retry`, `rate_limited` | +| [`docs/`](docs/) | [Fundamentals](docs/fundamentals.md) · [Implementation guide](docs/implementation.md) · [External examples](docs/examples.md) | +| [`examples/resilient_client/`](examples/resilient_client/) | Mini-project: a flaky API client hardened by stacking `pattern/` decorators | +| [`tests/`](tests/) | Behavioral tests for the pattern and the mini-project | + +```bash +uv run python -m patterns.structural.decorator.examples.resilient_client.main +``` diff --git a/patterns/structural/decorator/__init__.py b/patterns/structural/decorator/__init__.py index 51ffe68..9a46136 100644 --- a/patterns/structural/decorator/__init__.py +++ b/patterns/structural/decorator/__init__.py @@ -1 +1,5 @@ -"""Decorator: add behavior around objects or callables without editing them.""" +from .pattern.decorators import RateLimitExceededError as RateLimitExceededError +from .pattern.decorators import logged as logged +from .pattern.decorators import rate_limited as rate_limited +from .pattern.decorators import retry as retry +from .pattern.decorators import timed as timed diff --git a/patterns/structural/decorator/docs/examples.md b/patterns/structural/decorator/docs/examples.md new file mode 100644 index 0000000..b5614d1 --- /dev/null +++ b/patterns/structural/decorator/docs/examples.md @@ -0,0 +1,39 @@ +# Decorator — where it lives outside this repo + +Cited, real implementations to study (or point an agent at) when designing or +reviewing decorator-shaped code. + +## Python standard library + +- **`functools.lru_cache` / `functools.cache`.** Memoization as a decorator — + wrap a function, gain a cache and `cache_info()` statistics. The pattern + shipping in the box. + [docs.python.org/3/library/functools.html#functools.lru_cache](https://docs.python.org/3/library/functools.html#functools.lru_cache) +- **`functools.wraps`.** A decorator whose only job is making other decorators + honest — it copies the wrapped function's identity onto the wrapper. + [docs.python.org/3/library/functools.html#functools.wraps](https://docs.python.org/3/library/functools.html#functools.wraps) +- **`contextlib.contextmanager`.** Wraps a generator into a context manager — + a decorator that changes the *kind* of the thing it wraps. + [docs.python.org/3/library/contextlib.html#contextlib.contextmanager](https://docs.python.org/3/library/contextlib.html#contextlib.contextmanager) + +## Major ecosystems + +- **Flask routing.** `@app.route("/path")` registers view functions into the + URL map at definition site — decorator as registration API. + [flask.palletsprojects.com/en/stable/quickstart/#routing](https://flask.palletsprojects.com/en/stable/quickstart/#routing) +- **Django's `@login_required`.** Access control layered onto views without + touching them. + [docs.djangoproject.com/en/stable/topics/auth/default/#the-login-required-decorator](https://docs.djangoproject.com/en/stable/topics/auth/default/#the-login-required-decorator) +- **`tenacity`.** Production retry policies (backoff, jitter, stop conditions) + stacked onto callables — this unit's `retry` grown up. + [tenacity.readthedocs.io](https://tenacity.readthedocs.io/) +- **`click`.** Whole CLIs built by stacking `@click.command` and + `@click.option` — decorators composing a program's surface. + [click.palletsprojects.com](https://click.palletsprojects.com/) + +## What to notice across all of them + +Every one preserves the wrapped callable's contract (arguments in, result +out) and adds exactly one concern beside it. And every serious one calls +`functools.wraps` — check for it first when reviewing any hand-rolled +decorator. diff --git a/patterns/structural/decorator/docs/fundamentals.md b/patterns/structural/decorator/docs/fundamentals.md new file mode 100644 index 0000000..3a80d97 --- /dev/null +++ b/patterns/structural/decorator/docs/fundamentals.md @@ -0,0 +1,77 @@ +# Decorator — fundamentals + +## Intent + +Attach responsibilities to an object or callable dynamically, without editing +the original and without a subclass per combination. Wrapping composes: +logging-around-retry-around-caching is three small pieces, not one class named +`LoggingRetryingCachingClient`. + +## Participants + +| Role | Classic (GoF) form | Python form | +|---|---|---| +| Component | Abstract interface both sides implement | Any callable (or any object, for the wrapping form) | +| Concrete component | The real object | The function being decorated | +| Decorator | Abstract wrapper holding a component | A factory returning a closure — see [`pattern/decorators.py`](../pattern/decorators.py) | +| Concrete decorators | One subclass per added concern | `logged`, `timed`, `retry`, `rate_limited` | + +## Mechanism + +1. A decorator takes the component, returns something with the same interface. +2. The wrapper adds its one concern before/after delegating inward. +3. Wrappers stack; order is meaningful and chosen at composition time. +4. `functools.wraps` copies identity (`__name__`, `__doc__`, signature) so the + stack stays introspectable. + +## The classic form, and what Python absorbs + +Two related shapes share the name. The GoF book wraps *objects* — a class +holding the wrapped instance, augmenting some methods, forwarding the rest: + +```python +class LoggingWriter: + """Wraps a file-like object; counts writes, forwards the rest.""" + + def __init__(self, wrapped: TextIO) -> None: + self._wrapped = wrapped + self.writes = 0 + + def write(self, text: str) -> int: # the augmented method + self.writes += 1 + return self._wrapped.write(text) + + def __getattr__(self, name: str) -> Any: # wholesale forwarding + return getattr(self._wrapped, name) +``` + +`__getattr__` already softens the book's forward-every-method tax — but the +wrapper still fails `isinstance` against the wrapped type (the guide's +caveat: wrapping doesn't make you the wrapped thing). + +For *callables*, Python absorbed the pattern into syntax: `@decorator` above a +`def` is the whole class diagram in one line. This module's +[`pattern/`](../pattern/) ships that form, because it is the one you compose +daily. See the guide chapter: +[python-patterns.guide/gang-of-four/decorator-pattern](https://python-patterns.guide/gang-of-four/decorator-pattern/). + +## When to use it + +- A cross-cutting concern (logging, retries, caching, limits, auth) recurs + around many call sites. +- You need concerns in different combinations per call site — stacking beats + a subclass lattice. + +## When not to use it + +- The behavior belongs to the function itself → just write it in the function. +- You need to intercept *every* attribute of a rich object → that's a Proxy + problem; see `structural/proxy`. +- One lazily computed value → `functools.cached_property`. + +## Verdict: pythonic + +For callables the pattern is idiomatic Python — the syntax exists for it. +Always apply `functools.wraps`; without it the stack destroys the wrapped +function's identity. Object wrapping is rarer: reach for it only when the +wrapped surface is wide and `__getattr__` forwarding keeps it honest. diff --git a/patterns/structural/decorator/docs/implementation.md b/patterns/structural/decorator/docs/implementation.md new file mode 100644 index 0000000..b8c29ab --- /dev/null +++ b/patterns/structural/decorator/docs/implementation.md @@ -0,0 +1,76 @@ +# Decorator — putting it into a system + +## The smell it fixes + +The same guard-and-report scaffolding pasted around every meaningful call: + +```python +def charge(card, amount): + log.info("charging...") + for attempt in range(3): + try: + result = api.charge(card, amount) + break + except ConnectionError: + if attempt == 2: + raise + log.info("charged") + return result +``` + +Business logic is one line; the other nine are concerns that belong to +everyone and therefore to no one. Each becomes a decorator written once. + +## Steps + +1. **Name each concern** hiding in the scaffolding: retry, log, time, limit. +2. **Write each as a decorator factory** `(config) -> (func) -> wrapper`, with + `functools.wraps` on every wrapper. Type with `ParamSpec` so the wrapped + signature survives type checking. +3. **Inject effects** (clock, sleep, log sink) as factory parameters with real + defaults — the decorators stay deterministic under test. +4. **Choose the stacking order deliberately**, and write it down where you + compose: retry innermost (each attempt hugs the call), observability + outside it (one line per *operation*), admission control outermost + (rejected calls cost nothing). A different policy is legitimate — but it + should be a decision, not an accident of paste order. +5. **Pin the order with a test.** Stacks are policy; swapping two layers must + fail a test, not a production incident. + +## Python idioms that keep it small + +- `@decorator` syntax at definition site when a function is always wrapped; + explicit `wrapped = deco(func)` at composition site when the policy varies + per use — [`examples/resilient_client/`](../examples/resilient_client/) + uses the second form. +- Parameterized decorators are three nested functions; that's the ceiling. + If you're four deep, refactor to a class with `__call__`. +- `functools.wraps` is non-negotiable — it is itself a decorator fixing + decorators, and every tool that inspects signatures depends on it. + +## Pitfalls + +- **Forgetting `functools.wraps`** — the wrapped function's name, docstring, + and signature vanish; stack traces and debuggers lie. +- **Order accidents.** These are decorator *factories* — call them first. + `retry(3)(logged(log)(f))` logs once per attempt; + `logged(log)(retry(3)(f))` logs once per operation. Both are useful; only + one is what you meant. +- **Decorators that swallow exceptions** turn control flow invisible; add + behavior around the call, don't change its contract. +- **Hidden effects** (module-level clocks, global sleeps) make wrapped code + untestable; inject them. +- **State on the wrapper** (`wrapper.calls += 1`) needs a `type: ignore` under + strict typing — prefer a sink/callback the caller owns. + +## Worked example + +[`examples/resilient_client/`](../examples/resilient_client/) hardens a flaky +payments client with the retry/logging/rate-limit stack and pins the ordering +policy in tests. `timed` is deliberately left out of that stack: latency is +measured around the whole hardened call at the edge, not baked between the +layers — slot it outermost when you want it: + +```bash +uv run python -m patterns.structural.decorator.examples.resilient_client.main +``` diff --git a/patterns/structural/decorator/examples/resilient_client/client.py b/patterns/structural/decorator/examples/resilient_client/client.py new file mode 100644 index 0000000..7af847b --- /dev/null +++ b/patterns/structural/decorator/examples/resilient_client/client.py @@ -0,0 +1,25 @@ +"""The unreliable thing being hardened: a fake payments API.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + + +class TransientNetworkError(ConnectionError): + """The kind of failure a retry can reasonably paper over.""" + + +@dataclass +class FlakyPaymentAPI: + """Fails the first ``failures`` calls, then succeeds forever after.""" + + failures: int + attempts: int = 0 + charges: list[tuple[str, int]] = field(default_factory=list) + + def charge(self, card: str, amount_cents: int) -> str: + self.attempts += 1 + if self.attempts <= self.failures: + raise TransientNetworkError(f"connection reset (attempt {self.attempts})") + self.charges.append((card, amount_cents)) + return f"txn-{len(self.charges)}" diff --git a/patterns/structural/decorator/examples/resilient_client/main.py b/patterns/structural/decorator/examples/resilient_client/main.py new file mode 100644 index 0000000..b007c9f --- /dev/null +++ b/patterns/structural/decorator/examples/resilient_client/main.py @@ -0,0 +1,19 @@ +"""Demo: two charges against an API that fails twice before recovering.""" + +from __future__ import annotations + +from patterns.structural.decorator.examples.resilient_client.client import FlakyPaymentAPI +from patterns.structural.decorator.examples.resilient_client.service import build_charge + + +def main() -> None: + api = FlakyPaymentAPI(failures=2) + charge = build_charge(api, log=lambda line: print(f" log: {line}")) + print(f"charge('4242', 1200) = {charge('4242', 1200)}") + print(f"charge('4000', 800) = {charge('4000', 800)}") + print(f"network attempts: {api.attempts} (2 failures retried away)") + print(f"introspection survives: charge.__name__ = {charge.__name__!r}") + + +if __name__ == "__main__": + main() diff --git a/patterns/structural/decorator/examples/resilient_client/service.py b/patterns/structural/decorator/examples/resilient_client/service.py new file mode 100644 index 0000000..a832f9a --- /dev/null +++ b/patterns/structural/decorator/examples/resilient_client/service.py @@ -0,0 +1,38 @@ +"""Stacking the decorators into a hardened charge function. + +The stack reads bottom-up: retry hugs the flaky call so each attempt is +retried; logging sits outside so one *successful* operation logs once, not +once per attempt; the rate limit is outermost so rejected calls never touch +the network at all. That ordering is policy, and the tests pin it. +""" + +from __future__ import annotations + +import time +from collections.abc import Callable + +from patterns.structural.decorator.examples.resilient_client.client import ( + FlakyPaymentAPI, + TransientNetworkError, +) +from patterns.structural.decorator.pattern import logged, rate_limited, retry + + +def build_charge( + api: FlakyPaymentAPI, + *, + log: Callable[[str], None], + max_attempts: int = 3, + max_calls: int = 5, + window: float = 1.0, + clock: Callable[[], float] = time.monotonic, +) -> Callable[[str, int], str]: + """Wrap ``api.charge`` in retry -> logging -> rate limit, innermost first.""" + + def charge(card: str, amount_cents: int) -> str: + """Charge a card once.""" + return api.charge(card, amount_cents) + + hardened = retry(max_attempts, on=(TransientNetworkError,))(charge) + hardened = logged(log)(hardened) + return rate_limited(max_calls, window, clock)(hardened) diff --git a/patterns/structural/decorator/naive.py b/patterns/structural/decorator/naive.py deleted file mode 100644 index 5777edd..0000000 --- a/patterns/structural/decorator/naive.py +++ /dev/null @@ -1,42 +0,0 @@ -"""The Gang of Four Decorator: wrap an *object*, forward the rest. - -A write-logging wrapper around a file-like object. ``__getattr__`` handles -wholesale forwarding so only the augmented method is written by hand -- the -Python mitigation of the book's forward-every-method tax. -""" - -from __future__ import annotations - -from typing import Any, TextIO - - -class LoggingWriter: - """Wraps a file-like object; counts and logs writes, forwards the rest.""" - - def __init__(self, wrapped: TextIO) -> None: - self._wrapped = wrapped - self.writes: int = 0 - - def write(self, text: str) -> int: - self.writes += 1 - return self._wrapped.write(text) - - def __getattr__(self, name: str) -> Any: - # Everything we don't augment is forwarded untouched. - return getattr(self._wrapped, name) - - -def main() -> None: - import io - - buffer = io.StringIO() - writer = LoggingWriter(buffer) - writer.write("hello ") - writer.write("world") - print(f"writes seen: {writer.writes}") - print(f"content: {buffer.getvalue()!r}") - print(f"isinstance survives wrapping: {isinstance(writer, io.StringIO)}") # False! - - -if __name__ == "__main__": - main() diff --git a/patterns/structural/decorator/pattern/__init__.py b/patterns/structural/decorator/pattern/__init__.py new file mode 100644 index 0000000..cb23ca0 --- /dev/null +++ b/patterns/structural/decorator/pattern/__init__.py @@ -0,0 +1,5 @@ +from .decorators import RateLimitExceededError as RateLimitExceededError +from .decorators import logged as logged +from .decorators import rate_limited as rate_limited +from .decorators import retry as retry +from .decorators import timed as timed diff --git a/patterns/structural/decorator/pattern/decorators.py b/patterns/structural/decorator/pattern/decorators.py new file mode 100644 index 0000000..f9f75a9 --- /dev/null +++ b/patterns/structural/decorator/pattern/decorators.py @@ -0,0 +1,139 @@ +"""Function decorators as importable, composable building blocks. + +Each factory returns a decorator that wraps a callable with one cross-cutting +concern -- logging, timing, retry, rate limiting -- and every wrapper applies +``functools.wraps`` so the wrapped function keeps its identity. Effects +(clocks, sleeping, log sinks) are injected, so the decorators stay +deterministic under test. +""" + +from __future__ import annotations + +import functools +import time +from collections.abc import Callable +from typing import ParamSpec, Protocol, TypeVar + +P = ParamSpec("P") +R = TypeVar("R") + + +class Decorator(Protocol): + """A signature-preserving wrapper: takes a callable, returns its like. + + The type variables live on ``__call__``, so one ``Decorator`` value can + wrap functions of any signature — they bind per decoration, not when the + factory runs. + """ + + def __call__(self, func: Callable[P, R], /) -> Callable[P, R]: ... + + +class RateLimitExceededError(RuntimeError): + """The wrapped callable was invoked more often than its window allows.""" + + +def logged(log: Callable[[str], None]) -> Decorator: + """Report every call and its outcome to ``log``.""" + + def decorator(func: Callable[P, R]) -> Callable[P, R]: + @functools.wraps(func) + def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: + log(f"-> {func.__name__}") + try: + result = func(*args, **kwargs) + except Exception as exc: + log(f"!! {func.__name__} raised {type(exc).__name__}") + raise + log(f"<- {func.__name__}") + return result + + return wrapper + + return decorator + + +def timed( + sink: Callable[[str, float], None], + clock: Callable[[], float] = time.perf_counter, +) -> Decorator: + """Report each call's duration (seconds) to ``sink`` as ``(name, elapsed)``.""" + + def decorator(func: Callable[P, R]) -> Callable[P, R]: + @functools.wraps(func) + def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: + started = clock() + try: + return func(*args, **kwargs) + finally: + sink(func.__name__, clock() - started) + + return wrapper + + return decorator + + +def retry( + attempts: int, + *, + on: tuple[type[Exception], ...] = (Exception,), + wait: float = 0.0, + sleep: Callable[[float], None] = time.sleep, +) -> Decorator: + """Retry up to ``attempts`` times on the listed exceptions. + + The wait doubles after each failure (``wait``, ``2*wait``, ...); the last + failure propagates. Inject ``sleep`` in tests to keep them instant. + """ + if attempts < 1: + raise ValueError("attempts must be >= 1") + + def decorator(func: Callable[P, R]) -> Callable[P, R]: + @functools.wraps(func) + def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: + pause = wait + for attempt in range(1, attempts + 1): + try: + return func(*args, **kwargs) + except on: + if attempt == attempts: + raise + if pause: + sleep(pause) + pause *= 2 + raise AssertionError("unreachable") # pragma: no cover + + return wrapper + + return decorator + + +def rate_limited( + max_calls: int, + window: float, + clock: Callable[[], float] = time.monotonic, +) -> Decorator: + """Allow ``max_calls`` per sliding ``window`` seconds; then raise. + + Raises :class:`RateLimitExceededError` instead of blocking -- the caller + decides whether to queue, drop, or surface the pressure. + """ + + def decorator(func: Callable[P, R]) -> Callable[P, R]: + calls: list[float] = [] + + @functools.wraps(func) + def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: + now = clock() + while calls and now - calls[0] >= window: + calls.pop(0) + if len(calls) >= max_calls: + raise RateLimitExceededError( + f"{func.__name__}: {max_calls} calls per {window}s exceeded" + ) + calls.append(now) + return func(*args, **kwargs) + + return wrapper + + return decorator diff --git a/patterns/structural/decorator/pythonic.py b/patterns/structural/decorator/pythonic.py deleted file mode 100644 index 327b49f..0000000 --- a/patterns/structural/decorator/pythonic.py +++ /dev/null @@ -1,61 +0,0 @@ -"""Python's native form: the function decorator. - -Two shapes you need: the plain decorator (two layers) and the parameterized -decorator (three layers). Both use ``functools.wraps`` so the wrapped -function keeps its identity under introspection. -""" - -from __future__ import annotations - -import functools -from collections.abc import Callable -from typing import TypeVar - -R = TypeVar("R") - - -def count_calls(func: Callable[..., R]) -> Callable[..., R]: - """Plain decorator: adds a call counter to any function.""" - - @functools.wraps(func) - def wrapper(*args: object, **kwargs: object) -> R: - wrapper.calls += 1 # type: ignore[attr-defined] - return func(*args, **kwargs) - - wrapper.calls = 0 # type: ignore[attr-defined] - return wrapper - - -def repeat(times: int) -> Callable[[Callable[..., R]], Callable[..., list[R]]]: - """Parameterized decorator: the outer layer takes the arguments.""" - - def decorator(func: Callable[..., R]) -> Callable[..., list[R]]: - @functools.wraps(func) - def wrapper(*args: object, **kwargs: object) -> list[R]: - return [func(*args, **kwargs) for _ in range(times)] - - return wrapper - - return decorator - - -@count_calls -def greet(name: str) -> str: - """Say hello.""" - return f"hello {name}" - - -@repeat(times=3) -def beep() -> str: - return "beep" - - -def main() -> None: - print(greet("ada"), greet("grace")) - print(f"calls: {greet.calls}") # type: ignore[attr-defined] - print(f"wraps preserved identity: {greet.__name__!r}, {greet.__doc__!r}") - print(beep()) - - -if __name__ == "__main__": - main() diff --git a/patterns/structural/decorator/real_world.py b/patterns/structural/decorator/real_world.py deleted file mode 100644 index b3bb206..0000000 --- a/patterns/structural/decorator/real_world.py +++ /dev/null @@ -1,25 +0,0 @@ -"""The stdlib decorating itself. - -``functools.lru_cache`` wraps a function with memoization -- the Decorator -pattern shipping in the standard library, cache statistics included. -""" - -from __future__ import annotations - -import functools - - -@functools.cache -def fib(n: int) -> int: - """Naively exponential -- linear once decorated.""" - return n if n < 2 else fib(n - 1) + fib(n - 2) - - -def main() -> None: - print(f"fib(60) = {fib(60)}") - info = fib.cache_info() - print(f"cache hits: {info.hits}, misses: {info.misses}") - - -if __name__ == "__main__": - main() diff --git a/patterns/structural/decorator/tests/__init__.py b/patterns/structural/decorator/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/patterns/structural/decorator/tests/test_decorator.py b/patterns/structural/decorator/tests/test_decorator.py deleted file mode 100644 index c3b5db9..0000000 --- a/patterns/structural/decorator/tests/test_decorator.py +++ /dev/null @@ -1,49 +0,0 @@ -"""Behavioral tests for all three decorator variants.""" - -import io - -from patterns.structural.decorator import naive, pythonic, real_world - - -class TestNaive: - def test_augments_write_and_forwards_content(self) -> None: - buffer = io.StringIO() - writer = naive.LoggingWriter(buffer) - writer.write("a") - writer.write("b") - assert writer.writes == 2 - assert buffer.getvalue() == "ab" - - def test_unaugmented_methods_are_forwarded(self) -> None: - writer = naive.LoggingWriter(io.StringIO()) - writer.write("xyz") - assert writer.getvalue() == "xyz" # forwarded via __getattr__ - - def test_wrapping_does_not_fool_isinstance(self) -> None: - assert not isinstance(naive.LoggingWriter(io.StringIO()), io.StringIO) - - -class TestPythonic: - def test_count_calls_counts(self) -> None: - @pythonic.count_calls - def f() -> int: - return 1 - - f(), f(), f() - assert f.calls == 3 # type: ignore[attr-defined] - - def test_wraps_preserves_metadata(self) -> None: - assert pythonic.greet.__name__ == "greet" - assert pythonic.greet.__doc__ == "Say hello." - - def test_parameterized_decorator(self) -> None: - assert pythonic.beep() == ["beep", "beep", "beep"] - - -class TestRealWorld: - def test_lru_cache_memoizes(self) -> None: - real_world.fib.cache_clear() - assert real_world.fib(30) == 832040 - hits_before = real_world.fib.cache_info().hits - real_world.fib(30) - assert real_world.fib.cache_info().hits == hits_before + 1 diff --git a/patterns/structural/decorator/tests/test_decorators.py b/patterns/structural/decorator/tests/test_decorators.py new file mode 100644 index 0000000..bbe14b4 --- /dev/null +++ b/patterns/structural/decorator/tests/test_decorators.py @@ -0,0 +1,129 @@ +"""Behavioral tests for the decorator building blocks.""" + +from __future__ import annotations + +from collections.abc import Iterator + +import pytest + +from patterns.structural.decorator.pattern import ( + RateLimitExceededError, + logged, + rate_limited, + retry, + timed, +) + + +def test_logged_reports_call_and_return() -> None: + lines: list[str] = [] + + @logged(lines.append) + def add(a: int, b: int) -> int: + return a + b + + assert add(2, 3) == 5 + assert lines == ["-> add", "<- add"] + + +def test_logged_reports_raise_and_reraises() -> None: + lines: list[str] = [] + + @logged(lines.append) + def boom() -> None: + raise ValueError("no") + + with pytest.raises(ValueError): + boom() + assert lines == ["-> boom", "!! boom raised ValueError"] + + +def test_timed_feeds_sink_with_injected_clock() -> None: + ticks = iter([10.0, 10.25]) + seen: list[tuple[str, float]] = [] + + @timed(lambda name, secs: seen.append((name, secs)), clock=lambda: next(ticks)) + def work() -> str: + return "done" + + assert work() == "done" + assert seen == [("work", 0.25)] + + +def test_retry_retries_then_succeeds() -> None: + outcomes: Iterator[ConnectionError | str] = iter( + [ConnectionError("x"), ConnectionError("y"), "ok"] + ) + + @retry(3, on=(ConnectionError,)) + def flaky() -> str: + result = next(outcomes) + if isinstance(result, Exception): + raise result + return result + + assert flaky() == "ok" + + +def test_retry_exhaustion_raises_last_error_after_exact_attempts() -> None: + calls: list[int] = [] + + @retry(3, on=(ConnectionError,)) + def always_down() -> None: + calls.append(1) + raise ConnectionError("still down") + + with pytest.raises(ConnectionError): + always_down() + assert len(calls) == 3 + + +def test_retry_backoff_doubles_and_uses_injected_sleep() -> None: + pauses: list[float] = [] + + @retry(3, on=(ConnectionError,), wait=1.0, sleep=pauses.append) + def always_down() -> None: + raise ConnectionError("down") + + with pytest.raises(ConnectionError): + always_down() + assert pauses == [1.0, 2.0] + + +def test_retry_does_not_catch_unlisted_exceptions() -> None: + @retry(3, on=(ConnectionError,)) + def wrong_kind() -> None: + raise ValueError("not transient") + + with pytest.raises(ValueError): + wrong_kind() + + +def test_rate_limited_allows_within_window_then_raises() -> None: + now = [0.0] + + @rate_limited(2, window=10.0, clock=lambda: now[0]) + def ping() -> str: + return "pong" + + assert ping() == "pong" + assert ping() == "pong" + with pytest.raises(RateLimitExceededError): + ping() + now[0] = 11.0 # window slides; capacity returns + assert ping() == "pong" + + +def test_wraps_preserves_identity_through_a_stack() -> None: + @logged(lambda _: None) + @retry(2) + def documented() -> None: + """The docstring survives the stack.""" + + assert documented.__name__ == "documented" + assert documented.__doc__ == "The docstring survives the stack." + + +def test_retry_refuses_a_nonsensical_attempt_count() -> None: + with pytest.raises(ValueError, match="attempts"): + retry(0) diff --git a/patterns/structural/decorator/tests/test_resilient_client.py b/patterns/structural/decorator/tests/test_resilient_client.py new file mode 100644 index 0000000..5c7f564 --- /dev/null +++ b/patterns/structural/decorator/tests/test_resilient_client.py @@ -0,0 +1,52 @@ +"""Behavioral tests for the resilient_client mini-project.""" + +from __future__ import annotations + +import pytest + +from patterns.structural.decorator.examples.resilient_client.client import FlakyPaymentAPI +from patterns.structural.decorator.examples.resilient_client.service import build_charge +from patterns.structural.decorator.pattern import RateLimitExceededError + + +def test_transient_failures_are_retried_away() -> None: + api = FlakyPaymentAPI(failures=2) + charge = build_charge(api, log=lambda _: None) + assert charge("4242", 1200) == "txn-1" + assert api.attempts == 3 # two failures + the success + assert api.charges == [("4242", 1200)] + + +def test_stacking_order_logs_once_per_operation_not_per_attempt() -> None: + lines: list[str] = [] + api = FlakyPaymentAPI(failures=2) + charge = build_charge(api, log=lines.append) + charge("4242", 500) + # logged() sits OUTSIDE retry(): one arrow pair per operation, though the + # network was hit three times. Swapping the layers would fail this test. + assert lines == ["-> charge", "<- charge"] + assert api.attempts == 3 + + +def test_failures_beyond_the_retry_budget_surface() -> None: + api = FlakyPaymentAPI(failures=5) + charge = build_charge(api, log=lambda _: None, max_attempts=3) + with pytest.raises(ConnectionError): + charge("4242", 500) + assert api.charges == [] + + +def test_rate_limit_rejects_before_touching_the_network() -> None: + now = [0.0] + api = FlakyPaymentAPI(failures=0) + charge = build_charge(api, log=lambda _: None, max_calls=2, window=60.0, clock=lambda: now[0]) + charge("4242", 100) + charge("4242", 200) + with pytest.raises(RateLimitExceededError): + charge("4242", 300) + assert api.attempts == 2 # the rejected call never reached the API + + +def test_hardened_callable_keeps_identity() -> None: + charge = build_charge(FlakyPaymentAPI(failures=0), log=lambda _: None) + assert charge.__name__ == "charge" diff --git a/patterns/structural/facade/README.md b/patterns/structural/facade/README.md index a54d183..35ab5e5 100644 --- a/patterns/structural/facade/README.md +++ b/patterns/structural/facade/README.md @@ -14,31 +14,17 @@ stdlib_sightings: [subprocess.run, shutil.make_archive, urllib.request.urlopen] # Facade -## Problem - -Doing the common thing takes five coordinated calls into a subsystem, and -every caller performs the same dance. One misordered step, one leaked -resource, and the copy-paste bill comes due. - -## Naive solution - -`naive.py` is the class-shaped version: subsystem classes plus a -`HomeTheaterFacade` whose one method runs the sequence. - -## Pythonic solution - -Modules are namespaces and functions are entry points, so the natural Python -facade is a *function*: `pythonic.py` puts `place_order()` in front of an -order-fulfillment subsystem (inventory, payment, shipping, notification) — -including the payment-failure rollback every call site used to forget. The -subsystem stays public for callers needing the full controls. - -## In the wild - -`subprocess.run` is a facade over `Popen`'s wiring; `shutil.make_archive` -fronts `zipfile`/`tarfile`; `urllib.request.urlopen` hides openers and -handlers. Each leaves the machinery public underneath. - -## Verdict - -**Pythonic.** Ship the one-call common case; keep the subsystem's door open. +One entry point for the subsystem dance every caller used to copy-paste — +ordering, rollback and all. **Verdict: pythonic** — the natural Python facade +is a module-level function, and the subsystem stays public beside it. + +| Where | What | +|---|---| +| [`pattern/`](pattern/) | The importable code: `place_order` fronting `Warehouse`/`PaymentGateway`/`Shipping`/`Notifier` | +| [`docs/`](docs/) | [Fundamentals](docs/fundamentals.md) · [Implementation guide](docs/implementation.md) · [External examples](docs/examples.md) | +| [`examples/order_checkout/`](examples/order_checkout/) | Mini-project: a storefront batch-processing orders through the one door | +| [`tests/`](tests/) | Behavioral tests for the pattern and the mini-project | + +```bash +uv run python -m patterns.structural.facade.examples.order_checkout.main +``` diff --git a/patterns/structural/facade/__init__.py b/patterns/structural/facade/__init__.py index 6905b54..31d81f4 100644 --- a/patterns/structural/facade/__init__.py +++ b/patterns/structural/facade/__init__.py @@ -1 +1,6 @@ -"""Facade: one simple entry point in front of a subsystem.""" +from .pattern.checkout import Notifier as Notifier +from .pattern.checkout import OrderResult as OrderResult +from .pattern.checkout import PaymentGateway as PaymentGateway +from .pattern.checkout import Shipping as Shipping +from .pattern.checkout import Warehouse as Warehouse +from .pattern.checkout import place_order as place_order diff --git a/patterns/structural/facade/docs/examples.md b/patterns/structural/facade/docs/examples.md new file mode 100644 index 0000000..7068681 --- /dev/null +++ b/patterns/structural/facade/docs/examples.md @@ -0,0 +1,31 @@ +# Facade — where it lives outside this repo + +Cited, real implementations to study (or point an agent at) when designing or +reviewing facade-shaped code. + +## Python standard library + +- **`subprocess.run`.** One call fronting `Popen`'s pipes, waiting, timeout, + and return-code checking; `Popen` stays public for streaming callers. + [docs.python.org/3/library/subprocess.html#subprocess.run](https://docs.python.org/3/library/subprocess.html#subprocess.run) +- **`shutil.make_archive`.** Walks the tree, creates the archive, writes + entries, closes handles — the whole `zipfile`/`tarfile` dance in one call, + with both modules importable beside it. + [docs.python.org/3/library/shutil.html#shutil.make_archive](https://docs.python.org/3/library/shutil.html#shutil.make_archive) +- **`urllib.request.urlopen`.** Hides the opener/handler chain construction + every request needs; `build_opener` remains for callers who want the knobs. + [docs.python.org/3/library/urllib.request.html#urllib.request.urlopen](https://docs.python.org/3/library/urllib.request.html#urllib.request.urlopen) + +## Major ecosystems + +- **`requests`' functional API.** `requests.get(url)` fronts + Session/adapter/urllib3 machinery; the `Session` object is one import away + when you need pooling or retries. + [requests.readthedocs.io/en/latest/api/#main-interface](https://requests.readthedocs.io/en/latest/api/#main-interface) + +## What to notice across all of them + +Each facade owns a *policy*, not just a shortcut: `subprocess.run` decides +how waiting and non-zero exits work; `urlopen` decides the default handler +chain. And each leaves the machinery public — the measure of a good facade +is that power users never have to fight it. diff --git a/patterns/structural/facade/docs/fundamentals.md b/patterns/structural/facade/docs/fundamentals.md new file mode 100644 index 0000000..f67c385 --- /dev/null +++ b/patterns/structural/facade/docs/fundamentals.md @@ -0,0 +1,70 @@ +# Facade — fundamentals + +## Intent + +Give a complicated subsystem one simple entry point for the common case. The +facade performs the multi-step dance callers would otherwise copy-paste — +in the right order, with the right cleanup — while the subsystem stays +public for anyone needing the full controls. + +## Participants + +| Role | Classic (GoF) form | Python form | +|---|---|---| +| Facade | A class whose methods run subsystem sequences | Usually a module-level *function* — see [`pattern/checkout.py`](../pattern/checkout.py) | +| Subsystem classes | The machinery being fronted | Same, and deliberately still importable | +| Client | Calls the facade for the common case | Calls `place_order(...)`; reaches past it when needed | + +## Mechanism + +1. Identify the sequence every caller repeats against the subsystem. +2. Put that sequence — ordering, error handling, rollback — in one callable. +3. Callers use the one door for the common case. +4. The subsystem stays public: the facade simplifies, it must not imprison. + +## The classic form, and what Python absorbs + +The textbook facade is a class because 1994 had nothing else to hang a +function on: + +```python +class HomeTheaterFacade: + def __init__(self) -> None: + self.amp = Amplifier() + self.projector = Projector() + self.lights = Lights() + + def watch_movie(self) -> list[str]: # the one method + return [ + self.lights.dim(10), + self.projector.on(), + self.projector.wide_screen(), + self.amp.on(), + self.amp.set_volume(5), + ] +``` + +Python has modules for namespacing and functions as first-class entry points, +so a facade with one operation *is a function* — a class with a single method +is a function wearing a costume (this unit's standing caveat). The class form +earns its keep only when the facade holds real state across calls, as the +mini-project's `Store` does for a whole trading day. + +## When to use it + +- Callers repeat the same multi-call sequence against a subsystem, and one + misordered step or forgotten rollback is a real bug you have seen. +- You want a stable, small surface in front of churning machinery. + +## When not to use it + +- One underlying call → just call it; a pass-through layer is noise. +- Callers all need different sequences → there is no common case to front. +- You are tempted to *hide* the subsystem → that's a different (worse) + decision; keep the machinery importable. + +## Verdict: pythonic + +Ship the one-call common case as a function with good defaults; keep the +subsystem's door open. `subprocess.run` over `Popen` is the stdlib's model +citizen of this shape. diff --git a/patterns/structural/facade/docs/implementation.md b/patterns/structural/facade/docs/implementation.md new file mode 100644 index 0000000..d469570 --- /dev/null +++ b/patterns/structural/facade/docs/implementation.md @@ -0,0 +1,65 @@ +# Facade — putting it into a system + +## The smell it fixes + +The same subsystem choreography pasted at every call site: + +```python +# checkout_view.py # admin_reorder.py # support_tool.py +warehouse.reserve(sku, n) warehouse.reserve(sku, n) warehouse.reserve(sku, n) +txn = gateway.charge(...) txn = gateway.charge(...) txn = gateway.charge(...) +label = shipping.label(...) # forgot the rollback! label = shipping.label(...) +``` + +Three copies, one missing rollback, and the bug ships. The sequence is a +policy; policies live in one place. + +## Steps + +1. **Find the repeated dance.** Grep for the subsystem's entry calls; the + facade's body is whatever keeps appearing between them. +2. **Write it as a function** taking the subsystem objects as parameters + (dependency injection keeps it testable) plus keyword-only arguments for + the order itself. +3. **Own the failure policy inside.** Partial completion is the facade's + whole reason to exist: reserve-then-declined must release the stock. Be + honest about the boundary — [`pattern/checkout.py`](../pattern/checkout.py) + marks exactly where its rollback guarantee ends. +4. **Leave the subsystem public.** Export the classes beside the facade; + write at least one caller that legitimately bypasses it (the + mini-project's `Store.restock`) to prove the door stays open. +5. **Route existing call sites through the facade** and delete their local + copies of the dance. The diff is the payoff: minus signs everywhere. + +## Python idioms that keep it small + +- **Module-level function, keyword-only config.** The natural Python facade + is `def place_order(...)` in a module, not a `Manager` class. +- **Take collaborators as parameters** rather than constructing them inside — + the facade coordinates, it doesn't own; tests swap in primed fakes. +- **Grow a class only when state accumulates.** `Store` in the mini-project + holds the subsystem for a whole batch; that's state, so a class is honest. + +## Pitfalls + +- **The one-method class.** `CheckoutManager.place_order()` with no other + members is a function in costume; write the function. +- **Imprisoning the subsystem** (private modules, mangled names) turns a + convenience into a bottleneck; every future need funnels through you. +- **Silent partial completion.** A facade that charges the card and then + crashes without compensating has *created* a bug factory. Decide: roll + back, or document the boundary loudly. +- **Facade sprawl.** When `place_order` sprouts eleven flag parameters, the + callers have distinct needs — give them the subsystem, not more flags. + +## Worked example + +[`examples/order_checkout/`](../examples/order_checkout/) processes a batch of +orders — one declined card among them — through the single checkout door. +Unusually for this catalog, the pattern package carries the whole domain: +`place_order` *is* the facade, so the mini-project adds only the batch +processing and the full-controls bypass around it: + +```bash +uv run python -m patterns.structural.facade.examples.order_checkout.main +``` diff --git a/patterns/structural/facade/examples/order_checkout/main.py b/patterns/structural/facade/examples/order_checkout/main.py new file mode 100644 index 0000000..33fb511 --- /dev/null +++ b/patterns/structural/facade/examples/order_checkout/main.py @@ -0,0 +1,28 @@ +"""Demo: a morning's orders, one declined card among them.""" + +from __future__ import annotations + +from patterns.structural.facade.examples.order_checkout.store import Order, Store +from patterns.structural.facade.pattern import PaymentGateway, Warehouse + + +def main() -> None: + store = Store( + warehouse=Warehouse(stock={"mug": 10, "tee": 3}), + gateway=PaymentGateway(declined_cards={"4000-declined"}), + ) + orders = [ + Order("mug", 2, 1200, "4242", "12 Grace Ave"), + Order("tee", 1, 2500, "4000-declined", "9 Hopper St"), + Order("mug", 1, 1200, "4111", "3 Lovelace Rd"), + ] + fulfilled, failed = store.process(orders) + for result in fulfilled: + print(f"fulfilled: {result.transaction_id} -> {result.shipping_label}") + for order, reason in failed: + print(f"failed: {order.sku} x{order.quantity} ({reason})") + print(f"stock after (tee restored by rollback): {store.warehouse.stock}") + + +if __name__ == "__main__": + main() diff --git a/patterns/structural/facade/examples/order_checkout/store.py b/patterns/structural/facade/examples/order_checkout/store.py new file mode 100644 index 0000000..0bd5fc3 --- /dev/null +++ b/patterns/structural/facade/examples/order_checkout/store.py @@ -0,0 +1,67 @@ +"""The mini-project: a storefront whose only checkout path is the facade. + +Every order goes through ``place_order`` -- no call site re-implements the +reserve/charge/ship/notify dance, so the payment-declined rollback exists in +exactly one place. Callers needing the full controls still reach the +subsystem directly (see ``Store.restock``, which talks to the warehouse). +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from patterns.structural.facade.pattern import ( + Notifier, + OrderResult, + PaymentGateway, + Shipping, + Warehouse, + place_order, +) + + +@dataclass(frozen=True) +class Order: + sku: str + quantity: int + price_cents: int + card: str + address: str + + +@dataclass +class Store: + """Owns the subsystem; exposes one door for the common case.""" + + warehouse: Warehouse = field(default_factory=Warehouse) + gateway: PaymentGateway = field(default_factory=PaymentGateway) + shipping: Shipping = field(default_factory=Shipping) + notifier: Notifier = field(default_factory=Notifier) + + def restock(self, sku: str, quantity: int) -> None: + # Full-controls path: the subsystem is public, not imprisoned. + self.warehouse.release(sku, quantity) + + def checkout(self, order: Order) -> OrderResult: + return place_order( + self.warehouse, + self.gateway, + self.shipping, + self.notifier, + sku=order.sku, + quantity=order.quantity, + price_cents=order.price_cents, + card=order.card, + address=order.address, + ) + + def process(self, orders: list[Order]) -> tuple[list[OrderResult], list[tuple[Order, str]]]: + """A day's batch: fulfilled results plus (order, reason) failures.""" + fulfilled: list[OrderResult] = [] + failed: list[tuple[Order, str]] = [] + for order in orders: + try: + fulfilled.append(self.checkout(order)) + except (LookupError, PermissionError) as exc: + failed.append((order, str(exc))) + return fulfilled, failed diff --git a/patterns/structural/facade/naive.py b/patterns/structural/facade/naive.py deleted file mode 100644 index 8ee26bc..0000000 --- a/patterns/structural/facade/naive.py +++ /dev/null @@ -1,53 +0,0 @@ -"""The class-shaped Facade. - -Three subsystem classes, one facade whose single method performs the -sequence every caller would otherwise copy-paste. -""" - -from __future__ import annotations - - -class Amplifier: - def on(self) -> str: - return "amp on" - - def set_volume(self, level: int) -> str: - return f"volume {level}" - - -class Projector: - def on(self) -> str: - return "projector on" - - def wide_screen(self) -> str: - return "16:9" - - -class Lights: - def dim(self, percent: int) -> str: - return f"lights {percent}%" - - -class HomeTheaterFacade: - def __init__(self) -> None: - self.amp = Amplifier() - self.projector = Projector() - self.lights = Lights() - - def watch_movie(self) -> list[str]: - return [ - self.lights.dim(10), - self.projector.on(), - self.projector.wide_screen(), - self.amp.on(), - self.amp.set_volume(5), - ] - - -def main() -> None: - for step in HomeTheaterFacade().watch_movie(): - print(step) - - -if __name__ == "__main__": - main() diff --git a/patterns/structural/facade/pattern/__init__.py b/patterns/structural/facade/pattern/__init__.py new file mode 100644 index 0000000..b015114 --- /dev/null +++ b/patterns/structural/facade/pattern/__init__.py @@ -0,0 +1,6 @@ +from .checkout import Notifier as Notifier +from .checkout import OrderResult as OrderResult +from .checkout import PaymentGateway as PaymentGateway +from .checkout import Shipping as Shipping +from .checkout import Warehouse as Warehouse +from .checkout import place_order as place_order diff --git a/patterns/structural/facade/pythonic.py b/patterns/structural/facade/pattern/checkout.py similarity index 78% rename from patterns/structural/facade/pythonic.py rename to patterns/structural/facade/pattern/checkout.py index 465ab01..90eba94 100644 --- a/patterns/structural/facade/pythonic.py +++ b/patterns/structural/facade/pattern/checkout.py @@ -1,10 +1,10 @@ -"""The pythonic facade: a module-level function with good defaults. +"""A facade in its natural Python form: one function with good defaults. The subsystem is a small order-fulfillment flow -- inventory, payment, shipping, notification -- four calls every checkout caller used to copy-paste, in the right order, with the right rollback. ``place_order`` -is the one-call common case; the subsystem stays public for callers who -need the full controls (partial shipments, invoice-only, etc.). +is the one-call common case; the subsystem classes stay public for callers +who need the full controls (partial shipments, invoice-only, etc.). """ from __future__ import annotations @@ -78,8 +78,10 @@ def place_order( warehouse.reserve(sku, quantity) try: txn = gateway.charge(card, price_cents * quantity) - except PermissionError: - warehouse.release(sku, quantity) # the step copy-paste always forgets + except Exception: + # Any charge failure — declined card or gateway blowup — must hand + # the reservation back; this is the step copy-paste always forgets. + warehouse.release(sku, quantity) raise # Honest boundary: a crash below this line leaves the charge captured. # Real systems make charge/label/notify a saga (compensate on failure) @@ -87,24 +89,3 @@ def place_order( label = shipping.create_label(sku, address) notifier.confirm(address, txn, label) return OrderResult(transaction_id=txn, shipping_label=label) - - -def main() -> None: - warehouse = Warehouse(stock={"mug": 10}) - result = place_order( - warehouse, - PaymentGateway(), - Shipping(), - Notifier(), - sku="mug", - quantity=2, - price_cents=1200, - card="4242", - address="12 Grace Ave", - ) - print(result) - print(f"stock after: {warehouse.stock}") - - -if __name__ == "__main__": - main() diff --git a/patterns/structural/facade/real_world.py b/patterns/structural/facade/real_world.py deleted file mode 100644 index 1d04a09..0000000 --- a/patterns/structural/facade/real_world.py +++ /dev/null @@ -1,31 +0,0 @@ -"""``shutil.make_archive``: one call fronting the zipfile machinery. - -Behind the facade: walking the tree, creating the archive, writing entries, -closing handles. The full ``zipfile`` API stays available beside it. -""" - -from __future__ import annotations - -import shutil -import tempfile -import zipfile -from pathlib import Path - - -def archive_directory(source: Path, out_dir: Path) -> Path: - """The facade in action: an entire directory zipped in one call.""" - return Path(shutil.make_archive(str(out_dir / "backup"), "zip", root_dir=source)) - - -def main() -> None: - with tempfile.TemporaryDirectory() as tmp: - source = Path(tmp) / "src" - source.mkdir() - (source / "a.txt").write_text("hello") - archive = archive_directory(source, Path(tmp)) - with zipfile.ZipFile(archive) as zf: # the subsystem, still public - print(f"{archive.name} contains {zf.namelist()}") - - -if __name__ == "__main__": - main() diff --git a/patterns/structural/facade/tests/__init__.py b/patterns/structural/facade/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/patterns/structural/facade/tests/test_checkout.py b/patterns/structural/facade/tests/test_checkout.py new file mode 100644 index 0000000..5d9a87f --- /dev/null +++ b/patterns/structural/facade/tests/test_checkout.py @@ -0,0 +1,109 @@ +"""Behavioral tests for the checkout facade.""" + +from __future__ import annotations + +import pytest + +from patterns.structural.facade.pattern import ( + Notifier, + PaymentGateway, + Shipping, + Warehouse, + place_order, +) + + +def build_subsystem( + stock: int = 10, declined: set[str] | None = None +) -> tuple[Warehouse, PaymentGateway, Shipping, Notifier]: + return ( + Warehouse(stock={"mug": stock}), + PaymentGateway(declined_cards=declined or set()), + Shipping(), + Notifier(), + ) + + +def test_happy_path_runs_the_whole_dance_in_order() -> None: + warehouse, gateway, shipping, notifier = build_subsystem() + result = place_order( + warehouse, + gateway, + shipping, + notifier, + sku="mug", + quantity=2, + price_cents=1200, + card="4242", + address="12 Grace Ave", + ) + assert warehouse.stock["mug"] == 8 + assert gateway.charges == [("4242", 2400)] + assert result.shipping_label in shipping.labels + assert notifier.sent and result.transaction_id in notifier.sent[0] + + +def test_declined_payment_rolls_back_the_reservation() -> None: + warehouse, gateway, shipping, notifier = build_subsystem(declined={"4000"}) + with pytest.raises(PermissionError): + place_order( + warehouse, + gateway, + shipping, + notifier, + sku="mug", + quantity=3, + price_cents=1000, + card="4000", + address="9 Hopper St", + ) + assert warehouse.stock["mug"] == 10 # released, not leaked + assert shipping.labels == [] + assert notifier.sent == [] + + +def test_gateway_blowup_also_releases_the_reservation() -> None: + # Rollback must cover ANY charge failure, not just the declined path. + class ExplodingGateway(PaymentGateway): + def charge(self, card: str, amount_cents: int) -> str: + raise ConnectionError("gateway unreachable") + + warehouse, _, shipping, notifier = build_subsystem() + with pytest.raises(ConnectionError): + place_order( + warehouse, + ExplodingGateway(), + shipping, + notifier, + sku="mug", + quantity=3, + price_cents=1000, + card="4242", + address="9 Hopper St", + ) + assert warehouse.stock["mug"] == 10 # released, not leaked + assert shipping.labels == [] + + +def test_insufficient_stock_stops_before_any_charge() -> None: + warehouse, gateway, shipping, notifier = build_subsystem(stock=1) + with pytest.raises(LookupError): + place_order( + warehouse, + gateway, + shipping, + notifier, + sku="mug", + quantity=5, + price_cents=1000, + card="4242", + address="3 Lovelace Rd", + ) + assert gateway.charges == [] + + +def test_subsystem_stays_usable_without_the_facade() -> None: + warehouse, gateway, _, _ = build_subsystem() + warehouse.reserve("mug", 1) # full-controls path: no facade required + assert gateway.charge("4242", 100) == "txn-1" + assert warehouse.stock["mug"] == 9 diff --git a/patterns/structural/facade/tests/test_facade.py b/patterns/structural/facade/tests/test_facade.py deleted file mode 100644 index c57dfc3..0000000 --- a/patterns/structural/facade/tests/test_facade.py +++ /dev/null @@ -1,96 +0,0 @@ -"""Behavioral tests for all three facade variants.""" - -import tempfile -import zipfile -from pathlib import Path - -import pytest - -from patterns.structural.facade import naive, pythonic, real_world - - -class TestNaive: - def test_one_call_runs_the_whole_sequence(self) -> None: - steps = naive.HomeTheaterFacade().watch_movie() - assert steps == ["lights 10%", "projector on", "16:9", "amp on", "volume 5"] - - -class TestPythonic: - def _subsystem( - self, - ) -> tuple[pythonic.Warehouse, pythonic.PaymentGateway, pythonic.Shipping, pythonic.Notifier]: - return ( - pythonic.Warehouse(stock={"mug": 10}), - pythonic.PaymentGateway(), - pythonic.Shipping(), - pythonic.Notifier(), - ) - - def test_facade_runs_every_step_in_order(self) -> None: - warehouse, gateway, shipping, notifier = self._subsystem() - result = pythonic.place_order( - warehouse, - gateway, - shipping, - notifier, - sku="mug", - quantity=2, - price_cents=1200, - card="4242", - address="12 Grace Ave", - ) - assert warehouse.stock["mug"] == 8 - assert gateway.charges == [("4242", 2400)] - assert result.shipping_label in shipping.labels - assert notifier.sent and result.transaction_id in notifier.sent[0] - - def test_declined_payment_rolls_back_the_reservation(self) -> None: - warehouse, gateway, shipping, notifier = self._subsystem() - gateway.declined_cards.add("0000") - with pytest.raises(PermissionError): - pythonic.place_order( - warehouse, - gateway, - shipping, - notifier, - sku="mug", - quantity=3, - price_cents=1200, - card="0000", - address="x", - ) - assert warehouse.stock["mug"] == 10 # released, not leaked - assert shipping.labels == [] and notifier.sent == [] - - def test_insufficient_stock_charges_nothing(self) -> None: - warehouse, gateway, shipping, notifier = self._subsystem() - with pytest.raises(LookupError): - pythonic.place_order( - warehouse, - gateway, - shipping, - notifier, - sku="mug", - quantity=99, - price_cents=1200, - card="4242", - address="x", - ) - assert gateway.charges == [] - - def test_subsystem_stays_public_for_full_control(self) -> None: - # Invoice-only flow: callers can still drive the parts directly. - gateway = pythonic.PaymentGateway() - assert gateway.charge("4242", 500) == "txn-1" - - -class TestRealWorld: - def test_make_archive_facade(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - source = Path(tmp) / "src" - source.mkdir() - (source / "a.txt").write_text("hello") - archive = real_world.archive_directory(source, Path(tmp)) - assert archive.exists() - with zipfile.ZipFile(archive) as zf: - assert zf.namelist() == ["a.txt"] diff --git a/patterns/structural/facade/tests/test_order_checkout.py b/patterns/structural/facade/tests/test_order_checkout.py new file mode 100644 index 0000000..b7469f9 --- /dev/null +++ b/patterns/structural/facade/tests/test_order_checkout.py @@ -0,0 +1,58 @@ +"""Behavioral tests for the order_checkout mini-project.""" + +from __future__ import annotations + +from patterns.structural.facade.examples.order_checkout.store import Order, Store +from patterns.structural.facade.pattern import PaymentGateway, Warehouse + + +def build_store() -> Store: + return Store( + warehouse=Warehouse(stock={"mug": 10, "tee": 3}), + gateway=PaymentGateway(declined_cards={"4000-declined"}), + ) + + +def test_batch_separates_fulfilled_from_failed() -> None: + store = build_store() + fulfilled, failed = store.process( + [ + Order("mug", 2, 1200, "4242", "12 Grace Ave"), + Order("tee", 1, 2500, "4000-declined", "9 Hopper St"), + Order("mug", 1, 1200, "4111", "3 Lovelace Rd"), + ] + ) + assert [r.transaction_id for r in fulfilled] == ["txn-1", "txn-2"] + assert [(o.sku, "declined" in reason) for o, reason in failed] == [("tee", True)] + + +def test_declined_order_leaves_stock_untouched_for_the_rest_of_the_batch() -> None: + store = build_store() + store.process( + [ + Order("tee", 2, 2500, "4000-declined", "9 Hopper St"), + Order("tee", 3, 2500, "4242", "12 Grace Ave"), + ] + ) + # The rollback restored the 2 tees, so the order for all 3 could succeed. + assert store.warehouse.stock["tee"] == 0 + assert len(store.gateway.charges) == 1 + + +def test_insufficient_stock_lands_in_the_failed_bucket() -> None: + store = build_store() + fulfilled, failed = store.process( + [ + Order("tee", 99, 2500, "4242", "9 Hopper St"), # only 3 in stock + Order("mug", 1, 1200, "4242", "12 Grace Ave"), + ] + ) + assert len(fulfilled) == 1 + assert [(o.sku, "tee" in reason) for o, reason in failed] == [("tee", True)] + assert store.gateway.charges == [("4242", 1200)] # the doomed order never charged + + +def test_full_controls_path_bypasses_the_facade() -> None: + store = build_store() + store.restock("mug", 5) + assert store.warehouse.stock["mug"] == 15 diff --git a/patterns/structural/flyweight/README.md b/patterns/structural/flyweight/README.md index 965d4b0..67b8e2d 100644 --- a/patterns/structural/flyweight/README.md +++ b/patterns/structural/flyweight/README.md @@ -15,30 +15,17 @@ stdlib_sightings: [sys.intern, functools.lru_cache, int] # Flyweight -## Problem - -A text editor holds a million character objects; a card game deals thousands -of hands from 52 distinct cards. Building a fresh object per occurrence wastes -memory on identical state. Share one immutable instance per distinct value. - -## Naive solution - -`naive.py` uses the book's shape — a factory that checks a pool before -constructing — for playing cards: ask for `9♥` twice, get the same object. - -## Pythonic solution - -Two idiomatic forms in `pythonic.py`: a `functools.lru_cache`-decorated -factory (the pool is the cache), and the guide's `__new__` variant where the -class itself makes `Card(9, "♥") is Card(9, "♥")` true. - -## In the wild - -CPython interns small integers (`-5..256`) and identifier-like strings on its -own, and `sys.intern` lets you intern strings explicitly to speed up -comparisons — the interpreter running Flyweight underneath you. - -## Verdict - -**Use with care.** Great when profiling shows real duplication of immutable -values; pointless ceremony otherwise. Keep flyweights frozen. +Share one immutable instance per distinct value instead of building millions +of duplicates. **Verdict: use with care** — measure first, keep flyweights +frozen, prefer an explicit factory over `__new__` tricks. + +| Where | What | +|---|---| +| [`pattern/`](pattern/) | The importable code: `InternPool` (keyed sharing with an immutability guard) | +| [`docs/`](docs/) | [Fundamentals](docs/fundamentals.md) · [Implementation guide](docs/implementation.md) · [External examples](docs/examples.md) | +| [`examples/glyph_styles/`](examples/glyph_styles/) | Mini-project: a text buffer holding thousands of glyphs on a handful of shared styles | +| [`tests/`](tests/) | Behavioral tests for the pattern and the mini-project | + +```bash +uv run python -m patterns.structural.flyweight.examples.glyph_styles.main +``` diff --git a/patterns/structural/flyweight/__init__.py b/patterns/structural/flyweight/__init__.py index eb8fb36..5fbed59 100644 --- a/patterns/structural/flyweight/__init__.py +++ b/patterns/structural/flyweight/__init__.py @@ -1 +1 @@ -"""Flyweight: share immutable instances rather than duplicating them.""" +from .pattern.pool import InternPool as InternPool diff --git a/patterns/structural/flyweight/docs/examples.md b/patterns/structural/flyweight/docs/examples.md new file mode 100644 index 0000000..29d31b9 --- /dev/null +++ b/patterns/structural/flyweight/docs/examples.md @@ -0,0 +1,32 @@ +# Flyweight — where it lives outside this repo + +Cited, real implementations to study (or point an agent at) when designing or +reviewing sharing/interning code. + +## Python standard library + +- **`sys.intern`.** Explicit string interning: one shared copy, pointer-fast + equality. The docs call out dictionary keys as the winning case. + [docs.python.org/3/library/sys.html#sys.intern](https://docs.python.org/3/library/sys.html#sys.intern) +- **CPython small-int interning.** Integers −5..256 are pre-built singletons; + the interpreter runs the pattern under you, which is why careless `is` checks + on small ints "work" and then betray you at 257. + [docs.python.org/3/c-api/long.html](https://docs.python.org/3/c-api/long.html) +- **`functools.lru_cache`.** A memoizing decorator that, applied to a + factory, *is* the flyweight pool — the guide chapter's own recommendation. + [docs.python.org/3/library/functools.html#functools.lru_cache](https://docs.python.org/3/library/functools.html#functools.lru_cache) + +## Major ecosystems + +- **spaCy `StringStore`.** Interns every vocabulary string to a 64-bit hash + so tokens across a corpus share one copy — flyweight at NLP scale. + [spacy.io/api/stringstore](https://spacy.io/api/stringstore) +- **Apache Arrow dictionary arrays** (pandas `Categorical`). Column-scale + value sharing: each distinct value stored once, rows hold small indices. + [arrow.apache.org/docs/python/data.html#dictionary-arrays](https://arrow.apache.org/docs/python/data.html#dictionary-arrays) + +## What to notice across all of them + +Every production flyweight shares only **immutable** values, and none of +them expose the pooled object for mutation. And each one earned its place +with a measurement — interning pays at corpus/column scale, not at 52 cards. diff --git a/patterns/structural/flyweight/docs/fundamentals.md b/patterns/structural/flyweight/docs/fundamentals.md new file mode 100644 index 0000000..cd3ad39 --- /dev/null +++ b/patterns/structural/flyweight/docs/fundamentals.md @@ -0,0 +1,72 @@ +# Flyweight — fundamentals + +## Intent + +Support huge numbers of fine-grained objects by sharing one immutable +instance per distinct value instead of duplicating it. Split state into +**intrinsic** (shared, in the flyweight) and **extrinsic** (per occurrence, +carried by the holder). + +## Participants + +| Role | Classic (GoF) form | Python form | +|---|---|---| +| Flyweight | Interface for objects carrying intrinsic state | Any immutable value — a frozen dataclass, a tuple | +| Flyweight factory | Checks a pool before constructing | [`InternPool`](../pattern/pool.py), or `functools.lru_cache` on a factory | +| Client | Supplies extrinsic state on each use | The holder keeps `(char, style)`, not a fat per-char object | + +## Mechanism + +1. Identify the duplicated immutable core of your many objects. +2. Front construction with a pool keyed by that core: first request builds, + later requests share. +3. Keep everything per-occurrence *outside* the shared object. +4. Never mutate a flyweight — a shared instance mutated once is corrupted + everywhere. + +## The classic form, and what Python absorbs + +The book's mechanism is a factory checking a pool — recognizable verbatim in +Python: + +```python +class CardFactory: + def __init__(self) -> None: + self._pool: dict[tuple[str, str], Card] = {} + + def get(self, rank: str, suit: str) -> Card: + key = (rank, suit) + if key not in self._pool: # check the pool... + self._pool[key] = Card(rank, suit) + return self._pool[key] # ...share the instance +``` + +Python absorbs this twice over. `functools.lru_cache` on a plain factory +function *is* the pool. And the guide's `__new__` variant moves the pool +inside the class so `Card('9','♥') is Card('9','♥')` holds with plain +construction syntax — clever, but the sharing becomes invisible at the call +site, which is why the guide (and this unit) prefer the explicit factory: +[python-patterns.guide/gang-of-four/flyweight](https://python-patterns.guide/gang-of-four/flyweight/). + +Most humbling: CPython already interns small integers and identifier-like +strings. Your duplicates may not exist — measure first. + +## When to use it + +- Profiling shows real memory pressure from many identical immutable values + (glyph styles, map tiles, token metadata). +- Identity comparison (`is`) as a fast path is worth engineering for. + +## When not to use it + +- The objects are mutable — sharing mutable state is a bug generator, not an + optimization. +- The population is small; a pool managing 52 cards saves nothing worth the + indirection unless the *lesson* is the point. +- You haven't measured; interning by reflex is ceremony. + +## Verdict: use with care + +Great when profiling shows genuine duplication of immutable values; +pointless ceremony otherwise. Keep flyweights frozen — the pool's +`strict=True` guard exists because that rule gets broken quietly. diff --git a/patterns/structural/flyweight/docs/implementation.md b/patterns/structural/flyweight/docs/implementation.md new file mode 100644 index 0000000..0b07dc4 --- /dev/null +++ b/patterns/structural/flyweight/docs/implementation.md @@ -0,0 +1,68 @@ +# Flyweight — putting it into a system + +## The smell it fixes + +A million tiny objects that are mostly the same object: + +```python +@dataclass +class Char: + char: str + font: str # "Georgia" a million times + size: int # 11 a million times + weight: str # "regular" a million times +``` + +Per-occurrence data (the character) is fused to duplicated data (the style), +and memory pays for the duplication a million-fold. + +## Steps + +1. **Measure first.** `sys.getsizeof`, `tracemalloc`, a heap profiler — + confirm the duplicates exist and matter. CPython already interns small + ints and many strings; your problem may be imaginary. +2. **Split intrinsic from extrinsic.** Intrinsic = identical across + occurrences and immutable (the style); extrinsic = per occurrence (the + char, the position). The split is the design work; the pool is plumbing. +3. **Freeze the intrinsic part** (`@dataclass(frozen=True)`) so sharing is + safe by construction. +4. **Front construction with a pool** — `InternPool(build)` from + [`pattern/pool.py`](../pattern/pool.py), or `functools.lru_cache` on a + factory function when you don't need to inspect the pool. +5. **Route all construction through the factory.** A single call site that + builds directly reintroduces duplicates silently; make the factory the + only public door. +6. **Assert the sharing in a test** — `get(k) is get(k)` and a distinct-count + ceiling — so a refactor that breaks interning fails loudly. + +## Python idioms that keep it small + +- **`functools.lru_cache` as the pool** when the key is the factory's + argument tuple and you never need eviction control or introspection. +- **Frozen dataclasses** give immutability, `__hash__`, and `__eq__` in one + decorator line. +- **Tuples as keys**: `(font, size, weight)` needs no key class. +- **`sys.intern`** when the flyweights are strings compared often. + +## Pitfalls + +- **Mutable flyweights** — one mutation corrupts every holder. The pool's + `strict=True` refuses values it can't verify as frozen. +- **Unbounded pools from user-supplied keys** are a memory leak wearing the + memory-optimization costume; bound them (`lru_cache(maxsize=...)`) or key + from a closed domain. +- **Equality vs identity confusion.** Sharing makes `is` work; code that + *relies* on `is` for correctness now silently depends on the pool being + the only constructor. +- **Interning by reflex** — without a measurement, the pattern is pure + ceremony (this unit's verdict in one line). + +## Worked example + +[`examples/glyph_styles/`](../examples/glyph_styles/) holds a ~30,000-glyph +document at two live `Style` objects and pins both the identity sharing +and the ceiling in tests: + +```bash +uv run python -m patterns.structural.flyweight.examples.glyph_styles.main +``` diff --git a/patterns/structural/flyweight/examples/glyph_styles/document.py b/patterns/structural/flyweight/examples/glyph_styles/document.py new file mode 100644 index 0000000..3d2bde9 --- /dev/null +++ b/patterns/structural/flyweight/examples/glyph_styles/document.py @@ -0,0 +1,61 @@ +"""The mini-project: a document where every character carries a style. + +The GoF book's own motivating example, made measurable. Intrinsic state +(font, size, weight) is interned in a ``StyleBook``; extrinsic state (the +character, its position) stays with each occurrence. A million-character +document holds a handful of ``Style`` objects. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from patterns.structural.flyweight.pattern import InternPool + +StyleKey = tuple[str, int, str] # (font, size, weight) + + +@dataclass(frozen=True) +class Style: + """Intrinsic, shared, immutable — the flyweight.""" + + font: str + size: int + weight: str + + +@dataclass(frozen=True) +class Glyph: + """One occurrence: extrinsic state plus a reference to the shared style.""" + + char: str + style: Style + + +class StyleBook: + """The intern pool with a domain face: ask for a style, share the object.""" + + def __init__(self) -> None: + self._pool: InternPool[StyleKey, Style] = InternPool(lambda key: Style(*key), strict=True) + + def get(self, font: str, size: int, weight: str = "regular") -> Style: + return self._pool.get((font, size, weight)) + + @property + def distinct_styles(self) -> int: + return len(self._pool) + + +class Document: + """A text buffer whose glyphs share their styles.""" + + def __init__(self, styles: StyleBook | None = None) -> None: + self.styles = styles if styles is not None else StyleBook() + self.glyphs: list[Glyph] = [] + + def write(self, text: str, *, font: str, size: int, weight: str = "regular") -> None: + style = self.styles.get(font, size, weight) # one lookup per run of text + self.glyphs.extend(Glyph(char, style) for char in text) + + def __len__(self) -> int: + return len(self.glyphs) diff --git a/patterns/structural/flyweight/examples/glyph_styles/main.py b/patterns/structural/flyweight/examples/glyph_styles/main.py new file mode 100644 index 0000000..544b668 --- /dev/null +++ b/patterns/structural/flyweight/examples/glyph_styles/main.py @@ -0,0 +1,23 @@ +"""Demo: a large document, a tiny number of live Style objects.""" + +from __future__ import annotations + +from patterns.structural.flyweight.examples.glyph_styles.document import Document + + +def main() -> None: + doc = Document() + doc.write("Chapter One", font="Georgia", size=18, weight="bold") + for _ in range(1000): + doc.write("All happy families are alike. ", font="Georgia", size=11) + doc.write("THE END", font="Georgia", size=18, weight="bold") + + a = doc.glyphs[0].style + b = doc.glyphs[-1].style + print(f"glyphs in document: {len(doc):,}") + print(f"distinct styles: {doc.styles.distinct_styles}") + print(f"headers share one object: {a is b}") + + +if __name__ == "__main__": + main() diff --git a/patterns/structural/flyweight/naive.py b/patterns/structural/flyweight/naive.py deleted file mode 100644 index 2304261..0000000 --- a/patterns/structural/flyweight/naive.py +++ /dev/null @@ -1,46 +0,0 @@ -"""The Gang of Four Flyweight: a factory in front of an instance pool. - -Cards are immutable; the factory returns the pooled instance when the same -card is requested again. -""" - -from __future__ import annotations - -from dataclasses import dataclass - - -@dataclass(frozen=True) -class Card: - """The flyweight: intrinsic state only, and frozen.""" - - rank: str - suit: str - - -class CardFactory: - """Checks the pool before constructing -- the book's central mechanism.""" - - def __init__(self) -> None: - self._pool: dict[tuple[str, str], Card] = {} - - def get(self, rank: str, suit: str) -> Card: - key = (rank, suit) - if key not in self._pool: - self._pool[key] = Card(rank, suit) - return self._pool[key] - - @property - def distinct_cards(self) -> int: - return len(self._pool) - - -def main() -> None: - factory = CardFactory() - hand = [factory.get("9", "♥"), factory.get("A", "♠"), factory.get("9", "♥")] - print(f"hand: {hand}") - print(f"shared: {hand[0] is hand[2]}") - print(f"distinct objects created: {factory.distinct_cards}") - - -if __name__ == "__main__": - main() diff --git a/patterns/structural/flyweight/pattern/__init__.py b/patterns/structural/flyweight/pattern/__init__.py new file mode 100644 index 0000000..8d0ce6b --- /dev/null +++ b/patterns/structural/flyweight/pattern/__init__.py @@ -0,0 +1 @@ +from .pool import InternPool as InternPool diff --git a/patterns/structural/flyweight/pattern/pool.py b/patterns/structural/flyweight/pattern/pool.py new file mode 100644 index 0000000..33381f3 --- /dev/null +++ b/patterns/structural/flyweight/pattern/pool.py @@ -0,0 +1,67 @@ +"""Flyweight as an importable building block: a keyed intern pool. + +``InternPool`` fronts construction with a pool: identical keys yield the +*identical* object. It is the explicit, inspectable form of what +``functools.lru_cache`` on a factory does implicitly — and the pool only +stays safe if the pooled values are immutable, which ``get`` can enforce. +""" + +from __future__ import annotations + +from collections.abc import Callable, Hashable +from dataclasses import fields, is_dataclass +from typing import Generic, TypeVar + +K = TypeVar("K", bound=Hashable) +V = TypeVar("V") + + +def _is_frozen(value: object) -> bool: + # Best-effort immutability check for the guard rail: frozen dataclasses + # and common immutable builtins pass; everything else is the caller's + # own risk and rejected under strict=True. Containers are only as frozen + # as their elements — a tuple holding a list is mutable where it counts. + if is_dataclass(value) and not isinstance(value, type): + params = getattr(type(value), "__dataclass_params__", None) + return bool(params and params.frozen) and all( + _is_frozen(getattr(value, f.name)) for f in fields(value) + ) + if isinstance(value, (tuple, frozenset)): + return all(_is_frozen(item) for item in value) + return isinstance(value, (str, bytes, int, float, bool, type(None))) + + +class InternPool(Generic[K, V]): + """Share one instance per distinct key. + + ``build`` constructs a value the first time a key appears; every later + request for that key returns the same object. With ``strict=True`` the + pool refuses values it cannot verify as immutable — a mutated shared + instance corrupts every holder at once. + """ + + def __init__(self, build: Callable[[K], V], *, strict: bool = False) -> None: + self._build = build + self._strict = strict + self._pool: dict[K, V] = {} + + def get(self, key: K) -> V: + """Return the shared instance for ``key``, building it on first use.""" + try: + return self._pool[key] + except KeyError: + value = self._build(key) + if self._strict and not _is_frozen(value): + raise TypeError( + f"InternPool(strict=True) refuses mutable value {value!r}; " + "flyweights must be immutable" + ) from None + self._pool[key] = value + return value + + def __len__(self) -> int: + """How many distinct instances exist — the number sharing saves you to.""" + return len(self._pool) + + def __contains__(self, key: object) -> bool: + return key in self._pool diff --git a/patterns/structural/flyweight/pythonic.py b/patterns/structural/flyweight/pythonic.py deleted file mode 100644 index d97caf0..0000000 --- a/patterns/structural/flyweight/pythonic.py +++ /dev/null @@ -1,53 +0,0 @@ -"""Two pythonic flyweights. - -1. ``functools.lru_cache`` on a factory function: the cache *is* the pool. -2. The guide's ``__new__`` variant: the class hides the pool, so plain - construction syntax returns shared instances. -""" - -from __future__ import annotations - -import functools -from typing import ClassVar - - -@functools.cache -def get_card(rank: str, suit: str) -> tuple[str, str]: - """The factory form: identical arguments yield the identical object.""" - return (rank, suit) - - -class Card: - """The __new__ form: ``Card('9', '♥') is Card('9', '♥')``. - - The pool is unbounded and unsynchronized: fine for a fixed domain like - 52 cards, wrong for unbounded user-supplied keys or racing threads. - """ - - _pool: ClassVar[dict[tuple[str, str], Card]] = {} - - rank: str - suit: str - - def __new__(cls, rank: str, suit: str) -> Card: - key = (rank, suit) - card = cls._pool.get(key) - if card is None: - card = super().__new__(cls) - card.rank = rank - card.suit = suit - cls._pool[key] = card - return card - - def __repr__(self) -> str: - return f"" - - -def main() -> None: - print(f"factory form shares: {get_card('9', '♥') is get_card('9', '♥')}") - print(f"__new__ form shares: {Card('9', '♥') is Card('9', '♥')}") - print(f"distinct stays distinct: {Card('9', '♥') is not Card('A', '♠')}") - - -if __name__ == "__main__": - main() diff --git a/patterns/structural/flyweight/real_world.py b/patterns/structural/flyweight/real_world.py deleted file mode 100644 index 96dd0ca..0000000 --- a/patterns/structural/flyweight/real_world.py +++ /dev/null @@ -1,32 +0,0 @@ -"""The interpreter's own flyweights. - -CPython interns small integers and many strings; ``sys.intern`` requests -interning explicitly, turning string equality into pointer equality. -""" - -from __future__ import annotations - -import sys - - -def small_ints_are_interned() -> bool: - """Integers in -5..256 are pre-built and shared.""" - a = 254 + 2 - b = 250 + 6 - return a is b - - -def interned_strings_share_identity() -> bool: - # Build strings at runtime so the compiler can't fold them together. - a = sys.intern("flyweight " + "pattern") - b = sys.intern("flyweight" + " pattern") - return a is b - - -def main() -> None: - print(f"small ints interned: {small_ints_are_interned()}") - print(f"sys.intern shares: {interned_strings_share_identity()}") - - -if __name__ == "__main__": - main() diff --git a/patterns/structural/flyweight/tests/__init__.py b/patterns/structural/flyweight/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/patterns/structural/flyweight/tests/test_flyweight.py b/patterns/structural/flyweight/tests/test_flyweight.py deleted file mode 100644 index 3cc24c2..0000000 --- a/patterns/structural/flyweight/tests/test_flyweight.py +++ /dev/null @@ -1,35 +0,0 @@ -"""Behavioral tests for all three flyweight variants.""" - -from patterns.structural.flyweight import naive, pythonic, real_world - - -class TestNaive: - def test_same_request_returns_shared_instance(self) -> None: - factory = naive.CardFactory() - assert factory.get("9", "♥") is factory.get("9", "♥") - - def test_pool_counts_distinct_only(self) -> None: - factory = naive.CardFactory() - for _ in range(10): - factory.get("9", "♥") - factory.get("A", "♠") - assert factory.distinct_cards == 2 - - -class TestPythonic: - def test_lru_cache_factory_shares(self) -> None: - assert pythonic.get_card("2", "♦") is pythonic.get_card("2", "♦") - - def test_dunder_new_shares_on_plain_construction(self) -> None: - assert pythonic.Card("9", "♥") is pythonic.Card("9", "♥") - - def test_distinct_values_stay_distinct(self) -> None: - assert pythonic.Card("9", "♥") is not pythonic.Card("A", "♠") - - -class TestRealWorld: - def test_small_int_interning(self) -> None: - assert real_world.small_ints_are_interned() - - def test_sys_intern(self) -> None: - assert real_world.interned_strings_share_identity() diff --git a/patterns/structural/flyweight/tests/test_glyph_styles.py b/patterns/structural/flyweight/tests/test_glyph_styles.py new file mode 100644 index 0000000..d06201b --- /dev/null +++ b/patterns/structural/flyweight/tests/test_glyph_styles.py @@ -0,0 +1,40 @@ +"""Behavioral tests for the glyph_styles mini-project.""" + +from __future__ import annotations + +import dataclasses + +import pytest + +from patterns.structural.flyweight.examples.glyph_styles.document import Document + + +def test_many_glyphs_share_a_handful_of_styles() -> None: + doc = Document() + for _ in range(500): + doc.write("All happy families are alike. ", font="Georgia", size=11) + doc.write("THE END", font="Georgia", size=18, weight="bold") + assert len(doc) > 10_000 + assert doc.styles.distinct_styles == 2 + + +def test_identical_runs_share_the_identical_style_object() -> None: + doc = Document() + doc.write("one", font="Georgia", size=11) + doc.write("two", font="Georgia", size=11) + assert doc.glyphs[0].style is doc.glyphs[-1].style + + +def test_styles_are_frozen() -> None: + doc = Document() + doc.write("x", font="Georgia", size=11) + with pytest.raises(dataclasses.FrozenInstanceError): + doc.glyphs[0].style.size = 99 # type: ignore[misc] + + +def test_extrinsic_state_stays_per_glyph() -> None: + doc = Document() + doc.write("ab", font="Georgia", size=11) + first, second = doc.glyphs + assert (first.char, second.char) == ("a", "b") + assert first.style is second.style # shared core, distinct occurrences diff --git a/patterns/structural/flyweight/tests/test_pool.py b/patterns/structural/flyweight/tests/test_pool.py new file mode 100644 index 0000000..4e6783b --- /dev/null +++ b/patterns/structural/flyweight/tests/test_pool.py @@ -0,0 +1,80 @@ +"""Behavioral tests for the InternPool building block.""" + +from __future__ import annotations + +from dataclasses import dataclass + +import pytest + +from patterns.structural.flyweight.pattern import InternPool + + +@dataclass(frozen=True) +class Color: + name: str + + +def test_same_key_yields_the_identical_object() -> None: + pool: InternPool[str, Color] = InternPool(Color) + assert pool.get("red") is pool.get("red") + + +def test_distinct_keys_stay_distinct() -> None: + pool: InternPool[str, Color] = InternPool(Color) + assert pool.get("red") is not pool.get("blue") + assert len(pool) == 2 + + +def test_build_runs_once_per_key() -> None: + built: list[str] = [] + + def build(name: str) -> Color: + built.append(name) + return Color(name) + + pool = InternPool(build) + pool.get("red"), pool.get("red"), pool.get("red") + assert built == ["red"] + + +def test_contains_reflects_what_was_interned() -> None: + pool: InternPool[str, Color] = InternPool(Color) + pool.get("red") + assert "red" in pool + assert "blue" not in pool + + +def test_strict_pool_accepts_frozen_values() -> None: + pool: InternPool[str, Color] = InternPool(Color, strict=True) + assert pool.get("red") is pool.get("red") + + +def test_strict_pool_refuses_mutable_values() -> None: + pool: InternPool[str, list[str]] = InternPool(lambda k: [k], strict=True) + with pytest.raises(TypeError, match="must be immutable"): + pool.get("red") + + +def test_strict_pool_refuses_a_tuple_holding_a_mutable() -> None: + # A tuple is only as frozen as its elements: mutating the inner list + # would corrupt every holder of the shared value. + pool: InternPool[str, tuple[list[str]]] = InternPool(lambda k: ([k],), strict=True) + with pytest.raises(TypeError, match="must be immutable"): + pool.get("red") + + +def test_strict_pool_accepts_deeply_frozen_nesting() -> None: + pool: InternPool[str, tuple[object, ...]] = InternPool( + lambda k: (k, frozenset({(k, 1)}), Color(k)), strict=True + ) + assert pool.get("red") is pool.get("red") + + +def test_strict_pool_refuses_a_frozen_dataclass_with_a_mutable_field() -> None: + @dataclass(frozen=True) + class Palette: + names: list[str] + + pool: InternPool[str, Palette] = InternPool(lambda k: Palette([k]), strict=True) + with pytest.raises(TypeError, match="must be immutable"): + pool.get("red") diff --git a/patterns/structural/proxy/README.md b/patterns/structural/proxy/README.md index 8e5d1e6..361f91c 100644 --- a/patterns/structural/proxy/README.md +++ b/patterns/structural/proxy/README.md @@ -14,31 +14,17 @@ stdlib_sightings: [weakref.proxy, functools.cached_property, unittest.mock.Mock] # Proxy -## Problem - -You want the *interface* of an object but not (yet, or not directly) the -object: constructing it is expensive, touching it needs a permission check, -or you want to observe every access. - -## Naive solution - -`naive.py` is the GoF virtual proxy: same interface as the real subject, -constructing it only on first use. - -## Pythonic solution - -`__getattr__` builds a generic lazy proxy in a dozen lines — no shared -interface needed, any attribute access triggers construction and then -forwards. And when the real goal is one lazily-computed attribute, -`functools.cached_property` replaces the whole apparatus. - -## In the wild - -`weakref.proxy` returns an object that forwards everything to its referent -without keeping it alive — and raises once the referent is gone. -`unittest.mock.Mock` is a proxy you interrogate afterwards. - -## Verdict - -**Use with care.** Powerful where laziness or mediation is real; remember the -disguise is skin-deep (identity, isinstance, dunders). +Stand between callers and an object to mediate access — lazily building it, +guarding it, observing it. **Verdict: use with care** — the mediation is real +power, the disguise is skin-deep. + +| Where | What | +|---|---| +| [`pattern/`](pattern/) | The importable code: `LazyProxy`, `ProtectionProxy`, `MeteringProxy` — stackable | +| [`docs/`](docs/) | [Fundamentals](docs/fundamentals.md) · [Implementation guide](docs/implementation.md) · [External examples](docs/examples.md) | +| [`examples/db_gateway/`](examples/db_gateway/) | Mini-project: an expensive warehouse connection behind all three proxies | +| [`tests/`](tests/) | Behavioral tests for the pattern and the mini-project | + +```bash +uv run python -m patterns.structural.proxy.examples.db_gateway.main +``` diff --git a/patterns/structural/proxy/__init__.py b/patterns/structural/proxy/__init__.py index f3f802a..d89cbd2 100644 --- a/patterns/structural/proxy/__init__.py +++ b/patterns/structural/proxy/__init__.py @@ -1 +1,3 @@ -"""Proxy: a stand-in that controls access to the real object.""" +from .pattern.proxies import LazyProxy as LazyProxy +from .pattern.proxies import MeteringProxy as MeteringProxy +from .pattern.proxies import ProtectionProxy as ProtectionProxy diff --git a/patterns/structural/proxy/docs/examples.md b/patterns/structural/proxy/docs/examples.md new file mode 100644 index 0000000..72669f9 --- /dev/null +++ b/patterns/structural/proxy/docs/examples.md @@ -0,0 +1,40 @@ +# Proxy — where it lives outside this repo + +Cited, real implementations to study (or point an agent at) when designing or +reviewing proxy-shaped code. + +## Python standard library + +- **`weakref.proxy`.** Forwards everything to its referent without keeping + it alive; raises `ReferenceError` once the referent is collected — a + lifetime-mediating proxy in the box. + [docs.python.org/3/library/weakref.html#weakref.proxy](https://docs.python.org/3/library/weakref.html#weakref.proxy) +- **`functools.cached_property`.** The virtual proxy shrunk to its minimal + honest size: one attribute, computed on first access, cached after. + [docs.python.org/3/library/functools.html#functools.cached_property](https://docs.python.org/3/library/functools.html#functools.cached_property) +- **`unittest.mock.Mock`.** A stand-in you interrogate afterwards — the + smart-reference flavor: every access recorded, assertions available. + [docs.python.org/3/library/unittest.mock.html](https://docs.python.org/3/library/unittest.mock.html) + +## Major ecosystems + +- **Werkzeug `LocalProxy`** (Flask's `request` and `g`). Module-level names + that forward to context-local objects per request — remote-ish proxies to + "wherever the current context keeps it". + [werkzeug.palletsprojects.com/en/stable/local/](https://werkzeug.palletsprojects.com/en/stable/local/) +- **Django `SimpleLazyObject`** and lazy `QuerySet` evaluation. Virtual + proxies in a mainstream ORM: `request.user` is built only if touched; + querysets hit the database only when iterated. + [docs.djangoproject.com/en/stable/ref/models/querysets/#when-querysets-are-evaluated](https://docs.djangoproject.com/en/stable/ref/models/querysets/#when-querysets-are-evaluated) +- **`wrapt` / `lazy-object-proxy`.** Production-grade generic proxies whose + documentation is largely about the dunder problem — evidence for how hard + the full disguise really is. + [wrapt.readthedocs.io](https://wrapt.readthedocs.io/) + +## What to notice across all of them + +Each one mediates exactly one concern (lifetime, laziness, context, +recording), none pretend the disguise is complete — `weakref.proxy` +documents which operations see through it, Werkzeug documents `isinstance` +behavior — and the ones that must survive dunders (`wrapt`) pay a whole +library's worth of effort for it. diff --git a/patterns/structural/proxy/docs/fundamentals.md b/patterns/structural/proxy/docs/fundamentals.md new file mode 100644 index 0000000..a5d8d0b --- /dev/null +++ b/patterns/structural/proxy/docs/fundamentals.md @@ -0,0 +1,76 @@ +# Proxy — fundamentals + +## Intent + +Provide a surrogate for another object to control access to it. The proxy +offers the subject's interface but mediates: deferring construction +(virtual), guarding operations (protection), observing traffic (smart +reference), or standing in for something remote. + +## Participants + +| Role | Classic (GoF) form | Python form | +|---|---|---| +| Subject | Abstract interface proxy and real object share | No interface needed — `__getattr__` forwards anything | +| Real subject | The expensive/guarded/remote object | Same | +| Proxy | Implements the interface, holds the real subject | A dozen-line forwarding class — see [`pattern/proxies.py`](../pattern/proxies.py) | + +## Mechanism + +1. The proxy holds (or knows how to build) the subject. +2. Attribute access hits the proxy first; it applies its one mediation. +3. Then it forwards to the subject with plain `getattr`. +4. Proxies are objects too, so mediations stack — metering over protection + over laziness is three small classes composed, not one class with flags. + +## The classic form, and what Python absorbs + +The book's virtual proxy shares an abstract interface with its subject and +re-implements every method as a forwarding stub: + +```python +class Report(ABC): + @abstractmethod + def summary(self) -> str: ... + + +class ReportProxy(Report): # same interface, by inheritance + def __init__(self) -> None: + self._real: ExpensiveReport | None = None + + def summary(self) -> str: # one stub per subject method + if self._real is None: + self._real = ExpensiveReport() + return self._real.summary() +``` + +Python absorbs the ceremony twice. `__getattr__` — called only when normal +lookup fails — forwards the *entire* surface in one method, no shared +interface required. And when the real goal is "compute this one attribute +lazily", `functools.cached_property` is the whole pattern at the right size. +What Python does **not** absorb is the disguise: `isinstance` checks, +identity comparisons, and dunder lookups (which bypass `__getattr__` +entirely) all see through the proxy. That caveat leads this unit. + +## When to use it + +- Construction is genuinely expensive and often unnecessary (virtual). +- Operations need per-caller mediation — permissions, quotas, audit + (protection / smart reference). +- Several mediations must compose over one subject — the case a single + `cached_property` can't cover. + +## When not to use it + +- One lazily computed attribute → `functools.cached_property`. +- The mediation is per-*call* on known functions → that's a decorator; see + `structural/decorator`. +- Code downstream relies on `isinstance`/identity of the subject — the + disguise will leak, and dunder-dependent protocols (`len`, iteration, + context managers) won't forward. + +## Verdict: use with care + +Powerful where laziness or mediation is real; the skin-deep disguise is the +tax. Production-grade generic proxies (`wrapt`) exist precisely because the +dunder problem is hard — reach for them before hand-rolling cleverness. diff --git a/patterns/structural/proxy/docs/implementation.md b/patterns/structural/proxy/docs/implementation.md new file mode 100644 index 0000000..e85c85a --- /dev/null +++ b/patterns/structural/proxy/docs/implementation.md @@ -0,0 +1,67 @@ +# Proxy — putting it into a system + +## The smell it fixes + +Mediation logic fused into either the subject or every caller: + +```python +class WarehouseConnection: + def query(self, sql, *, role, audit_log): # the subject now knows + if role != "admin" and is_write(sql): # about roles... + raise PermissionError + audit_log.append(sql) # ...and about auditing + ... +``` + +The connection's job is querying. Permissions and audit are *access* +concerns — they belong between the caller and the subject, in a layer each +side can ignore. + +## Steps + +1. **Name the mediation**: deferral, guarding, observation, remoteness. One + proxy per concern — resist the mega-proxy with flags. +2. **Write each as a `__getattr__` forwarder** holding the subject (or its + factory). Keep the proxy's own attributes few; `__getattr__` only fires + for names not found on the proxy itself. +3. **Stack in the order the policy demands.** Outermost runs first: + metering outside protection counts denied attempts; protection outside + laziness means denied callers never pay construction. + [`examples/db_gateway/`](../examples/db_gateway/) pins exactly that order. +4. **Keep a proxy-free path** for code that legitimately owns the subject — + construction stays public, like any good facade or wrapper discipline. +5. **Test the mediation, not the forwarding**: assert the subject is *not* + built before first use, denied roles *never* reach it, counts match + traffic. Plain forwarding needs no tests of its own. + +## Python idioms that keep it small + +- **`__getattr__` (not `__getattribute__`)** — it fires only on lookup + misses, so the proxy's own state stays reachable and recursion stays away. +- **`functools.cached_property`** when the mediation is "one expensive + attribute, once" — the apparatus disappears into the stdlib. +- **`weakref.proxy`** when the mediation is lifetime, not access. +- **Factories, not eager subjects**, for virtual proxies: pass + `lambda: Connection(dsn)`, never a pre-built connection. + +## Pitfalls + +- **The disguise is skin-deep** — `isinstance`, `is`, and every dunder + bypass `__getattr__`. A proxied object that must support `len()`, + iteration, or `with` needs those dunders written explicitly. +- **`__getattr__` recursion**: initialize the proxy's own attributes before + any forwarding can happen, or route them through `object.__setattr__`. +- **Name shadowing**: an attribute the proxy defines (`access_counts`) wins + over the subject's attribute of the same name — keep proxy surfaces tiny. +- **Leaking the subject**: a mediated method that returns `self._subject` + hands callers an unguarded reference; return proxied results if the + guarantee matters. + +## Worked example + +[`examples/db_gateway/`](../examples/db_gateway/) stacks metering over +role-protection over a lazy warehouse connection: + +```bash +uv run python -m patterns.structural.proxy.examples.db_gateway.main +``` diff --git a/patterns/structural/proxy/examples/db_gateway/gateway.py b/patterns/structural/proxy/examples/db_gateway/gateway.py new file mode 100644 index 0000000..8aeb750 --- /dev/null +++ b/patterns/structural/proxy/examples/db_gateway/gateway.py @@ -0,0 +1,49 @@ +"""The mini-project: one expensive connection, three kinds of mediation. + +The stack, outside-in: metering observes everything (including denials), +protection guards by role, laziness defers the expensive connect until a +query actually runs. This composition over one subject is what a single +``cached_property`` cannot express -- and the reason the pattern survives. +""" + +from __future__ import annotations + +from patterns.structural.proxy.pattern import LazyProxy, MeteringProxy, ProtectionProxy + +READ_ONLY_ATTRS = frozenset({"query", "connected"}) + + +class WarehouseConnection: + """The real subject; pretend ``__init__`` dials a distant warehouse.""" + + instances_connected = 0 + + def __init__(self, dsn: str) -> None: + type(self).instances_connected += 1 + self.dsn = dsn + self.connected = True + self.queries_run: list[str] = [] + + def query(self, sql: str) -> list[str]: + self.queries_run.append(sql) + return [f"row for {sql!r}"] + + def drop_table(self, name: str) -> str: + return f"dropped {name}" + + +def allow_for_role(role: str) -> frozenset[str]: + """Analyst sees the read-only surface; admin sees everything.""" + return ( + frozenset({"query", "connected", "drop_table", "dsn", "queries_run"}) + if role == "admin" + else READ_ONLY_ATTRS + ) + + +def build_gateway(dsn: str, *, role: str) -> MeteringProxy: + """Stack the three proxies over one lazily-built connection.""" + allowed = allow_for_role(role) + lazy = LazyProxy(lambda: WarehouseConnection(dsn)) + guarded = ProtectionProxy(lazy, lambda name: name in allowed or name == "is_built") + return MeteringProxy(guarded) diff --git a/patterns/structural/proxy/examples/db_gateway/main.py b/patterns/structural/proxy/examples/db_gateway/main.py new file mode 100644 index 0000000..9c8f88a --- /dev/null +++ b/patterns/structural/proxy/examples/db_gateway/main.py @@ -0,0 +1,24 @@ +"""Demo: laziness, denial, and metering over one connection.""" + +from __future__ import annotations + +from patterns.structural.proxy.examples.db_gateway.gateway import ( + WarehouseConnection, + build_gateway, +) + + +def main() -> None: + analyst = build_gateway("warehouse://prod", role="analyst") + print(f"connections before any query: {WarehouseConnection.instances_connected}") + rows = analyst.query("SELECT sku, qty FROM stock") + print(f"first query connected lazily: {WarehouseConnection.instances_connected} -> {rows}") + try: + analyst.drop_table("stock") + except PermissionError as exc: + print(f"analyst denied: {exc}") + print(f"metered access counts: {dict(analyst.access_counts)}") + + +if __name__ == "__main__": + main() diff --git a/patterns/structural/proxy/naive.py b/patterns/structural/proxy/naive.py deleted file mode 100644 index a886ffc..0000000 --- a/patterns/structural/proxy/naive.py +++ /dev/null @@ -1,49 +0,0 @@ -"""The Gang of Four virtual proxy, translated literally. - -The proxy shares the subject's interface and defers the expensive -construction until the first real call. -""" - -from __future__ import annotations - -from abc import ABC, abstractmethod - - -class Report(ABC): - @abstractmethod - def summary(self) -> str: ... - - -class ExpensiveReport(Report): - """The real subject; pretend __init__ crunches a warehouse of data.""" - - instances_built = 0 - - def __init__(self) -> None: - type(self).instances_built += 1 - - def summary(self) -> str: - return "42 pages of insight" - - -class ReportProxy(Report): - """Same interface; builds the real subject only when first needed.""" - - def __init__(self) -> None: - self._real: ExpensiveReport | None = None - - def summary(self) -> str: - if self._real is None: - self._real = ExpensiveReport() - return self._real.summary() - - -def main() -> None: - proxy = ReportProxy() - print(f"built after construction: {ExpensiveReport.instances_built}") - print(proxy.summary()) - print(f"built after first use: {ExpensiveReport.instances_built}") - - -if __name__ == "__main__": - main() diff --git a/patterns/structural/proxy/pattern/__init__.py b/patterns/structural/proxy/pattern/__init__.py new file mode 100644 index 0000000..6493a2d --- /dev/null +++ b/patterns/structural/proxy/pattern/__init__.py @@ -0,0 +1,3 @@ +from .proxies import LazyProxy as LazyProxy +from .proxies import MeteringProxy as MeteringProxy +from .proxies import ProtectionProxy as ProtectionProxy diff --git a/patterns/structural/proxy/pattern/proxies.py b/patterns/structural/proxy/pattern/proxies.py new file mode 100644 index 0000000..d9bba73 --- /dev/null +++ b/patterns/structural/proxy/pattern/proxies.py @@ -0,0 +1,65 @@ +"""Three composable proxies: lazy, protection, metering. + +Each forwards attribute access to a subject via ``__getattr__`` -- no shared +interface required -- and each adds exactly one kind of mediation. Because +every proxy is also a plain object, they stack: +``MeteringProxy(ProtectionProxy(LazyProxy(build), allow))``. + +The disguise is skin-deep (this unit's standing caveat): ``isinstance``, +identity, and dunder lookups all see the proxy, not the subject. +""" + +from __future__ import annotations + +from collections import Counter +from collections.abc import Callable +from typing import Any + +# Not-yet-built marker: ``None`` won't do, because a factory may legitimately +# return ``None`` and that result must still be cached exactly once. +_MISSING = object() + + +class LazyProxy: + """Defer construction: the subject is built on first attribute access.""" + + def __init__(self, factory: Callable[[], object]) -> None: + # object.__setattr__-free here: plain attributes are fine because + # __getattr__ only fires for names *not* found on the proxy itself. + self._factory = factory + self._subject: object = _MISSING + + @property + def is_built(self) -> bool: + """Whether the expensive subject exists yet.""" + return self._subject is not _MISSING + + def __getattr__(self, name: str) -> Any: + if self._subject is _MISSING: + self._subject = self._factory() + return getattr(self._subject, name) + + +class ProtectionProxy: + """Guard access: every attribute name passes ``allow`` or raises.""" + + def __init__(self, subject: object, allow: Callable[[str], bool]) -> None: + self._subject = subject + self._allow = allow + + def __getattr__(self, name: str) -> Any: + if not self._allow(name): + raise PermissionError(f"access to {name!r} denied") + return getattr(self._subject, name) + + +class MeteringProxy: + """Observe access: count every attribute lookup by name, then forward.""" + + def __init__(self, subject: object) -> None: + self._subject = subject + self.access_counts: Counter[str] = Counter() + + def __getattr__(self, name: str) -> Any: + self.access_counts[name] += 1 + return getattr(self._subject, name) diff --git a/patterns/structural/proxy/pythonic.py b/patterns/structural/proxy/pythonic.py deleted file mode 100644 index 2adb36b..0000000 --- a/patterns/structural/proxy/pythonic.py +++ /dev/null @@ -1,60 +0,0 @@ -"""Lazy access, two pythonic sizes. - -A generic ``__getattr__`` proxy defers construction of *any* object; and -when the goal is one expensive attribute, ``functools.cached_property`` -is the whole pattern. -""" - -from __future__ import annotations - -from collections.abc import Callable -from functools import cached_property -from typing import Any - - -class LazyProxy: - """Builds the real object on first attribute access, then forwards.""" - - def __init__(self, factory: Callable[[], object]) -> None: - # Avoid __setattr__/__getattr__ recursion via object.__setattr__. - object.__setattr__(self, "_factory", factory) - object.__setattr__(self, "_real", None) - - def __getattr__(self, name: str) -> Any: - real = object.__getattribute__(self, "_real") - if real is None: - real = object.__getattribute__(self, "_factory")() - object.__setattr__(self, "_real", real) - return getattr(real, name) - - -class Dataset: - """cached_property: the one-attribute proxy, built into functools.""" - - def __init__(self, raw: list[int]) -> None: - self.raw = raw - self.computations = 0 - - @cached_property - def stats(self) -> tuple[int, int]: - self.computations += 1 - return (min(self.raw), max(self.raw)) - - -def main() -> None: - built: list[str] = [] - - def factory() -> object: - built.append("now") - return "the real string" - - proxy = LazyProxy(factory) - print(f"built before use: {built}") - print(f"forwarded upper(): {proxy.upper()}, built: {built}") - - data = Dataset([3, 1, 4]) - print(f"stats {data.stats} computed {data.computations} time(s) over 2 reads: {data.stats}") - - -if __name__ == "__main__": - main() diff --git a/patterns/structural/proxy/real_world.py b/patterns/structural/proxy/real_world.py deleted file mode 100644 index c98fd22..0000000 --- a/patterns/structural/proxy/real_world.py +++ /dev/null @@ -1,40 +0,0 @@ -"""``weakref.proxy``: a stdlib proxy with teeth. - -It forwards attribute access to the referent without keeping it alive; -once the referent is collected, the proxy raises ReferenceError. -""" - -from __future__ import annotations - -import weakref - - -class Service: - def ping(self) -> str: - return "pong" - - -def live_proxy_forwards() -> str: - service = Service() - proxy = weakref.proxy(service) - return str(proxy.ping()) - - -def dead_proxy_raises() -> bool: - service = Service() - proxy = weakref.proxy(service) - del service # CPython refcounting collects immediately - try: - proxy.ping() - except ReferenceError: - return True - return False - - -def main() -> None: - print(f"live proxy: {live_proxy_forwards()}") - print(f"dead proxy raises ReferenceError: {dead_proxy_raises()}") - - -if __name__ == "__main__": - main() diff --git a/patterns/structural/proxy/tests/__init__.py b/patterns/structural/proxy/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/patterns/structural/proxy/tests/test_db_gateway.py b/patterns/structural/proxy/tests/test_db_gateway.py new file mode 100644 index 0000000..6fd5caf --- /dev/null +++ b/patterns/structural/proxy/tests/test_db_gateway.py @@ -0,0 +1,64 @@ +"""Behavioral tests for the db_gateway mini-project.""" + +from __future__ import annotations + +import pytest + +from patterns.structural.proxy.examples.db_gateway.gateway import WarehouseConnection, build_gateway + + +@pytest.fixture(autouse=True) +def reset_connection_counter() -> None: + WarehouseConnection.instances_connected = 0 + + +def test_no_connection_until_the_first_query() -> None: + gateway = build_gateway("warehouse://prod", role="analyst") + assert WarehouseConnection.instances_connected == 0 + rows = gateway.query("SELECT 1") + assert WarehouseConnection.instances_connected == 1 + assert rows == ["row for 'SELECT 1'"] + + +def test_denied_role_never_touches_the_subject() -> None: + gateway = build_gateway("warehouse://prod", role="analyst") + with pytest.raises(PermissionError): + gateway.drop_table("stock") + # The denial fired before the lazy layer: nothing ever connected. + assert WarehouseConnection.instances_connected == 0 + + +def test_admin_role_reaches_the_full_surface() -> None: + gateway = build_gateway("warehouse://prod", role="admin") + assert gateway.drop_table("stock") == "dropped stock" + + +def test_metering_counts_all_traffic_including_denials() -> None: + gateway = build_gateway("warehouse://prod", role="analyst") + gateway.query("SELECT 1") + gateway.query("SELECT 2") + with pytest.raises(PermissionError): + gateway.drop_table("stock") + assert gateway.access_counts == {"query": 2, "drop_table": 1} + + +def test_analyst_can_read_connected_but_not_dsn() -> None: + gateway = build_gateway("warehouse://prod", role="analyst") + gateway.query("SELECT 1") # force the connection into existence + assert gateway.connected is True + with pytest.raises(PermissionError): + gateway.dsn # noqa: B018 — the access itself is the assertion + + +def test_admin_reads_dsn_and_query_log() -> None: + gateway = build_gateway("warehouse://prod", role="admin") + gateway.query("SELECT 1") + assert gateway.dsn == "warehouse://prod" + assert gateway.queries_run == ["SELECT 1"] + + +def test_is_built_passes_through_the_stack() -> None: + gateway = build_gateway("warehouse://prod", role="analyst") + assert gateway.is_built is False + gateway.query("SELECT 1") + assert gateway.is_built is True diff --git a/patterns/structural/proxy/tests/test_proxies.py b/patterns/structural/proxy/tests/test_proxies.py new file mode 100644 index 0000000..6410434 --- /dev/null +++ b/patterns/structural/proxy/tests/test_proxies.py @@ -0,0 +1,101 @@ +"""Behavioral tests for the proxy building blocks.""" + +from __future__ import annotations + +import pytest + +from patterns.structural.proxy.pattern import LazyProxy, MeteringProxy, ProtectionProxy + + +class Subject: + def __init__(self) -> None: + self.color = "green" + + def greet(self) -> str: + return "hello" + + +def test_lazy_proxy_defers_construction_until_first_access() -> None: + built: list[str] = [] + + def factory() -> Subject: + built.append("now") + return Subject() + + proxy = LazyProxy(factory) + assert not proxy.is_built + assert built == [] + assert proxy.greet() == "hello" + assert proxy.is_built + assert built == ["now"] + + +def test_lazy_proxy_builds_exactly_once() -> None: + built: list[str] = [] + + def factory() -> Subject: + built.append("now") + return Subject() + + proxy = LazyProxy(factory) + proxy.greet(), proxy.greet(), proxy.color + assert built == ["now"] + + +def test_lazy_proxy_caches_a_none_subject_once() -> None: + # A factory may legitimately produce None (e.g. a failed connect that the + # caller inspects); that result is still "built" and must not retrigger. + built: list[str] = [] + + def factory() -> None: + built.append("now") + return None + + proxy = LazyProxy(factory) + with pytest.raises(AttributeError): + proxy.anything # noqa: B018 — the access itself is the trigger + with pytest.raises(AttributeError): + proxy.other # noqa: B018 + assert built == ["now"] + assert proxy.is_built + + +def test_protection_proxy_forwards_allowed_and_denies_the_rest() -> None: + proxy = ProtectionProxy(Subject(), allow=lambda name: name == "greet") + assert proxy.greet() == "hello" + with pytest.raises(PermissionError, match="color"): + proxy.color # noqa: B018 — the access itself is the assertion + + +def test_metering_proxy_counts_each_attribute_access() -> None: + proxy = MeteringProxy(Subject()) + proxy.greet(), proxy.greet(), proxy.color + assert proxy.access_counts == {"greet": 2, "color": 1} + + +def test_stacked_proxies_compose_their_mediations() -> None: + built: list[str] = [] + + def factory() -> Subject: + built.append("now") + return Subject() + + stack = MeteringProxy(ProtectionProxy(LazyProxy(factory), lambda n: n == "greet")) + with pytest.raises(PermissionError): + stack.color # noqa: B018 — denied by the protection layer + assert built == [] # denial happened before the lazy layer built anything + assert stack.greet() == "hello" + assert built == ["now"] + assert stack.access_counts == {"color": 1, "greet": 1} # denials metered too + + +def test_the_disguise_is_skin_deep() -> None: + # Dunder lookups bypass __getattr__: a subject that supports len() does + # not make the proxy support it. That is the caveat, behaviorally. + class Sized: + def __len__(self) -> int: + return 3 + + proxy = LazyProxy(Sized) + with pytest.raises(TypeError): + len(proxy) diff --git a/patterns/structural/proxy/tests/test_proxy.py b/patterns/structural/proxy/tests/test_proxy.py deleted file mode 100644 index 9875110..0000000 --- a/patterns/structural/proxy/tests/test_proxy.py +++ /dev/null @@ -1,48 +0,0 @@ -"""Behavioral tests for all three proxy variants.""" - -from patterns.structural.proxy import naive, pythonic, real_world - - -class TestNaive: - def test_construction_is_deferred_until_first_use(self) -> None: - before = naive.ExpensiveReport.instances_built - proxy = naive.ReportProxy() - assert naive.ExpensiveReport.instances_built == before - assert proxy.summary() == "42 pages of insight" - assert naive.ExpensiveReport.instances_built == before + 1 - - def test_repeat_calls_reuse_the_subject(self) -> None: - before = naive.ExpensiveReport.instances_built - proxy = naive.ReportProxy() - proxy.summary() - proxy.summary() - assert naive.ExpensiveReport.instances_built == before + 1 - - -class TestPythonic: - def test_lazy_proxy_defers_then_forwards(self) -> None: - built: list[str] = [] - - def factory() -> object: - built.append("x") - return "abc" - - proxy = pythonic.LazyProxy(factory) - assert built == [] - assert proxy.upper() == "ABC" - assert proxy.startswith("a") - assert built == ["x"] # built exactly once - - def test_cached_property_computes_once(self) -> None: - data = pythonic.Dataset([3, 1, 4]) - assert data.stats == (1, 4) - assert data.stats == (1, 4) - assert data.computations == 1 - - -class TestRealWorld: - def test_live_weakref_proxy_forwards(self) -> None: - assert real_world.live_proxy_forwards() == "pong" - - def test_dead_weakref_proxy_raises(self) -> None: - assert real_world.dead_proxy_raises() diff --git a/pyproject.toml b/pyproject.toml index 8303eb7..8277bd8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "python-design-patterns" -version = "1.0.0" -description = "Design patterns in Python: naive, pythonic, and real-world examples, with an MCP server for agents." +version = "2.0.0" +description = "Design patterns as importable Python modules — docs, runnable mini-projects, and an MCP server for agents." readme = "README.md" license = { file = "LICENSE" } authors = [{ name = "SuperElectron" }] @@ -27,7 +27,7 @@ Homepage = "https://github.com/SuperElectron/python-design-patterns" Reference = "https://python-patterns.guide/" [project.scripts] -python-design-patterns-mcp = "design_patterns_mcp.server:main" +python-design-patterns-mcp = "design_patterns.mcp.server:main" [dependency-groups] dev = [ @@ -37,6 +37,7 @@ dev = [ "mypy>=1.13", "types-pyyaml>=6.0", "pytest-asyncio>=0.24", + "pytest-timeout>=2.4.0", ] [build-system] @@ -44,7 +45,7 @@ requires = ["hatchling"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] -packages = ["src/design_patterns", "src/design_patterns_mcp", "patterns"] +packages = ["src/design_patterns", "patterns"] [tool.ruff] line-length = 100 @@ -54,16 +55,25 @@ src = ["src", "patterns", "tests"] [tool.ruff.lint] select = ["E", "F", "W", "I", "UP", "B", "SIM", "RUF"] +[tool.ruff.lint.isort] +# Namespace packages (PEP 420): no __init__.py to detect these by, so declare them. +known-first-party = ["patterns", "design_patterns"] + [tool.mypy] strict = true python_version = "3.11" files = ["src", "patterns"] +# Namespace packages (PEP 420): no empty __init__.py — resolve modules by +# their full dotted path from the package bases below. +explicit_package_bases = true +mypy_path = ["src", "."] [tool.pytest.ini_options] testpaths = ["tests", "patterns"] pythonpath = ["."] asyncio_mode = "auto" -addopts = "-q --cov=src --cov=patterns --cov-report=term-missing" +addopts = "-q --import-mode=importlib --cov=src --cov=patterns --cov-report=term-missing" +timeout = 30 # a shutdown regression must fail CI, not hang it [tool.coverage.report] skip_empty = true diff --git a/src/design_patterns/__init__.py b/src/design_patterns/__init__.py deleted file mode 100644 index 38cee3d..0000000 --- a/src/design_patterns/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -"""Design patterns in Python: catalog loader and shared utilities.""" - -__version__ = "1.0.0" diff --git a/src/design_patterns/catalog.py b/src/design_patterns/catalog.py index f34952c..bb7a707 100644 --- a/src/design_patterns/catalog.py +++ b/src/design_patterns/catalog.py @@ -15,10 +15,14 @@ from typing import Literal, get_args Verdict = Literal["pythonic", "use-with-care", "prefer-alternative"] -VariantName = Literal["naive", "pythonic", "real_world"] +DocName = Literal["fundamentals", "implementation", "examples"] VERDICTS: tuple[str, ...] = get_args(Verdict) -VARIANTS: tuple[str, ...] = get_args(VariantName) +DOC_NAMES: tuple[str, ...] = get_args(DocName) + +# Pre-v2 units shipped flat naive/pythonic/real_world files; their presence in +# a unit today is stale debris and fails validation. +_RETIRED_VARIANT_FILES = ("naive.py", "pythonic.py", "real_world.py") _REQUIRED_KEYS = frozenset({"id", "name", "guide_url", "problem", "symptoms", "verdict", "caveats"}) @@ -51,9 +55,31 @@ def group(self) -> str: def slug(self) -> str: return self.id.split("/", 1)[1] - def variants(self) -> dict[str, Path]: - """The example files this unit actually ships.""" - return {v: self.path / f"{v}.py" for v in VARIANTS if (self.path / f"{v}.py").is_file()} + def docs(self) -> dict[str, Path]: + """The unit's teaching docs (fundamentals/implementation/examples).""" + return { + d: self.path / "docs" / f"{d}.md" + for d in DOC_NAMES + if (self.path / "docs" / f"{d}.md").is_file() + } + + def examples(self) -> dict[str, Path]: + """Runnable mini-project packages: ``examples//`` with a ``main.py``.""" + examples_dir = self.path / "examples" + if not examples_dir.is_dir(): + return {} + return { + child.name: child + for child in sorted(examples_dir.iterdir()) + if child.is_dir() and (child / "main.py").is_file() + } + + def sources(self) -> dict[str, Path]: + """The pattern's own code: ``pattern/*.py``, keyed by filename.""" + pattern_dir = self.path / "pattern" + if not pattern_dir.is_dir(): + return {} + return {p.name: p for p in sorted(pattern_dir.glob("*.py"))} def _split_frontmatter(text: str, readme: Path) -> tuple[str, str]: @@ -119,11 +145,46 @@ def _parse_pattern(readme: Path, root: Path) -> Pattern: prose=prose, path=unit_dir, ) - if not pattern.variants(): - raise CatalogError(f"{readme}: unit ships no naive/pythonic/real_world example") + _validate_shape(pattern, readme) return pattern +def _validate_shape(pattern: Pattern, readme: Path) -> None: + """Every unit must ship the complete module shape: pattern/ docs/ examples/ tests/.""" + unit = pattern.path + if stale := sorted(f for f in _RETIRED_VARIANT_FILES if (unit / f).is_file()): + raise CatalogError(f"{readme}: module unit still ships legacy variant files: {stale}") + missing_docs = [d for d in DOC_NAMES if not (unit / "docs" / f"{d}.md").is_file()] + if missing_docs: + raise CatalogError(f"{readme}: module unit missing docs/: {missing_docs}") + if not (unit / "pattern" / "__init__.py").is_file(): + raise CatalogError(f"{readme}: module unit's pattern/ package has no __init__.py") + examples = pattern.examples() + if not examples: + raise CatalogError(f"{readme}: module unit ships no runnable examples//main.py") + if dunder_mains := sorted(unit.rglob("__main__.py")): + raise CatalogError( + f"{readme}: __main__.py is banned " + f"({[str(f.relative_to(unit)) for f in dunder_mains]}) — " + "entry points are main.py, run via python -m .main" + ) + for stray in _empty_inits(unit): + raise CatalogError( + f"{readme}: delete empty __init__.py ({stray.relative_to(unit)}) — " + "namespace packages (PEP 420) carry the structure" + ) + tests_dir = unit / "tests" + if not any(tests_dir.glob("test_*.py")): + raise CatalogError(f"{readme}: module unit has no tests/test_*.py") + + +def _empty_inits(unit: Path) -> list[Path]: + """Empty ``__init__.py`` anywhere in the unit — banned; only load-bearing ones exist.""" + return sorted( + f for f in unit.rglob("__init__.py") if f.stat().st_size == 0 or not f.read_text().strip() + ) + + @dataclass(frozen=True) class Catalog: """All validated pattern units, ordered by id.""" @@ -140,12 +201,13 @@ def ids(self) -> tuple[str, ...]: return tuple(p.id for p in self.patterns) def to_json(self) -> str: - """The ``catalog://index`` payload: everything except prose and paths.""" + """The ``catalog://index`` payload: metadata plus docs/examples listings.""" entries = [] for p in self.patterns: entry = asdict(p) del entry["prose"], entry["path"] - entry["variants"] = sorted(p.variants()) + entry["docs"] = sorted(p.docs()) + entry["examples"] = sorted(p.examples()) entries.append(entry) return json.dumps(entries, indent=2) diff --git a/src/design_patterns_mcp/sandbox.py b/src/design_patterns/mcp/sandbox.py similarity index 63% rename from src/design_patterns_mcp/sandbox.py rename to src/design_patterns/mcp/sandbox.py index 1e69529..441f5cb 100644 --- a/src/design_patterns_mcp/sandbox.py +++ b/src/design_patterns/mcp/sandbox.py @@ -1,7 +1,7 @@ -"""Sandboxed execution of catalog example files -- and nothing else. +"""Sandboxed execution of catalog example packages -- and nothing else. The contract: only paths resolved from the catalog index are runnable. -The (id, variant) pair is looked up, never joined into a path, so there is +The (id, example) pair is looked up, never joined into a path, so there is no traversal and no arbitrary-file execution surface. """ @@ -26,22 +26,12 @@ class RunResult: timed_out: bool = False -def run_example(catalog: Catalog, pattern_id: str, variant: str) -> RunResult: - """Execute one vendored example in a subprocess and capture its output.""" - pattern = catalog.get(pattern_id) # KeyError for unknown ids -- by design - variants = pattern.variants() - if variant not in variants: - raise KeyError(f"{pattern_id} has no variant {variant!r} (has: {sorted(variants)})") - path = variants[variant] # resolved by the catalog, never by the caller - if not path.is_file(): # a real check, not an assert: survives python -O - raise FileNotFoundError(f"catalog names {path} but it does not exist") - - repo_root = pattern.path.parents[2] - module = f"patterns.{pattern.group}.{pattern.slug}.{variant}" +def _run_module(repo_root: str, module: str) -> RunResult: + """Run ``python -I -m `` with a scrubbed env, scratch cwd, and output caps.""" # -I ignores PYTHONPATH by design, so the repo root (resolved by the # catalog, never by the caller) is injected in the bootstrap itself. bootstrap = ( - f"import sys, runpy; sys.path.insert(0, {str(repo_root)!r}); " + f"import sys, runpy; sys.path.insert(0, {repo_root!r}); " f"runpy.run_module({module!r}, run_name='__main__')" ) with tempfile.TemporaryDirectory() as scratch_cwd: @@ -68,3 +58,18 @@ def run_example(catalog: Catalog, pattern_id: str, variant: str) -> RunResult: stdout=completed.stdout[:MAX_OUTPUT_BYTES], stderr=completed.stderr[:MAX_OUTPUT_BYTES], ) + + +def run_example_package(catalog: Catalog, pattern_id: str, example: str) -> RunResult: + """Execute a unit's ``examples/`` mini-project in a subprocess.""" + pattern = catalog.get(pattern_id) # KeyError for unknown ids -- by design + examples = pattern.examples() + if example not in examples: + raise KeyError(f"{pattern_id} has no example {example!r} (has: {sorted(examples)})") + path = examples[example] # resolved by the catalog, never by the caller + if not (path / "main.py").is_file(): # a real check, not an assert: survives python -O + raise FileNotFoundError(f"catalog names {path} but it has no main.py") + + repo_root = pattern.path.parents[2] + module = f"patterns.{pattern.group}.{pattern.slug}.examples.{example}.main" + return _run_module(str(repo_root), module) diff --git a/src/design_patterns_mcp/search.py b/src/design_patterns/mcp/search.py similarity index 100% rename from src/design_patterns_mcp/search.py rename to src/design_patterns/mcp/search.py diff --git a/src/design_patterns_mcp/server.py b/src/design_patterns/mcp/server.py similarity index 56% rename from src/design_patterns_mcp/server.py rename to src/design_patterns/mcp/server.py index 1cab21f..95aa70f 100644 --- a/src/design_patterns_mcp/server.py +++ b/src/design_patterns/mcp/server.py @@ -11,10 +11,11 @@ from typing import Any from mcp.server import MCPServer +from mcp.server.mcpserver.exceptions import ResourceError -from design_patterns.catalog import Catalog, Pattern, load_catalog -from design_patterns_mcp.sandbox import run_example as _run_example -from design_patterns_mcp.search import SearchIndex +from design_patterns.catalog import DOC_NAMES, Catalog, Pattern, load_catalog +from design_patterns.mcp.sandbox import run_example_package as _run_example_package +from design_patterns.mcp.search import SearchIndex # Lazy initialization (see patterns/python/global_object): importing this @@ -33,11 +34,13 @@ def get_index() -> SearchIndex: "python-design-patterns", instructions=( "Design patterns in Python: 32 units covering all 23 GoF patterns, " - "Python-native patterns, and modern additions. Each unit has prose, a " - "naive (GoF-literal) example, a pythonic example, a real_world stdlib " - "sighting, and an honest verdict. Start with search_patterns or " - "recommend_pattern; verdicts of 'prefer-alternative' tell you what to " - "write instead." + "Python-native patterns, and modern additions, each with an honest " + "verdict. Start with search_patterns or recommend_pattern; verdicts of " + "'prefer-alternative' tell you what to write instead. Every unit " + "offers three access levels: get_pattern_docs " + "(fundamentals/implementation/examples), then list_examples + " + "run_example for runnable mini-projects, then read_source " + "(the pattern/ package)." ), ) @@ -51,22 +54,18 @@ def _summary(pattern: Pattern) -> dict[str, Any]: } -def _detail(pattern: Pattern, include_source: str | None) -> dict[str, Any]: - detail: dict[str, Any] = { +def _detail(pattern: Pattern) -> dict[str, Any]: + return { **_summary(pattern), "aliases": list(pattern.aliases), "guide_url": pattern.guide_url, "symptoms": list(pattern.symptoms), "caveats": list(pattern.caveats), "stdlib_sightings": list(pattern.stdlib_sightings), - "variants": sorted(pattern.variants()), + "docs": sorted(pattern.docs()), + "examples": sorted(pattern.examples()), "prose": pattern.prose, } - if include_source: - variants = pattern.variants() - wanted = sorted(variants) if include_source == "all" else [include_source] - detail["source"] = {name: variants[name].read_text() for name in wanted if name in variants} - return detail @mcp.tool() @@ -83,16 +82,11 @@ def list_patterns(group: str | None = None, verdict: str | None = None) -> list[ @mcp.tool() -def get_pattern(pattern_id: str, variant: str | None = None) -> dict[str, Any]: - """Fetch one pattern's full documentation. pattern_id is '/' - (e.g. 'structural/decorator'). variant: 'naive', 'pythonic', 'real_world', - or 'all' to include example source code.""" - try: - pattern = get_catalog().get(pattern_id) - except KeyError: - known = ", ".join(get_catalog().ids()) - raise ValueError(f"unknown pattern {pattern_id!r}; known ids: {known}") from None - return _detail(pattern, variant) +def get_pattern(pattern_id: str) -> dict[str, Any]: + """Fetch one pattern's metadata and README prose. pattern_id is + '/' (e.g. 'structural/decorator'). Teaching docs come from + get_pattern_docs; code comes from read_source.""" + return _detail(_get(pattern_id)) @mcp.tool() @@ -102,11 +96,20 @@ def search_patterns(query: str, limit: int = 5) -> list[dict[str, Any]]: return [{**_summary(h.pattern), "score": h.score} for h in get_index().search(query, limit)] +def _get(pattern_id: str) -> Pattern: + try: + return get_catalog().get(pattern_id) + except KeyError: + known = ", ".join(get_catalog().ids()) + raise ValueError(f"unknown pattern {pattern_id!r}; known ids: {known}") from None + + @mcp.tool() -def run_example(pattern_id: str, variant: str) -> dict[str, Any]: - """Execute one of a pattern's vendored example files ('naive', 'pythonic', - 'real_world') in a sandboxed subprocess and return its real output.""" - result = _run_example(get_catalog(), pattern_id, variant) +def run_example(pattern_id: str, example: str) -> dict[str, Any]: + """Execute one of a pattern's mini-projects (example=) in a sandboxed subprocess and return its real output.""" + _get(pattern_id) # helpful unknown-id error before the sandbox's KeyError + result = _run_example_package(get_catalog(), pattern_id, example) return { "exit_code": result.exit_code, "stdout": result.stdout, @@ -115,11 +118,46 @@ def run_example(pattern_id: str, variant: str) -> dict[str, Any]: } +@mcp.tool() +def get_pattern_docs(pattern_id: str, doc: str) -> str: + """Read one of a pattern's teaching docs: 'fundamentals' (intent, + participants, mechanism, classic-form contrast), 'implementation' (how to + introduce it into a real system), or 'examples' (cited external usages).""" + pattern = _get(pattern_id) + docs = pattern.docs() + if doc not in docs: + raise ValueError(f"doc must be one of {sorted(DOC_NAMES)}; {pattern_id} has {sorted(docs)}") + return docs[doc].read_text(encoding="utf-8") + + +@mcp.tool() +def list_examples(pattern_id: str) -> list[dict[str, Any]]: + """List a pattern's runnable mini-projects (examples/); + run one with run_example(pattern_id, example=).""" + pattern = _get(pattern_id) + return [ + { + "name": name, + "modules": sorted(p.name for p in path.glob("*.py")), + "run": f"run_example(pattern_id={pattern.id!r}, example={name!r})", + } + for name, path in pattern.examples().items() + ] + + +@mcp.tool() +def read_source(pattern_id: str) -> dict[str, str]: + """Read a pattern's own implementation: every file in its pattern/ + package, keyed by filename.""" + pattern = _get(pattern_id) + return {name: path.read_text(encoding="utf-8") for name, path in pattern.sources().items()} + + @mcp.tool() def recommend_pattern(problem_statement: str, limit: int = 3) -> list[dict[str, Any]]: """Describe a design problem in plain words; get ranked candidate patterns, each with its caveats and verdict attached. A 'prefer-alternative' verdict - means the pythonic variant shows what to write instead.""" + means the unit's docs show what to write instead.""" recommendations = [] for hit in get_index().search(problem_statement, limit): p = hit.pattern @@ -132,7 +170,8 @@ def recommend_pattern(problem_statement: str, limit: int = 3) -> list[dict[str, if p.verdict == "prefer-alternative": rec["note"] = ( f"The guide's honest answer is usually not {p.name}: " - f"see this unit's pythonic.py for what to write instead." + f"read get_pattern_docs({p.id!r}, 'fundamentals') and " + f"read_source({p.id!r}) for what to write instead." ) recommendations.append(rec) return recommendations @@ -140,34 +179,37 @@ def recommend_pattern(problem_statement: str, limit: int = 3) -> list[dict[str, @mcp.resource("catalog://index") def catalog_index() -> str: - """The whole catalog as JSON: every pattern's metadata and variants.""" + """The whole catalog as JSON: every pattern's metadata, docs, and examples.""" return get_catalog().to_json() @mcp.resource("pattern://{group}/{slug}") def pattern_doc(group: str, slug: str) -> str: """One pattern's README prose.""" - return get_catalog().get(f"{group}/{slug}").prose + return _get(f"{group}/{slug}").prose -@mcp.resource("pattern://{group}/{slug}/{variant}") -def pattern_source(group: str, slug: str, variant: str) -> str: - """One pattern's example source (naive | pythonic | real_world).""" - pattern = get_catalog().get(f"{group}/{slug}") - variants = pattern.variants() - if variant not in variants: - raise KeyError(f"{pattern.id} has no variant {variant!r}") - return variants[variant].read_text() +@mcp.resource("pattern://{group}/{slug}/docs/{doc}") +def pattern_docs_resource(group: str, slug: str, doc: str) -> str: + """One pattern's teaching doc (fundamentals | implementation | examples).""" + pattern = _get(f"{group}/{slug}") + docs = pattern.docs() + if doc not in docs: + raise ResourceError(f"{pattern.id} has no doc {doc!r} (has: {sorted(docs)})") + return docs[doc].read_text(encoding="utf-8") @mcp.prompt() def refactor_toward(pattern_id: str, code: str) -> str: """Ask for a refactor of the given code toward one catalog pattern.""" - pattern = get_catalog().get(pattern_id) + pattern = _get(pattern_id) caveats = "\n".join(f"- {c}" for c in pattern.caveats) + reference = ( + f"read_source({pattern.id!r}) and get_pattern_docs({pattern.id!r}, 'implementation')" + ) return ( f"Refactor the following code toward the {pattern.name} pattern " - f"({pattern.id}), as done in this catalog's pythonic variant.\n" + f"({pattern.id}), as shown by {reference}.\n" f"Verdict for this pattern: {pattern.verdict}. Honor these caveats:\n" f"{caveats}\n\nCode:\n```python\n{code}\n```" ) @@ -176,10 +218,13 @@ def refactor_toward(pattern_id: str, code: str) -> str: @mcp.prompt() def explain_pattern(pattern_id: str, audience: str = "an intermediate Python developer") -> str: """Ask for an explanation of one pattern, tuned to an audience.""" - pattern = get_catalog().get(pattern_id) + pattern = _get(pattern_id) + contrast = ( + f"the classic-form vs Python contrast in get_pattern_docs({pattern.id!r}, 'fundamentals')" + ) return ( f"Explain the {pattern.name} pattern to {audience}. Problem it solves: " - f"{pattern.problem} Use the catalog's naive-vs-pythonic contrast, state " + f"{pattern.problem} Use {contrast}, state " f"the verdict ({pattern.verdict}) plainly, and show where the stdlib " f"already uses it ({', '.join(pattern.stdlib_sightings)})." ) @@ -193,8 +238,8 @@ def choose_pattern(problem: str) -> str: "Using the python-design-patterns catalog (search_patterns / " "recommend_pattern), name the best-fitting pattern or say plainly that " "no pattern is needed. If the top candidate's verdict is " - "'prefer-alternative', recommend the alternative its pythonic variant " - "shows instead." + "'prefer-alternative', recommend the alternative the unit itself " + "documents instead." ) diff --git a/src/design_patterns/readme_table.py b/src/design_patterns/readme_table.py index d95a43a..90884c1 100644 --- a/src/design_patterns/readme_table.py +++ b/src/design_patterns/readme_table.py @@ -33,17 +33,14 @@ def render_table() -> str: catalog = load_catalog() - lines: list[str] = [] + lines = ["\n| Pattern | Group | Verdict | Problem it solves |", "|---|---|---|---|"] for group in _GROUP_ORDER: members = [p for p in catalog.patterns if p.group == group] - if not members: - continue - lines.append(f"\n### {_GROUP_TITLES[group]}\n") - lines.append("| Pattern | Verdict | Problem it solves |") - lines.append("|---|---|---|") for p in sorted(members, key=lambda p: p.slug): link = f"[{p.name}](patterns/{p.id}/)" - lines.append(f"| {link} | {_VERDICT_BADGES[p.verdict]} | {p.problem} |") + lines.append( + f"| {link} | {_GROUP_TITLES[group]} | {_VERDICT_BADGES[p.verdict]} | {p.problem} |" + ) return "\n".join(lines) + "\n" diff --git a/src/design_patterns_mcp/__init__.py b/src/design_patterns_mcp/__init__.py deleted file mode 100644 index 07b6c88..0000000 --- a/src/design_patterns_mcp/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""MCP server exposing the pattern catalog to agents.""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..9fffa75 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,50 @@ +"""Shared fixtures: a synthetic pattern unit for engine tests. + +Engine tests use a self-contained synthetic unit instead of assuming any real +unit's API, so unit-level refactors never ripple into the engine suite. +""" + +from pathlib import Path + +import pytest + +from design_patterns.catalog import Catalog, load_catalog + + +def write_module_unit(root: Path) -> Path: + """Build ``/creational/thing`` as a complete, runnable module-shape unit.""" + unit = root / "creational" / "thing" + (unit / "pattern").mkdir(parents=True) + # No empty __init__.py anywhere — namespace packages (PEP 420) carry the + # structure. The two API files model the house style: bare as-alias re-exports. + (unit / "__init__.py").write_text("from .pattern.thing import build as build\n") + (unit / "pattern" / "__init__.py").write_text("from .thing import build as build\n") + (unit / "README.md").write_text( + "---\n" + "id: creational/thing\nname: Thing\nguide_url: null\n" + 'problem: "Build a thing."\nsymptoms: ["thing needed"]\n' + "verdict: prefer-alternative\ncaveats: []\n" + "---\n\n# Thing\n" + ) + (unit / "pattern" / "thing.py").write_text("def build() -> str:\n return 'built a thing'\n") + docs = unit / "docs" + docs.mkdir() + for name in ("fundamentals", "implementation", "examples"): + (docs / f"{name}.md").write_text(f"# {name} of Thing\n") + project = unit / "examples" / "demo" + project.mkdir(parents=True) + (project / "main.py").write_text( + "from patterns.creational.thing.pattern.thing import build\n\nprint(build())\n" + ) + tests = unit / "tests" + tests.mkdir() + (tests / "test_thing.py").write_text("def test_ok() -> None:\n assert True\n") + return unit + + +@pytest.fixture +def module_catalog(tmp_path: Path) -> Catalog: + """A catalog whose ``patterns/`` root holds one synthetic module-shape unit.""" + root = tmp_path / "patterns" + write_module_unit(root) + return load_catalog(root) diff --git a/tests/test_catalog.py b/tests/test_catalog.py index cfc3aef..e86281d 100644 --- a/tests/test_catalog.py +++ b/tests/test_catalog.py @@ -19,9 +19,45 @@ def test_loads_all_units(self) -> None: assert len(catalog.patterns) == 32 assert "structural/decorator" in catalog.ids() - def test_every_unit_ships_all_three_variants(self) -> None: + def test_every_module_example_builds_on_its_own_pattern_package(self) -> None: + # The mini-projects exist to show the pattern in practice: each one + # must import its unit's pattern/ package, not reimplement the idea. + # AST walk, not text search — a docstring mentioning the path is not + # an import. + import ast + + for pattern in load_catalog().patterns: + group, slug = pattern.id.split("/") + absolute = f"patterns.{group}.{slug}.pattern" + for name, path in pattern.examples().items(): + imports_pattern = False + for source_file in sorted(path.rglob("*.py")): + tree = ast.parse(source_file.read_text(), filename=str(source_file)) + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom): + module = node.module or "" + if module == absolute or module.startswith(f"{absolute}."): + imports_pattern = True + # Relative: from ..pattern import X / from ...pattern.chain import X + if node.level > 0 and ( + module == "pattern" or module.startswith("pattern.") + ): + imports_pattern = True + elif isinstance(node, ast.Import): + for alias in node.names: + if alias.name == absolute or alias.name.startswith(f"{absolute}."): + imports_pattern = True + assert imports_pattern, ( + f"{pattern.id} example {name!r} never imports its own pattern package" + ) + + def test_every_unit_ships_the_full_module_shape(self) -> None: for pattern in load_catalog().patterns: - assert sorted(pattern.variants()) == ["naive", "pythonic", "real_world"], pattern.id + assert sorted(pattern.docs()) == ["examples", "fundamentals", "implementation"], ( + pattern.id + ) + assert pattern.examples(), pattern.id + assert pattern.sources(), pattern.id def test_verdicts_are_from_the_vocabulary(self) -> None: for pattern in load_catalog().patterns: @@ -39,17 +75,11 @@ def test_get_unknown_id_raises(self) -> None: def test_index_json_round_trips(self) -> None: entries = json.loads(load_catalog().to_json()) assert len(entries) == 32 - assert all({"id", "name", "problem", "verdict", "variants"} <= e.keys() for e in entries) + keys = {"id", "name", "problem", "verdict", "docs", "examples"} + assert all(keys <= e.keys() for e in entries) assert not any("prose" in e or "path" in e for e in entries) -def _write_unit(root: Path, group: str, slug: str, frontmatter: str, body: str = "# x") -> None: - unit = root / group / slug - unit.mkdir(parents=True) - (unit / "README.md").write_text(f"---\n{frontmatter}\n---\n\n{body}\n") - (unit / "pythonic.py").write_text("def main() -> None: ...\n") - - GOOD = """\ id: creational/thing name: Thing @@ -62,22 +92,24 @@ def _write_unit(root: Path, group: str, slug: str, frontmatter: str, body: str = class TestValidation: def test_minimal_valid_unit_loads(self, tmp_path: Path) -> None: - _write_unit(tmp_path, "creational", "thing", GOOD) + _write_module_unit(tmp_path, "creational", "thing", GOOD) catalog = load_catalog(tmp_path) assert catalog.get("creational/thing").verdict == "pythonic" def test_missing_key_fails(self, tmp_path: Path) -> None: - _write_unit(tmp_path, "creational", "thing", GOOD.replace('problem: "Build a thing."', "")) + _write_module_unit( + tmp_path, "creational", "thing", GOOD.replace('problem: "Build a thing."', "") + ) with pytest.raises(CatalogError, match="missing frontmatter keys"): load_catalog(tmp_path) def test_id_directory_mismatch_fails(self, tmp_path: Path) -> None: - _write_unit(tmp_path, "creational", "other", GOOD) + _write_module_unit(tmp_path, "creational", "other", GOOD) with pytest.raises(CatalogError, match="!= directory"): load_catalog(tmp_path) def test_unknown_verdict_fails(self, tmp_path: Path) -> None: - _write_unit(tmp_path, "creational", "thing", GOOD.replace("pythonic", "amazing")) + _write_module_unit(tmp_path, "creational", "thing", GOOD.replace("pythonic", "amazing")) with pytest.raises(CatalogError, match="verdict"): load_catalog(tmp_path) @@ -88,16 +120,111 @@ def test_no_frontmatter_fails(self, tmp_path: Path) -> None: with pytest.raises(CatalogError, match="frontmatter"): load_catalog(tmp_path) - def test_unit_without_examples_fails(self, tmp_path: Path) -> None: - _write_unit(tmp_path, "creational", "thing", GOOD) - (tmp_path / "creational" / "thing" / "pythonic.py").unlink() - with pytest.raises(CatalogError, match="ships no"): - load_catalog(tmp_path) - def test_empty_tree_fails(self, tmp_path: Path) -> None: with pytest.raises(CatalogError, match="no pattern units"): load_catalog(tmp_path) +def _write_module_unit(root: Path, group: str, slug: str, frontmatter: str) -> Path: + """A minimal valid module-shape unit: pattern/ + docs/ + examples/ + tests/.""" + unit = root / group / slug + (unit / "pattern").mkdir(parents=True) + (unit / "README.md").write_text(f"---\n{frontmatter}\n---\n\n# x\n") + # Only load-bearing __init__.py exist (house rule): the two API files. + (unit / "__init__.py").write_text("from .pattern.thing import build as build\n") + (unit / "pattern" / "__init__.py").write_text("from .thing import build as build\n") + (unit / "pattern" / "thing.py").write_text("def build() -> str:\n return 'thing'\n") + docs = unit / "docs" + docs.mkdir() + for name in ("fundamentals", "implementation", "examples"): + (docs / f"{name}.md").write_text(f"# {name}\n") + project = unit / "examples" / "demo" + project.mkdir(parents=True) + (project / "main.py").write_text("print('demo ran')\n") + tests = unit / "tests" + tests.mkdir() + (tests / "test_thing.py").write_text("def test_ok() -> None:\n assert True\n") + return unit + + +class TestModuleShapeValidation: + def test_valid_module_unit_loads_with_shape_fields(self, tmp_path: Path) -> None: + _write_module_unit(tmp_path, "creational", "thing", GOOD) + pattern = load_catalog(tmp_path).get("creational/thing") + assert sorted(pattern.docs()) == ["examples", "fundamentals", "implementation"] + assert sorted(pattern.examples()) == ["demo"] + assert sorted(pattern.sources()) == ["__init__.py", "thing.py"] + + def test_missing_doc_fails(self, tmp_path: Path) -> None: + unit = _write_module_unit(tmp_path, "creational", "thing", GOOD) + (unit / "docs" / "implementation.md").unlink() + with pytest.raises(CatalogError, match=r"missing docs.*implementation"): + load_catalog(tmp_path) + + def test_no_example_fails(self, tmp_path: Path) -> None: + unit = _write_module_unit(tmp_path, "creational", "thing", GOOD) + (unit / "examples" / "demo" / "main.py").unlink() + with pytest.raises(CatalogError, match="no runnable examples"): + load_catalog(tmp_path) + + def test_empty_init_in_example_fails(self, tmp_path: Path) -> None: + unit = _write_module_unit(tmp_path, "creational", "thing", GOOD) + (unit / "examples" / "demo" / "__init__.py").write_text("") + with pytest.raises(CatalogError, match=r"delete empty __init__\.py"): + load_catalog(tmp_path) + + def test_dunder_main_is_banned(self, tmp_path: Path) -> None: + unit = _write_module_unit(tmp_path, "creational", "thing", GOOD) + (unit / "examples" / "demo" / "__main__.py").write_text("print('old style')\n") + with pytest.raises(CatalogError, match=r"__main__\.py is banned"): + load_catalog(tmp_path) + + def test_empty_tests_fails(self, tmp_path: Path) -> None: + unit = _write_module_unit(tmp_path, "creational", "thing", GOOD) + (unit / "tests" / "test_thing.py").unlink() + with pytest.raises(CatalogError, match="no tests"): + load_catalog(tmp_path) + + def test_pattern_package_needs_init(self, tmp_path: Path) -> None: + unit = _write_module_unit(tmp_path, "creational", "thing", GOOD) + (unit / "pattern" / "__init__.py").unlink() + with pytest.raises(CatalogError, match=r"no __init__\.py"): + load_catalog(tmp_path) + + def test_stale_legacy_variant_files_fail(self, tmp_path: Path) -> None: + unit = _write_module_unit(tmp_path, "creational", "thing", GOOD) + (unit / "pythonic.py").write_text("def main() -> None: ...\n") + with pytest.raises(CatalogError, match="legacy variant files"): + load_catalog(tmp_path) + + def test_partial_unit_fails_loudly(self, tmp_path: Path) -> None: + # A unit with only some of the template present must fail validation. + unit = tmp_path / "creational" / "thing" + (unit / "docs").mkdir(parents=True) + (unit / "README.md").write_text(f"---\n{GOOD}\n---\n\n# x\n") + with pytest.raises(CatalogError, match="module unit missing docs"): + load_catalog(tmp_path) + + def test_empty_init_in_examples_dir_fails(self, tmp_path: Path) -> None: + unit = _write_module_unit(tmp_path, "creational", "thing", GOOD) + (unit / "examples" / "__init__.py").write_text("") + with pytest.raises(CatalogError, match=r"delete empty __init__\.py"): + load_catalog(tmp_path) + + def test_loadbearing_example_init_is_allowed(self, tmp_path: Path) -> None: + # A NON-empty example __init__.py (e.g. plugin self-registration) is fine. + unit = _write_module_unit(tmp_path, "creational", "thing", GOOD) + (unit / "examples" / "demo" / "__init__.py").write_text( + "# load-bearing: demo of import-time registration\n" + ) + assert load_catalog(tmp_path).get("creational/thing").examples() + + def test_index_json_carries_docs_and_examples(self, tmp_path: Path) -> None: + _write_module_unit(tmp_path, "creational", "thing", GOOD) + entries = json.loads(load_catalog(tmp_path).to_json()) + assert entries[0]["examples"] == ["demo"] + assert entries[0]["docs"] == ["examples", "fundamentals", "implementation"] + + def test_find_patterns_root_from_repo() -> None: assert find_patterns_root().name == "patterns" diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index d0db7a5..28d4504 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -2,10 +2,13 @@ import json +import pytest from mcp import Client from mcp.types import TextResourceContents -from design_patterns_mcp.server import mcp +import design_patterns.mcp.server as server_module +from design_patterns.catalog import Catalog +from design_patterns.mcp.server import mcp class TestTools: @@ -24,15 +27,15 @@ async def test_list_patterns_filters(self) -> None: ids = [p["id"] for p in result.structured_content["result"]] assert len(ids) == 5 and all(i.startswith("creational/") for i in ids) - async def test_get_pattern_with_source(self) -> None: + async def test_get_pattern_lists_docs_and_examples(self) -> None: async with Client(mcp) as client: - result = await client.call_tool( - "get_pattern", {"pattern_id": "structural/decorator", "variant": "pythonic"} - ) + result = await client.call_tool("get_pattern", {"pattern_id": "structural/decorator"}) assert result.structured_content is not None detail = result.structured_content assert detail["verdict"] == "pythonic" - assert "functools" in detail["source"]["pythonic"] + assert detail["docs"] == ["examples", "fundamentals", "implementation"] + assert "resilient_client" in detail["examples"] + assert detail["prose"] async def test_get_pattern_unknown_id_names_the_catalog(self) -> None: async with Client(mcp) as client: @@ -49,16 +52,22 @@ async def test_search_finds_singleton_from_symptoms(self) -> None: assert "creational/singleton" in ids async def test_run_example_returns_real_output(self) -> None: + # The pilot unit is module-shape for good — a stable target while the + # remaining units migrate group by group. async with Client(mcp) as client: result = await client.call_tool( - "run_example", {"pattern_id": "creational/singleton", "variant": "pythonic"} + "run_example", + { + "pattern_id": "behavioral/chain_of_responsibility", + "example": "ticket_escalation", + }, ) assert result.structured_content is not None run = result.structured_content assert run["exit_code"] == 0 and not run["timed_out"] - assert "module global is shared" in run["stdout"] + assert "helpdesk" in run["stdout"] - async def test_recommend_attaches_caveats_and_alternative_note(self) -> None: + async def test_recommend_attaches_caveats(self) -> None: async with Client(mcp) as client: result = await client.call_tool( "recommend_pattern", @@ -68,10 +77,90 @@ async def test_recommend_attaches_caveats_and_alternative_note(self) -> None: recs = result.structured_content["result"] singleton = next(r for r in recs if r["id"] == "creational/singleton") assert singleton["verdict"] == "prefer-alternative" - assert "pythonic.py" in singleton["note"] assert singleton["caveats"] +class TestRecommendNote: + """The prefer-alternative note is pinned via the synthetic unit.""" + + async def test_note_names_the_docs_and_source_tools( + self, module_catalog: Catalog, monkeypatch: pytest.MonkeyPatch + ) -> None: + from design_patterns.mcp.search import SearchIndex + + monkeypatch.setattr(server_module, "get_catalog", lambda: module_catalog) + monkeypatch.setattr(server_module, "get_index", lambda: SearchIndex(module_catalog)) + async with Client(mcp) as client: + result = await client.call_tool( + "recommend_pattern", {"problem_statement": "thing needed"} + ) + assert result.structured_content is not None + rec = result.structured_content["result"][0] + assert rec["id"] == "creational/thing" + assert "get_pattern_docs" in rec["note"] and "read_source" in rec["note"] + + +class TestModuleShapeTools: + """The three access levels, against the synthetic unit.""" + + @pytest.fixture(autouse=True) + def _use_module_catalog(self, module_catalog: Catalog, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(server_module, "get_catalog", lambda: module_catalog) + + async def test_get_pattern_docs(self) -> None: + async with Client(mcp) as client: + result = await client.call_tool( + "get_pattern_docs", {"pattern_id": "creational/thing", "doc": "fundamentals"} + ) + assert not result.is_error + assert "fundamentals of Thing" in str(result.content[0]) + + async def test_get_pattern_docs_rejects_unknown_doc(self) -> None: + async with Client(mcp) as client: + result = await client.call_tool( + "get_pattern_docs", {"pattern_id": "creational/thing", "doc": "naive"} + ) + assert result.is_error + + async def test_list_examples_names_the_run_call(self) -> None: + async with Client(mcp) as client: + result = await client.call_tool("list_examples", {"pattern_id": "creational/thing"}) + assert result.structured_content is not None + examples = result.structured_content["result"] + assert [e["name"] for e in examples] == ["demo"] + assert "run_example" in examples[0]["run"] + + async def test_run_example_by_package(self) -> None: + async with Client(mcp) as client: + result = await client.call_tool( + "run_example", {"pattern_id": "creational/thing", "example": "demo"} + ) + assert result.structured_content is not None + run = result.structured_content + assert run["exit_code"] == 0 and "built a thing" in run["stdout"] + + async def test_read_source_returns_pattern_package(self) -> None: + async with Client(mcp) as client: + result = await client.call_tool("read_source", {"pattern_id": "creational/thing"}) + assert result.structured_content is not None + sources = result.structured_content + assert "built a thing" in sources["thing.py"] + + async def test_docs_resource(self) -> None: + async with Client(mcp) as client: + doc = await client.read_resource("pattern://creational/thing/docs/implementation") + first = doc.contents[0] + assert isinstance(first, TextResourceContents) + assert "implementation of Thing" in first.text + + async def test_unknown_doc_resource_error_text_reaches_client(self) -> None: + # ResourceError (not ValueError) is required for the hint to survive + # the SDK's template wrapper — this pins that the text gets through. + async with Client(mcp) as client: + with pytest.raises(Exception, match="has no doc"): + await client.read_resource("pattern://creational/thing/docs/naive") + + class TestResources: async def test_catalog_index_resource(self) -> None: async with Client(mcp) as client: @@ -80,17 +169,17 @@ 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") - first_src = src.contents[0] - assert isinstance(first_src, TextResourceContents) - assert "__next__" in first_src.text + 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 class TestPrompts: diff --git a/tests/test_sandbox.py b/tests/test_sandbox.py index 57b99e2..6ec4502 100644 --- a/tests/test_sandbox.py +++ b/tests/test_sandbox.py @@ -1,48 +1,85 @@ -"""Sandbox contract: only catalog files run; timeouts and bad ids refuse.""" +"""Sandbox contract: only catalog example packages run; bad ids and names refuse.""" + +from pathlib import Path import pytest +from tests.conftest import write_module_unit -from design_patterns.catalog import load_catalog -from design_patterns_mcp.sandbox import run_example +from design_patterns.catalog import Catalog, load_catalog +from design_patterns.mcp.sandbox import run_example_package CATALOG = load_catalog() -class TestSandbox: - def test_runs_a_real_example(self) -> None: - result = run_example(CATALOG, "structural/flyweight", "pythonic") - assert result.exit_code == 0 - assert "shares" in result.stdout +class TestPackageSandbox: + def test_runs_a_module_example_that_imports_the_pattern(self, module_catalog: Catalog) -> None: + result = run_example_package(module_catalog, "creational/thing", "demo") + assert result.exit_code == 0, result.stderr + assert "built a thing" in result.stdout assert not result.timed_out - def test_unknown_pattern_id_is_refused(self) -> None: - with pytest.raises(KeyError): - run_example(CATALOG, "../../etc/passwd", "naive") + def test_unknown_example_is_refused(self, module_catalog: Catalog) -> None: + with pytest.raises(KeyError, match="no example"): + run_example_package(module_catalog, "creational/thing", "nope") - def test_unknown_variant_is_refused(self) -> None: - with pytest.raises(KeyError, match="no variant"): - run_example(CATALOG, "structural/flyweight", "__init__") + def test_traversal_shaped_example_is_refused(self, module_catalog: Catalog) -> None: + with pytest.raises(KeyError): + run_example_package(module_catalog, "creational/thing", "../../../tmp/evil") - def test_traversal_shaped_variant_is_refused(self) -> None: + def test_unknown_pattern_id_is_refused(self, module_catalog: Catalog) -> None: with pytest.raises(KeyError): - run_example(CATALOG, "structural/flyweight", "../../../tmp/evil") + run_example_package(module_catalog, "../../etc/passwd", "demo") + + def test_failing_example_reports_not_raises(self, tmp_path: Path) -> None: + # A crashing demo must come back as a RunResult, not an exception. + root = tmp_path / "patterns" + unit = write_module_unit(root) + (unit / "examples" / "demo" / "main.py").write_text( + "import sys\n\nprint('about to fail')\nsys.exit(3)\n" + ) + result = run_example_package(load_catalog(root), "creational/thing", "demo") + assert result.exit_code == 3 + assert "about to fail" in result.stdout + assert not result.timed_out + + def test_runs_the_real_pilot_unit(self) -> None: + # The migrated unit itself, through the python -I -m path CI must cover. + result = run_example_package( + CATALOG, "behavioral/chain_of_responsibility", "ticket_escalation" + ) + assert result.exit_code == 0, result.stderr + assert "triage" in result.stdout + assert not result.timed_out + + +def _every_example() -> list[tuple[str, str]]: + return [ + (pattern.id, example) + for pattern in CATALOG.patterns + for example in sorted(pattern.examples()) + ] - def test_failing_example_reports_not_raises(self) -> None: - # every current example exits 0; simulate by checking the API shape - result = run_example(CATALOG, "behavioral/command", "real_world") - assert isinstance(result.exit_code, int) - assert isinstance(result.stderr, str) + +class TestEveryExampleRuns: + """Demo rot check: every unit's every example runs in the sandbox.""" + + @pytest.mark.parametrize(("pattern_id", "example"), _every_example()) + def test_example_exits_cleanly(self, pattern_id: str, example: str) -> None: + result = run_example_package(CATALOG, pattern_id, example) + assert result.exit_code == 0, f"{pattern_id}/{example}: {result.stderr}" + assert not result.timed_out + assert result.stdout.strip(), f"{pattern_id}/{example} printed nothing" class TestSearchIndex: def test_symptom_search_hits_the_right_unit(self) -> None: - from design_patterns_mcp.search import SearchIndex + from design_patterns.mcp.search import SearchIndex index = SearchIndex(CATALOG) top = index.search("undo redo history snapshot", limit=3) assert top and top[0].pattern.id in {"behavioral/memento", "behavioral/command"} def test_no_match_returns_empty(self) -> None: - from design_patterns_mcp.search import SearchIndex + from design_patterns.mcp.search import SearchIndex assert SearchIndex(CATALOG).search("zzzqqqxxx") == [] diff --git a/tests/test_scaffold.py b/tests/test_scaffold.py index a5943f6..a89b5b2 100644 --- a/tests/test_scaffold.py +++ b/tests/test_scaffold.py @@ -1,7 +1,7 @@ -"""Smoke test: the package imports and reports a version.""" +"""Smoke test: the package is installed and reports a version.""" -import design_patterns +from importlib.metadata import version def test_version() -> None: - assert design_patterns.__version__ + assert version("python-design-patterns")
westwest