diff --git a/.gitignore b/.gitignore
index 4230208..2361930 100644
--- a/.gitignore
+++ b/.gitignore
@@ -24,3 +24,6 @@ uv.lock
# local working files (plans, research briefs)
.cache/
+
+# agent worktrees
+.claude/worktrees/
diff --git a/patterns/creational/abstract_factory/README.md b/patterns/creational/abstract_factory/README.md
index 1c02510..8ef89a2 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
+```
diff --git a/patterns/creational/abstract_factory/__init__.py b/patterns/creational/abstract_factory/__init__.py
index afc6b8d..1506a67 100644
--- a/patterns/creational/abstract_factory/__init__.py
+++ b/patterns/creational/abstract_factory/__init__.py
@@ -1 +1,12 @@
-"""Abstract Factory: build families of objects. Verdict: pass callables."""
+"""Abstract Factory — public API.
+
+>>> from patterns.creational.abstract_factory import DocumentFamily
+"""
+
+from patterns.creational.abstract_factory.pattern import (
+ HTML,
+ MARKDOWN,
+ DocumentFamily,
+)
+
+__all__ = ["HTML", "MARKDOWN", "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..2f98513
--- /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
+```
diff --git a/patterns/creational/abstract_factory/examples/__init__.py b/patterns/creational/abstract_factory/examples/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/patterns/creational/abstract_factory/examples/report_renderer/__init__.py b/patterns/creational/abstract_factory/examples/report_renderer/__init__.py
new file mode 100644
index 0000000..da508e1
--- /dev/null
+++ b/patterns/creational/abstract_factory/examples/report_renderer/__init__.py
@@ -0,0 +1,13 @@
+"""Quarterly-report rendering built on the Abstract Factory.
+
+Run it: ``uv run python -m patterns.creational.abstract_factory.examples.report_renderer``
+"""
+
+from patterns.creational.abstract_factory.examples.report_renderer.renderer import render
+from patterns.creational.abstract_factory.examples.report_renderer.report import (
+ Report,
+ Section,
+ Table,
+)
+
+__all__ = ["Report", "Section", "Table", "render"]
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..7391668
--- /dev/null
+++ b/patterns/creational/abstract_factory/pattern/__init__.py
@@ -0,0 +1,9 @@
+"""The importable Abstract Factory building block."""
+
+from patterns.creational.abstract_factory.pattern.family import (
+ HTML,
+ MARKDOWN,
+ DocumentFamily,
+)
+
+__all__ = ["HTML", "MARKDOWN", "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""
+
+
+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""
-
-
-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/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 "| west | " 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..1d24f72
--- /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 import (
+ Report,
+ Section,
+ Table,
+ render,
+)
+from patterns.creational.abstract_factory.examples.report_renderer.__main__ import Q3
+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 "west | " 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..04e60bb 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
+```
diff --git a/patterns/creational/builder/__init__.py b/patterns/creational/builder/__init__.py
index 8681512..36c1d75 100644
--- a/patterns/creational/builder/__init__.py
+++ b/patterns/creational/builder/__init__.py
@@ -1 +1,8 @@
-"""Builder: staged assembly of complex objects. Verdict: kwargs first."""
+"""Builder — public API.
+
+>>> from patterns.creational.builder import SelectBuilder
+"""
+
+from patterns.creational.builder.pattern import Query, SelectBuilder
+
+__all__ = ["Query", "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..46c132d
--- /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
+```
diff --git a/patterns/creational/builder/examples/__init__.py b/patterns/creational/builder/examples/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/patterns/creational/builder/examples/sql_select_builder/__init__.py b/patterns/creational/builder/examples/sql_select_builder/__init__.py
new file mode 100644
index 0000000..78c8e1e
--- /dev/null
+++ b/patterns/creational/builder/examples/sql_select_builder/__init__.py
@@ -0,0 +1,13 @@
+"""Order analytics over sqlite, with every query built through SelectBuilder.
+
+Run it: ``uv run python -m patterns.creational.builder.examples.sql_select_builder``
+"""
+
+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,
+)
+
+__all__ = ["big_orders", "orders_in_region", "seed_orders", "top_orders"]
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/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/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..184bcec
--- /dev/null
+++ b/patterns/creational/builder/pattern/__init__.py
@@ -0,0 +1,5 @@
+"""The importable Builder building block."""
+
+from patterns.creational.builder.pattern.query import Query, SelectBuilder
+
+__all__ = ["Query", "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/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..e4681d5
--- /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 import (
+ big_orders,
+ orders_in_region,
+ seed_orders,
+ 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..3946541 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
+```
diff --git a/patterns/creational/factory_method/__init__.py b/patterns/creational/factory_method/__init__.py
index 089799e..48eb12f 100644
--- a/patterns/creational/factory_method/__init__.py
+++ b/patterns/creational/factory_method/__init__.py
@@ -1 +1,11 @@
-"""Factory Method: defer which helper gets built. Verdict: inject, or class attribute."""
+"""Factory Method — public API.
+
+>>> from patterns.creational.factory_method import factory_slot
+"""
+
+from patterns.creational.factory_method.pattern import Factory, factory_slot
+
+__all__ = [
+ "Factory",
+ "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..b3f3877
--- /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
+```
diff --git a/patterns/creational/factory_method/examples/__init__.py b/patterns/creational/factory_method/examples/__init__.py
new file mode 100644
index 0000000..16e951e
--- /dev/null
+++ b/patterns/creational/factory_method/examples/__init__.py
@@ -0,0 +1 @@
+"""Mini-projects demonstrating the Factory Method's Python forms in practice."""
diff --git a/patterns/creational/factory_method/examples/feed_client/__init__.py b/patterns/creational/factory_method/examples/feed_client/__init__.py
new file mode 100644
index 0000000..706f897
--- /dev/null
+++ b/patterns/creational/factory_method/examples/feed_client/__init__.py
@@ -0,0 +1,26 @@
+"""A feed-client framework built on the unit's ``pattern`` package.
+
+Run it: ``uv run python -m patterns.creational.factory_method.examples.feed_client``
+"""
+
+from patterns.creational.factory_method.examples.feed_client.client import (
+ DigestClient,
+ DigestResponse,
+ FeedClient,
+ FeedResponse,
+ StrictClient,
+ Transport,
+ parse_strictly,
+)
+from patterns.creational.factory_method.examples.feed_client.models import Article
+
+__all__ = [
+ "Article",
+ "DigestClient",
+ "DigestResponse",
+ "FeedClient",
+ "FeedResponse",
+ "StrictClient",
+ "Transport",
+ "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/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/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..0c02cec
--- /dev/null
+++ b/patterns/creational/factory_method/pattern/__init__.py
@@ -0,0 +1,8 @@
+"""The Factory Method pattern's Python forms, importable as library code."""
+
+from patterns.creational.factory_method.pattern.dodges import Factory, factory_slot
+
+__all__ = [
+ "Factory",
+ "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/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..2504d09
--- /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 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..2024366 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
+```
diff --git a/patterns/creational/prototype/__init__.py b/patterns/creational/prototype/__init__.py
index 4c2c58a..b67e88e 100644
--- a/patterns/creational/prototype/__init__.py
+++ b/patterns/creational/prototype/__init__.py
@@ -1 +1,8 @@
-"""Prototype: new instances by cloning an exemplar. Verdict: store callables instead."""
+"""Prototype — public API.
+
+>>> from patterns.creational.prototype import TemplateRegistry
+"""
+
+from patterns.creational.prototype.pattern import Template, TemplateRegistry
+
+__all__ = ["Template", "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..b97bdd0
--- /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
+```
diff --git a/patterns/creational/prototype/examples/__init__.py b/patterns/creational/prototype/examples/__init__.py
new file mode 100644
index 0000000..f1651ca
--- /dev/null
+++ b/patterns/creational/prototype/examples/__init__.py
@@ -0,0 +1 @@
+"""Mini-projects demonstrating the Prototype's Python form in practice."""
diff --git a/patterns/creational/prototype/examples/report_job_templates/__init__.py b/patterns/creational/prototype/examples/report_job_templates/__init__.py
new file mode 100644
index 0000000..f4a7a68
--- /dev/null
+++ b/patterns/creational/prototype/examples/report_job_templates/__init__.py
@@ -0,0 +1,12 @@
+"""Report jobs stamped from templates, built on the Prototype's Python form.
+
+Run it: ``uv run python -m patterns.creational.prototype.examples.report_job_templates``
+"""
+
+from patterns.creational.prototype.examples.report_job_templates.models import ReportJob
+from patterns.creational.prototype.examples.report_job_templates.scheduler import (
+ Scheduler,
+ build_template_menu,
+)
+
+__all__ = ["ReportJob", "Scheduler", "build_template_menu"]
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..e358512
--- /dev/null
+++ b/patterns/creational/prototype/pattern/__init__.py
@@ -0,0 +1,5 @@
+"""The Prototype pattern's Python form, importable as library code."""
+
+from patterns.creational.prototype.pattern.templates import Template, TemplateRegistry
+
+__all__ = ["Template", "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/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..ba40fcf
--- /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 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..1c216c4 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
+```
diff --git a/patterns/creational/singleton/__init__.py b/patterns/creational/singleton/__init__.py
index 3a3757f..1b4287b 100644
--- a/patterns/creational/singleton/__init__.py
+++ b/patterns/creational/singleton/__init__.py
@@ -1 +1,8 @@
-"""Singleton: one instance, program-wide access. Verdict: prefer the Global Object pattern."""
+"""Singleton — public API (the alternative, really).
+
+>>> from patterns.creational.singleton import Shared
+"""
+
+from patterns.creational.singleton.pattern import Shared
+
+__all__ = ["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..92e2ddf
--- /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
+```
diff --git a/patterns/creational/singleton/examples/__init__.py b/patterns/creational/singleton/examples/__init__.py
new file mode 100644
index 0000000..d25c438
--- /dev/null
+++ b/patterns/creational/singleton/examples/__init__.py
@@ -0,0 +1 @@
+"""Mini-projects demonstrating the Singleton's Python replacement in practice."""
diff --git a/patterns/creational/singleton/examples/app_config/__init__.py b/patterns/creational/singleton/examples/app_config/__init__.py
new file mode 100644
index 0000000..98736de
--- /dev/null
+++ b/patterns/creational/singleton/examples/app_config/__init__.py
@@ -0,0 +1,13 @@
+"""App configuration behind an accessor, built on the Singleton's replacement.
+
+Run it: ``uv run python -m patterns.creational.singleton.examples.app_config``
+"""
+
+from patterns.creational.singleton.examples.app_config.settings import (
+ Settings,
+ get_settings,
+ load_settings,
+ reset_settings,
+)
+
+__all__ = ["Settings", "get_settings", "load_settings", "reset_settings"]
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..62d5062
--- /dev/null
+++ b/patterns/creational/singleton/pattern/__init__.py
@@ -0,0 +1,5 @@
+"""The Singleton's Python replacement, importable as library code."""
+
+from patterns.creational.singleton.pattern.shared import Shared
+
+__all__ = ["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/test_app_config.py b/patterns/creational/singleton/tests/test_app_config.py
new file mode 100644
index 0000000..c2fac19
--- /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 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/tests/conftest.py b/tests/conftest.py
index 4bcc19c..cabd0df 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -21,7 +21,8 @@ def write_module_unit(root: Path) -> Path:
(unit / "README.md").write_text(
"---\n"
"id: creational/thing\nname: Thing\nguide_url: null\n"
- 'problem: "Build a thing."\nsymptoms: ["thing needed"]\nverdict: pythonic\ncaveats: []\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")
@@ -42,9 +43,47 @@ def write_module_unit(root: Path) -> Path:
return unit
+def write_legacy_unit(root: Path) -> Path:
+ """Build ``/creational/oldthing`` as a pre-migration legacy-shape unit."""
+ unit = root / "creational" / "oldthing"
+ unit.mkdir(parents=True)
+ for pkg in (root, root / "creational", unit):
+ init = pkg / "__init__.py"
+ if not init.exists():
+ init.write_text("")
+ (unit / "README.md").write_text(
+ "---\n"
+ "id: creational/oldthing\nname: Oldthing\nguide_url: null\n"
+ 'problem: "Build an old thing."\nsymptoms: ["old thing needed"]\n'
+ "verdict: prefer-alternative\ncaveats: []\n"
+ "---\n\n# Oldthing\n"
+ )
+ for variant in ("naive", "pythonic", "real_world"):
+ (unit / f"{variant}.py").write_text(
+ f'def main() -> None:\n print("{variant} oldthing runs")\n\n\n'
+ 'if __name__ == "__main__":\n main()\n'
+ )
+ tests = unit / "tests"
+ tests.mkdir()
+ (tests / "test_oldthing.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)
+
+
+@pytest.fixture
+def legacy_catalog(tmp_path: Path) -> Catalog:
+ """A catalog holding one synthetic legacy-shape unit.
+
+ Real units migrate to the module shape group by group, so tests of the
+ legacy behavior must not depend on any real unit staying legacy.
+ """
+ root = tmp_path / "patterns"
+ write_legacy_unit(root)
+ return load_catalog(root)
diff --git a/tests/test_catalog.py b/tests/test_catalog.py
index 8d5b8f4..3b8e11f 100644
--- a/tests/test_catalog.py
+++ b/tests/test_catalog.py
@@ -27,6 +27,23 @@ def test_catalog_contains_both_shapes_during_migration(self) -> None:
module_ids = {p.id for p in load_catalog().patterns if p.shape == "module"}
assert "behavioral/chain_of_responsibility" in module_ids
+ 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.
+ import re
+
+ for pattern in load_catalog().patterns:
+ if pattern.shape != "module":
+ continue
+ group, slug = pattern.id.split("/")
+ absolute = f"patterns.{group}.{slug}.pattern"
+ relative = re.compile(r"from\s+\.+pattern\b|import\s+\.+pattern\b")
+ for name, path in pattern.examples().items():
+ sources = "\n".join(f.read_text() for f in sorted(path.rglob("*.py")))
+ assert absolute in sources or relative.search(sources), (
+ f"{pattern.id} example {name!r} never imports its own pattern package"
+ )
+
def test_every_unit_ships_its_shape_completely(self) -> None:
for pattern in load_catalog().patterns:
if pattern.shape == "module":
diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py
index 6c00f18..cae1778 100644
--- a/tests/test_mcp_server.py
+++ b/tests/test_mcp_server.py
@@ -27,15 +27,20 @@ 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_with_source(
+ self, legacy_catalog: Catalog, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ # Variant source is a legacy-shape feature; every real unit migrates,
+ # so this runs against the synthetic legacy unit.
+ monkeypatch.setattr(server_module, "get_catalog", lambda: legacy_catalog)
async with Client(mcp) as client:
result = await client.call_tool(
- "get_pattern", {"pattern_id": "structural/decorator", "variant": "pythonic"}
+ "get_pattern", {"pattern_id": "creational/oldthing", "variant": "pythonic"}
)
assert result.structured_content is not None
detail = result.structured_content
- assert detail["verdict"] == "pythonic"
- assert "functools" in detail["source"]["pythonic"]
+ assert detail["verdict"] == "prefer-alternative"
+ assert "pythonic oldthing runs" in detail["source"]["pythonic"]
async def test_get_pattern_unknown_id_names_the_catalog(self) -> None:
async with Client(mcp) as client:
@@ -52,16 +57,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",
@@ -71,10 +82,49 @@ 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 TestRecommendNoteByShape:
+ """The prefer-alternative note is pinned per shape, via synthetic units."""
+
+ @staticmethod
+ def _point_at(
+ catalog: Catalog,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ from design_patterns.mcp.search import SearchIndex
+
+ monkeypatch.setattr(server_module, "get_catalog", lambda: catalog)
+ monkeypatch.setattr(server_module, "get_index", lambda: SearchIndex(catalog))
+
+ async def test_module_unit_note_names_the_module_tools(
+ self, module_catalog: Catalog, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ self._point_at(module_catalog, monkeypatch)
+ 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"]
+
+ async def test_legacy_unit_note_points_at_pythonic_file(
+ self, legacy_catalog: Catalog, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ self._point_at(legacy_catalog, monkeypatch)
+ async with Client(mcp) as client:
+ result = await client.call_tool(
+ "recommend_pattern", {"problem_statement": "old thing needed"}
+ )
+ assert result.structured_content is not None
+ rec = result.structured_content["result"][0]
+ assert rec["id"] == "creational/oldthing"
+ assert "pythonic.py" in rec["note"]
+
+
class TestModuleShapeTools:
"""The three new access levels, against a synthetic migrated unit."""
@@ -133,39 +183,59 @@ async def test_docs_resource(self) -> None:
assert isinstance(first, TextResourceContents)
assert "implementation of Thing" in first.text
+ async def test_variant_resource_error_text_reaches_client(self) -> None:
+ # A module-shape unit refusing a legacy variant read must explain itself.
+ async with Client(mcp) as client:
+ with pytest.raises(Exception, match="module-shape unit"):
+ await client.read_resource("pattern://creational/thing/naive")
+
class TestLegacyShapeErrors:
- """Module-shape tools refuse un-migrated units with a clear message, not a crash."""
+ """Module-shape tools refuse un-migrated units with a clear message, not a crash.
+
+ Uses a synthetic legacy unit: every real unit migrates to the module shape,
+ so no real id can be relied on to stay legacy.
+ """
+
+ @pytest.fixture(autouse=True)
+ def _use_legacy_catalog(self, legacy_catalog: Catalog, monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setattr(server_module, "get_catalog", lambda: legacy_catalog)
async def test_get_pattern_docs_on_legacy_unit(self) -> None:
async with Client(mcp) as client:
result = await client.call_tool(
- "get_pattern_docs", {"pattern_id": "structural/decorator", "doc": "fundamentals"}
+ "get_pattern_docs", {"pattern_id": "creational/oldthing", "doc": "fundamentals"}
)
assert result.is_error
assert "not yet migrated" in str(result.content[0])
async def test_list_examples_on_legacy_unit(self) -> None:
async with Client(mcp) as client:
- result = await client.call_tool("list_examples", {"pattern_id": "structural/decorator"})
+ result = await client.call_tool("list_examples", {"pattern_id": "creational/oldthing"})
assert result.is_error
async def test_read_source_on_legacy_unit(self) -> None:
async with Client(mcp) as client:
- result = await client.call_tool("read_source", {"pattern_id": "structural/decorator"})
+ result = await client.call_tool("read_source", {"pattern_id": "creational/oldthing"})
assert result.is_error
+ async def test_run_example_legacy_variant_dispatch(self) -> None:
+ # The variant= arm is what every un-migrated unit still relies on.
+ async with Client(mcp) as client:
+ result = await client.call_tool(
+ "run_example", {"pattern_id": "creational/oldthing", "variant": "pythonic"}
+ )
+ assert result.structured_content is not None
+ run = result.structured_content
+ assert run["exit_code"] == 0 and not run["timed_out"]
+ assert "pythonic oldthing runs" in run["stdout"]
+
async def test_docs_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="not yet migrated"):
- await client.read_resource("pattern://structural/decorator/docs/fundamentals")
-
- async def test_variant_resource_error_text_reaches_client(self) -> None:
- async with Client(mcp) as client:
- with pytest.raises(Exception, match="module-shape unit"):
- await client.read_resource("pattern://behavioral/chain_of_responsibility/naive")
+ await client.read_resource("pattern://creational/oldthing/docs/fundamentals")
class TestResources: