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"{head}{body}
" + + +def _md_table(headers: Sequence[str], rows: Sequence[Sequence[str]]) -> str: + lines = [ + "| " + " | ".join(headers) + " |", + "|" + "---|" * len(headers), + *("| " + " | ".join(row) + " |" for row in rows), + ] + return "\n".join(lines) + + +HTML = DocumentFamily( + heading=lambda text: f"

{text}

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

{text}

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

Sales by region

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

Sales

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

{text}

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

note

" + assert plain.heading("Sales") == HTML.heading("Sales") # rest shared + + def test_markdown_table_carries_the_separator_row(self) -> None: + table = MARKDOWN.table(["region", "total"], [["west", "1280"]]) + lines = table.splitlines() + assert lines[0] == "| region | total |" + assert lines[1] == "|---|---|" # without it, the table is not Markdown + assert lines[2] == "| west | 1280 |" diff --git a/patterns/creational/abstract_factory/tests/test_report_renderer.py b/patterns/creational/abstract_factory/tests/test_report_renderer.py new file mode 100644 index 0000000..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 "" 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:
westwest