Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,6 @@ uv.lock

# local working files (plans, research briefs)
.cache/

# agent worktrees
.claude/worktrees/
46 changes: 16 additions & 30 deletions patterns/creational/abstract_factory/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
13 changes: 12 additions & 1 deletion patterns/creational/abstract_factory/__init__.py
Original file line number Diff line number Diff line change
@@ -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"]
39 changes: 39 additions & 0 deletions patterns/creational/abstract_factory/docs/examples.md
Original file line number Diff line number Diff line change
@@ -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.
80 changes: 80 additions & 0 deletions patterns/creational/abstract_factory/docs/fundamentals.md
Original file line number Diff line number Diff line change
@@ -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.
79 changes: 79 additions & 0 deletions patterns/creational/abstract_factory/docs/implementation.md
Original file line number Diff line number Diff line change
@@ -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"<h2>{report.title}</h2>")
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
```
Empty file.
Original file line number Diff line number Diff line change
@@ -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"]
Original file line number Diff line number Diff line change
@@ -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()
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
@@ -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, ...]
Loading
Loading