diff --git a/docs/mcp.md b/docs/mcp.md index 3149980..04fd6bb 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -29,24 +29,25 @@ python-design-patterns-mcp --http --host 127.0.0.1 --port 8734 | Tool | What it does | |---|---| | `list_patterns(group?, verdict?)` | Catalog listing, filterable | -| `get_pattern(pattern_id, variant?)` | Full prose (+ legacy variant source files: `naive.py` / `pythonic.py` / `real_world.py`, or `all`) | +| `get_pattern(pattern_id, variant?)` | Full prose and metadata (`variant` served only pre-module-shape units; the current catalog has none) | | `search_patterns(query, limit?)` | BM25 full-text search over names, aliases, problems, symptoms, prose | -| `get_pattern_docs(pattern_id, doc)` | A migrated pattern's teaching doc: `fundamentals`, `implementation`, or `examples` | -| `list_examples(pattern_id)` | A migrated pattern's runnable mini-projects | -| `run_example(pattern_id, variant?/example?)` | Executes a vendored example (legacy `variant` or migrated `example`) in a sandboxed subprocess; returns real stdout | -| `read_source(pattern_id)` | A migrated pattern's own implementation (`pattern/` package) | +| `get_pattern_docs(pattern_id, doc)` | A pattern's teaching doc: `fundamentals`, `implementation`, or `examples` | +| `list_examples(pattern_id)` | A pattern's runnable mini-projects | +| `run_example(pattern_id, example=...)` | Executes a mini-project in a sandboxed subprocess; returns real stdout (`variant=` remains for pre-module-shape units only) | +| `read_source(pattern_id)` | A pattern's own implementation (`pattern/` package) | | `recommend_pattern(problem_statement, limit?)` | Ranked candidates with caveats; `prefer-alternative` verdicts tell you what to write instead | -Migrated (module-shape) patterns follow three access levels: scan docs +Every pattern follows three access levels: scan docs (`get_pattern_docs`) → run a use case (`list_examples` + `run_example`) → -read the source (`read_source`). +read the source (`read_source`). Units predating the module shape would +expose flat variant files instead; the current catalog has none. ## Resources - `catalog://index` — the whole catalog as JSON - `pattern:///` — one pattern's prose -- `pattern:////` — one legacy example's source -- `pattern:////docs/` — one migrated pattern's teaching doc +- `pattern:////docs/` — one pattern's teaching doc +- `pattern:////` — a pre-module-shape unit's variant source (none in the current catalog) ## Prompts diff --git a/patterns/principle/composition_over_inheritance/README.md b/patterns/principle/composition_over_inheritance/README.md index 8af7d3d..250293c 100644 --- a/patterns/principle/composition_over_inheritance/README.md +++ b/patterns/principle/composition_over_inheritance/README.md @@ -14,31 +14,17 @@ stdlib_sightings: [logging.Logger, logging.Handler, logging.Filter] # Composition Over Inheritance -## Problem - -A logger can filter messages and can write to a file or a socket. With -inheritance, every combination costs a class: `FilteredLogger`, -`SocketLogger`, `FilteredSocketLogger`… M filters × N destinations = M×N -classes. This is the guide's opening case study. - -## Naive solution - -`naive.py` builds exactly that explosion, three classes deep, so you can -watch the combinatorics happen. - -## Pythonic solution - -Split each axis into its own object — filters decide, handlers write — and -*compose* them in one logger. M + N small classes cover all M × N behaviors, -and new combinations are constructor arguments, not new classes. - -## In the wild - -The stdlib `logging` module is this principle shipped at scale: `Logger` -composes `Handler`s, `Filter`s, and `Formatter`s, and no class named -`FilteredRotatingSyslogLogger` needs to exist. - -## Verdict - -**Pythonic** — and the single most load-bearing idea behind the other -patterns in this catalog. +One small piece per axis of variation, composed at a single point: M + N +pieces instead of M × N subclasses. **Verdict: pythonic** — the most +load-bearing idea behind the rest of this catalog. + +| Where | What | +|---|---| +| [`pattern/`](pattern/) | The importable code: `Pipeline` (the composition point), `Filter`/`Transform`/`Sink` axis aliases, and `Logger` built on it | +| [`docs/`](docs/) | [Fundamentals](docs/fundamentals.md) · [Implementation guide](docs/implementation.md) · [External examples](docs/examples.md) | +| [`examples/notification_router/`](examples/notification_router/) | Mini-project: alerts through filter × format × deliver pieces, zero combination subclasses | +| [`tests/`](tests/) | Behavioral tests for the pattern and the mini-project | + +```bash +uv run python -m patterns.principle.composition_over_inheritance.examples.notification_router +``` diff --git a/patterns/principle/composition_over_inheritance/__init__.py b/patterns/principle/composition_over_inheritance/__init__.py index 6732fce..ca5dfe6 100644 --- a/patterns/principle/composition_over_inheritance/__init__.py +++ b/patterns/principle/composition_over_inheritance/__init__.py @@ -1 +1,15 @@ -"""Composition over inheritance: objects per axis, not classes per combination.""" +"""Composition Over Inheritance — public API. + +>>> from patterns.principle.composition_over_inheritance import Pipeline, Logger +""" + +from patterns.principle.composition_over_inheritance.pattern import ( + Filter, + Logger, + Pipeline, + Sink, + Transform, + identity, +) + +__all__ = ["Filter", "Logger", "Pipeline", "Sink", "Transform", "identity"] diff --git a/patterns/principle/composition_over_inheritance/docs/examples.md b/patterns/principle/composition_over_inheritance/docs/examples.md new file mode 100644 index 0000000..360383c --- /dev/null +++ b/patterns/principle/composition_over_inheritance/docs/examples.md @@ -0,0 +1,31 @@ +# Composition Over Inheritance — where it lives outside this repo + +Cited, real implementations to study (or point an agent at) when designing or +reviewing class hierarchies. + +## Python standard library + +- **`logging`.** The guide's own worked example, shipped at scale: `Logger` + composes `Handler`s, `Filter`s, and `Formatter`s — three orthogonal axes, + no class per combination. + [docs.python.org/3/library/logging.html](https://docs.python.org/3/library/logging.html) +- **`socketserver`.** The honest contrast: the stdlib's mixin dodge + (`ThreadingMixIn` + `TCPServer` = `ThreadingTCPServer`), useful to study + as the alternative the principle warns will reconverge on the diamond. + [docs.python.org/3/library/socketserver.html](https://docs.python.org/3/library/socketserver.html) + +## Major ecosystems + +- **pytest's plugin architecture.** Behavior is added by composing plugins + registered with hook functions — not by subclassing the test runner. + [docs.pytest.org/en/stable/how-to/writing_plugins.html](https://docs.pytest.org/en/stable/how-to/writing_plugins.html) +- **The guide chapter** — the subclass-explosion case study and the taxonomy + of dodges (multiple inheritance, mixins, dynamically built classes). + [python-patterns.guide/gang-of-four/composition-over-inheritance](https://python-patterns.guide/gang-of-four/composition-over-inheritance/) + +## What to notice across all of them + +In every healthy example the *combination* is expressed at runtime — a +constructor call, a registration — while the *pieces* stay single-purpose. +When reviewing, count adjectives in class names: two or more is the explosion +starting. diff --git a/patterns/principle/composition_over_inheritance/docs/fundamentals.md b/patterns/principle/composition_over_inheritance/docs/fundamentals.md new file mode 100644 index 0000000..2e54e6e --- /dev/null +++ b/patterns/principle/composition_over_inheritance/docs/fundamentals.md @@ -0,0 +1,74 @@ +# Composition Over Inheritance — fundamentals + +## Intent + +Vary independent behaviors without one subclass per combination of them. +"Favor object composition over class inheritance" is the second principle in +the GoF introduction — the soil the structural and behavioral patterns grow +from. Each independent axis of variation becomes its own small object, +injected where needed; M + N pieces cover M × N behaviors. Source chapter: +[python-patterns.guide/gang-of-four/composition-over-inheritance](https://python-patterns.guide/gang-of-four/composition-over-inheritance/). + +## Participants + +| Role | What it is | +|---|---| +| Axes of variation | The independent behaviors (what to accept, how to shape, where to send) | +| One small piece per axis | A callable or tiny class per behavior — `Filter`/`Transform`/`Sink` in [`pattern/compose.py`](../pattern/compose.py) | +| The composition point | The one class that holds a piece per axis and wires them — `Pipeline` in [`pattern/compose.py`](../pattern/compose.py); `Logger` and the example's `Notifier` are `Pipeline` put to work | +| Clients | Pick pieces and construct; a new combination is a constructor call | + +## Mechanism + +1. Name the axes. If a class name wants two adjectives + (`FilteredSocketLogger`), there are at least two. +2. Give each axis its own minimal interface — in Python usually just a + callable signature. +3. Write one composition-point class holding one piece per axis; its methods + delegate in a fixed, readable order. +4. Combinations are now data: constructed, passed, and tested — never + subclassed into existence. + +## The subclass explosion, and what composition replaces + +The classic route bolts each behavior on by subclassing — and then needs a +class *per combination*: + +```python +class Logger: ... # base + + +class FilteredLogger(Logger): ... # axis 1 bolted on + + +class UppercaseLogger(Logger): ... # axis 2 bolted on + + +class FilteredUppercaseLogger(FilteredLogger): # the explosion begins: + """One class PER COMBINATION.""" # M x N x P classes coming +``` + +Two axes already cost four classes; each new filter or destination +*multiplies* the count. The dodges — multiple inheritance, mixins, +dynamically built classes — postpone the explosion rather than end it. The +composed form keeps one class per *piece* plus one composition point, and +the arithmetic turns from multiplication into addition. + +## When to use it + +- Two or more behaviors vary independently (filtering × formatting × + destination; retry × serialization × transport). +- Subclass names are growing adjectives, or a mixin diamond is forming. + +## When not to use it + +- One axis, two variants, stable → a single `if` or a subclass is honest and + smaller; composition machinery would be ceremony. +- The "axes" are not independent (one behavior's output feeds another's + contract) → model the coupling explicitly; forced composition hides it. + +## Verdict: pythonic + +The single most load-bearing idea behind the rest of this catalog — the +stdlib's `logging` (Logger/Handler/Filter/Formatter) is this principle +shipped at scale, with no `FilteredRotatingSyslogLogger` in sight. diff --git a/patterns/principle/composition_over_inheritance/docs/implementation.md b/patterns/principle/composition_over_inheritance/docs/implementation.md new file mode 100644 index 0000000..3e1c851 --- /dev/null +++ b/patterns/principle/composition_over_inheritance/docs/implementation.md @@ -0,0 +1,74 @@ +# Composition Over Inheritance — putting it into a system + +## The smell it fixes + +Class names collecting adjectives, and a new requirement meaning a new +subclass: + +```python +class JsonFileNotifier(FileNotifier): ... + + +class DedupJsonFileNotifier(JsonFileNotifier): ... + + +class DedupJsonWebhookNotifier(...): ... # and severity thresholds arrive Monday +``` + +## Steps + +1. **List the axes.** Read the subclass names: each adjective is an axis + (dedup / json / webhook = decide / reshape / act). +2. **Define one minimal interface per axis.** In Python a callable signature + is usually enough — `Filter[T] = Callable[[T], bool]`, + `Transform[T, U] = Callable[[T], U]`, `Sink[T] = Callable[[T], None]` + (importable from this unit's `pattern/`). +3. **Extract each behavior into a piece.** Plain functions for stateless + pieces; a small callable class where state is real (`Dedup`); a closure + factory where only a parameter varies (`min_severity(4)`). +4. **Write the composition point** — one dataclass with one field per axis + and the delegation order spelled out in one method. +5. **Delete the subclass tree.** Every leaf class becomes a constructor call; + its tests become tests of a *combination*, which now read as configuration. + +```python +from patterns.principle.composition_over_inheritance import Pipeline + +# Notifier = Pipeline[Alert, str] — the domain names the composition point. +pager = Notifier(filters=(min_severity(4), Dedup()), transform=as_json, sink=webhook) +``` + +## Python idioms that keep it small + +- **Callables are the interfaces.** No ABCs needed until an axis has multiple + methods; `Protocol` when it does. +- **Closure factories replace parameter subclasses**: `min_severity(4)` is + the whole class `SeverityAtLeastFourFilter`. +- **`functools.partial`** turns any configurable function into a piece. +- **Dataclasses as composition points** make the wiring visible in `repr` + and trivially testable. + +## Pitfalls + +- **Rebuilding inheritance inside composition** — a piece that reaches into + the composition point (or another piece) recreates the coupling with extra + steps. Pieces see only their own input. +- **The god-piece.** One "filter" that also formats and delivers has eaten + the axes; if a piece needs two verbs to describe, split it. +- **The mixin dodge.** `class DedupMixin: ...` feels cheaper today and + reconverges on the diamond tomorrow — the guide's taxonomy of dodges is + worth rereading when tempted. +- **Order left implicit.** The composition point owns the delegation order; + document and test it (filters run in order and short-circuit — a stateful + filter placed after a veto never records vetoed items). + +## Worked example + +[`examples/notification_router/`](../examples/notification_router/) routes +alerts through filter × format × deliver pieces — console plain-text at +severity ≥ 2, deduped JSON to a webhook at severity ≥ 4 — one `Notifier` +class, zero combination subclasses. Run it: + +```bash +uv run python -m patterns.principle.composition_over_inheritance.examples.notification_router +``` diff --git a/patterns/principle/composition_over_inheritance/examples/__init__.py b/patterns/principle/composition_over_inheritance/examples/__init__.py new file mode 100644 index 0000000..ca2cb3e --- /dev/null +++ b/patterns/principle/composition_over_inheritance/examples/__init__.py @@ -0,0 +1 @@ +"""Mini-projects demonstrating Composition Over Inheritance in practice.""" diff --git a/patterns/principle/composition_over_inheritance/examples/notification_router/__init__.py b/patterns/principle/composition_over_inheritance/examples/notification_router/__init__.py new file mode 100644 index 0000000..46a58ee --- /dev/null +++ b/patterns/principle/composition_over_inheritance/examples/notification_router/__init__.py @@ -0,0 +1,35 @@ +"""An alert router built on Composition Over Inheritance. + +Run it: ``uv run python -m patterns.principle.composition_over_inheritance\ +.examples.notification_router`` +""" + +from patterns.principle.composition_over_inheritance.examples.notification_router.axes import ( + Dedup, + FakeWebhook, + MemorySink, + as_json, + console, + min_severity, + plain_text, +) +from patterns.principle.composition_over_inheritance.examples.notification_router.models import ( + Alert, +) +from patterns.principle.composition_over_inheritance.examples.notification_router.router import ( + Notifier, + Router, +) + +__all__ = [ + "Alert", + "Dedup", + "FakeWebhook", + "MemorySink", + "Notifier", + "Router", + "as_json", + "console", + "min_severity", + "plain_text", +] diff --git a/patterns/principle/composition_over_inheritance/examples/notification_router/__main__.py b/patterns/principle/composition_over_inheritance/examples/notification_router/__main__.py new file mode 100644 index 0000000..af7d2e2 --- /dev/null +++ b/patterns/principle/composition_over_inheritance/examples/notification_router/__main__.py @@ -0,0 +1,42 @@ +"""Demo: two very different notifiers from one class and a handful of pieces.""" + +from __future__ import annotations + +from patterns.principle.composition_over_inheritance.examples.notification_router.axes import ( + Dedup, + FakeWebhook, + as_json, + console, + min_severity, + plain_text, +) +from patterns.principle.composition_over_inheritance.examples.notification_router.models import ( + Alert, +) +from patterns.principle.composition_over_inheritance.examples.notification_router.router import ( + Notifier, + Router, +) + + +def main() -> None: + webhook = FakeWebhook("https://pager.example/hook") + router = Router( + notifiers=( + Notifier(filters=(min_severity(2),), transform=plain_text, sink=console), + Notifier(filters=(min_severity(4), Dedup()), transform=as_json, sink=webhook), + ) + ) + alerts = [ + Alert("api", 2, "latency rising"), + Alert("db", 5, "primary down"), + Alert("db", 5, "primary down"), # duplicate: webhook dedups, console repeats + Alert("cron", 1, "nightly job finished"), + ] + for alert in alerts: + router.broadcast(alert) + print(f"webhook received {len(webhook.posted)} page(s): {webhook.posted[0]}") + + +if __name__ == "__main__": + main() diff --git a/patterns/principle/composition_over_inheritance/examples/notification_router/axes.py b/patterns/principle/composition_over_inheritance/examples/notification_router/axes.py new file mode 100644 index 0000000..c3d4721 --- /dev/null +++ b/patterns/principle/composition_over_inheritance/examples/notification_router/axes.py @@ -0,0 +1,81 @@ +"""One small piece per axis of variation: filter x format x deliver. + +Three axes, a few pieces each. Composed, they cover every combination the +inheritance route would need a class for — no ``DedupJsonWebhookNotifier`` +anywhere. +""" + +from __future__ import annotations + +import json + +from patterns.principle.composition_over_inheritance.examples.notification_router.models import ( + Alert, +) +from patterns.principle.composition_over_inheritance.pattern import Filter + +# --- axis 1: filters (decide) ------------------------------------------------ + + +def min_severity(threshold: int) -> Filter[Alert]: + """Parameterization instead of a subclass per threshold.""" + + def accepts(alert: Alert) -> bool: + return alert.severity >= threshold + + return accepts + + +class Dedup: + """A stateful filter: each (source, message) passes once.""" + + def __init__(self) -> None: + self._seen: set[tuple[str, str]] = set() + + def __call__(self, alert: Alert) -> bool: + key = (alert.source, alert.message) + if key in self._seen: + return False + self._seen.add(key) + return True + + +# --- axis 2: formats (reshape) ----------------------------------------------- + + +def plain_text(alert: Alert) -> str: + return f"[{alert.severity}] {alert.source}: {alert.message}" + + +def as_json(alert: Alert) -> str: + return json.dumps( + {"source": alert.source, "severity": alert.severity, "message": alert.message} + ) + + +# --- axis 3: deliveries (act) ------------------------------------------------ + + +def console(line: str) -> None: + print(line) + + +class MemorySink: + """Delivery for tests and demos: keeps what it was handed.""" + + def __init__(self) -> None: + self.lines: list[str] = [] + + def __call__(self, line: str) -> None: + self.lines.append(line) + + +class FakeWebhook: + """Stands in for an HTTP POST; records payloads instead of sending.""" + + def __init__(self, url: str) -> None: + self.url = url + self.posted: list[str] = [] + + def __call__(self, payload: str) -> None: + self.posted.append(payload) diff --git a/patterns/principle/composition_over_inheritance/examples/notification_router/models.py b/patterns/principle/composition_over_inheritance/examples/notification_router/models.py new file mode 100644 index 0000000..de4b4d7 --- /dev/null +++ b/patterns/principle/composition_over_inheritance/examples/notification_router/models.py @@ -0,0 +1,14 @@ +"""Domain types for the notification-router mini-project.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Alert: + """Something worth telling someone about. Severity: 1 (info) .. 5 (page).""" + + source: str + severity: int + message: str diff --git a/patterns/principle/composition_over_inheritance/examples/notification_router/router.py b/patterns/principle/composition_over_inheritance/examples/notification_router/router.py new file mode 100644 index 0000000..44e3f90 --- /dev/null +++ b/patterns/principle/composition_over_inheritance/examples/notification_router/router.py @@ -0,0 +1,24 @@ +"""The composition point, instantiated for the notification domain.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from patterns.principle.composition_over_inheritance.examples.notification_router.models import ( + Alert, +) +from patterns.principle.composition_over_inheritance.pattern import Pipeline + +#: One class, ever — the pattern's ``Pipeline`` bound to the alert domain. +#: A new behavior combination is a constructor call, not a subclass. +Notifier = Pipeline[Alert, str] + + +@dataclass +class Router: + """Fan-out: every notifier sees every alert, each applies its own policy.""" + + notifiers: tuple[Notifier, ...] + + def broadcast(self, alert: Alert) -> int: + return sum(1 for notifier in self.notifiers if notifier.process(alert)) diff --git a/patterns/principle/composition_over_inheritance/naive.py b/patterns/principle/composition_over_inheritance/naive.py deleted file mode 100644 index 3c7a6ad..0000000 --- a/patterns/principle/composition_over_inheritance/naive.py +++ /dev/null @@ -1,53 +0,0 @@ -"""The subclass explosion, reproduced faithfully. - -Two independent axes (filtering, destination) already cost four classes; -each new filter or destination multiplies, not adds. -""" - -from __future__ import annotations - - -class Logger: - def __init__(self, sink: list[str]) -> None: - self.sink = sink - - def log(self, message: str) -> None: - self.sink.append(message) - - -class FilteredLogger(Logger): - """Axis 1 bolted on by subclassing.""" - - def __init__(self, pattern: str, sink: list[str]) -> None: - super().__init__(sink) - self.pattern = pattern - - def log(self, message: str) -> None: - if self.pattern in message: - super().log(message) - - -class UppercaseLogger(Logger): - """Axis 2 bolted on by subclassing.""" - - def log(self, message: str) -> None: - super().log(message.upper()) - - -class FilteredUppercaseLogger(FilteredLogger): - """And here is the explosion: one class PER COMBINATION.""" - - def log(self, message: str) -> None: - if self.pattern in message: - self.sink.append(message.upper()) - - -def main() -> None: - sink: list[str] = [] - FilteredUppercaseLogger("error", sink).log("error: disk full") - FilteredUppercaseLogger("error", sink).log("all fine") - print(sink) - - -if __name__ == "__main__": - main() diff --git a/patterns/principle/composition_over_inheritance/pattern/__init__.py b/patterns/principle/composition_over_inheritance/pattern/__init__.py new file mode 100644 index 0000000..3b18b5f --- /dev/null +++ b/patterns/principle/composition_over_inheritance/pattern/__init__.py @@ -0,0 +1,12 @@ +"""Composition Over Inheritance, importable as library code.""" + +from patterns.principle.composition_over_inheritance.pattern.compose import ( + Filter, + Logger, + Pipeline, + Sink, + Transform, + identity, +) + +__all__ = ["Filter", "Logger", "Pipeline", "Sink", "Transform", "identity"] diff --git a/patterns/principle/composition_over_inheritance/pattern/compose.py b/patterns/principle/composition_over_inheritance/pattern/compose.py new file mode 100644 index 0000000..ef13ce4 --- /dev/null +++ b/patterns/principle/composition_over_inheritance/pattern/compose.py @@ -0,0 +1,69 @@ +"""Composition Over Inheritance as importable, typed building blocks. + +The principle's vocabulary: each independent axis of variation is a small +callable — something that decides (``Filter``), something that reshapes +(``Transform``), something that acts (``Sink``) — and ``Pipeline`` is the +one composition point that wires a piece per axis together. ``Logger`` is +the guide's own worked shape, built *on* ``Pipeline`` rather than beside +it: M filters + N transforms cover M x N behaviors with M + N pieces. +""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass, field +from typing import Generic, TypeVar + +T = TypeVar("T") +U = TypeVar("U") +In = TypeVar("In") +Out = TypeVar("Out") + +#: One alias per axis kind — the principle's whole type system. +Filter = Callable[[T], bool] +Transform = Callable[[T], U] +Sink = Callable[[T], None] + + +def identity(message: str) -> str: + """The do-nothing Transform: a composed axis can opt out explicitly.""" + return message + + +@dataclass +class Pipeline(Generic[In, Out]): + """The composition point: one piece per axis, wired once, no subclasses. + + Filters run in declaration order and short-circuit (``all``). That order + is behavior when a filter keeps state: put cheap vetoes before + state-recording filters, or the recorder remembers items that were never + delivered. + """ + + filters: tuple[Filter[In], ...] + transform: Transform[In, Out] + sink: Sink[Out] + + def process(self, item: In) -> bool: + """Deliver if every filter accepts; report whether delivery happened.""" + if not all(accepts(item) for accepts in self.filters): + return False + self.sink(self.transform(item)) + return True + + +@dataclass +class Logger: + """The guide's worked shape, composed from ``Pipeline``. + + The sink axis here is "append to my lines": ``sink`` stays a plain + ``list[str]`` so callers and tests read results directly, and the + ``Sink`` handed to the underlying ``Pipeline`` is ``sink.append``. + """ + + sink: list[str] = field(default_factory=list) + filters: tuple[Filter[str], ...] = () + transform: Transform[str, str] = identity + + def log(self, message: str) -> None: + Pipeline(self.filters, self.transform, self.sink.append).process(message) diff --git a/patterns/principle/composition_over_inheritance/pythonic.py b/patterns/principle/composition_over_inheritance/pythonic.py deleted file mode 100644 index 2c21476..0000000 --- a/patterns/principle/composition_over_inheritance/pythonic.py +++ /dev/null @@ -1,45 +0,0 @@ -"""Composition: one small object per axis, combined at runtime. - -M filters + N transforms cover M x N behaviors with M + N classes; a new -combination is a constructor call, not a new class. -""" - -from __future__ import annotations - -from collections.abc import Callable -from dataclasses import dataclass, field - -Filter = Callable[[str], bool] -Transform = Callable[[str], str] - - -def contains(pattern: str) -> Filter: - return lambda message: pattern in message - - -def identity(message: str) -> str: - return message - - -@dataclass -class Logger: - """One logger class, ever. Behavior comes from what you compose into it.""" - - sink: list[str] = field(default_factory=list) - filters: tuple[Filter, ...] = () - transform: Transform = identity - - def log(self, message: str) -> None: - if all(f(message) for f in self.filters): - self.sink.append(self.transform(message)) - - -def main() -> None: - loud_errors = Logger(filters=(contains("error"),), transform=str.upper) - loud_errors.log("error: disk full") - loud_errors.log("all fine") - print(loud_errors.sink) - - -if __name__ == "__main__": - main() diff --git a/patterns/principle/composition_over_inheritance/real_world.py b/patterns/principle/composition_over_inheritance/real_world.py deleted file mode 100644 index 7ae4289..0000000 --- a/patterns/principle/composition_over_inheritance/real_world.py +++ /dev/null @@ -1,37 +0,0 @@ -"""The ``logging`` module: composition at industrial scale. - -A Logger composes Handlers and Filters; nobody subclasses per combination. -""" - -from __future__ import annotations - -import logging - - -def build_error_logger(name: str, sink: list[str]) -> logging.Logger: - """Compose: a list-writing handler + a substring filter, one stock Logger.""" - - class ListHandler(logging.Handler): - def emit(self, record: logging.LogRecord) -> None: - sink.append(record.getMessage()) - - logger = logging.getLogger(name) - logger.handlers.clear() - logger.propagate = False - logger.setLevel(logging.INFO) - handler = ListHandler() - handler.addFilter(lambda record: "error" in record.getMessage()) - logger.addHandler(handler) - return logger - - -def main() -> None: - sink: list[str] = [] - logger = build_error_logger("demo", sink) - logger.info("error: disk full") - logger.info("all fine") - print(sink) - - -if __name__ == "__main__": - main() diff --git a/patterns/principle/composition_over_inheritance/tests/test_compose.py b/patterns/principle/composition_over_inheritance/tests/test_compose.py new file mode 100644 index 0000000..6490438 --- /dev/null +++ b/patterns/principle/composition_over_inheritance/tests/test_compose.py @@ -0,0 +1,82 @@ +"""Behavioral tests for the pattern's Pipeline core and the composed Logger.""" + +from __future__ import annotations + +from patterns.principle.composition_over_inheritance import ( + Filter, + Logger, + Pipeline, + identity, +) + + +def contains(pattern: str) -> Filter[str]: + def accepts(message: str) -> bool: + return pattern in message + + return accepts + + +class Recording: + """A stateful filter that remembers everything it was asked about.""" + + def __init__(self, verdict: bool = True) -> None: + self.verdict = verdict + self.asked: list[str] = [] + + def __call__(self, message: str) -> bool: + self.asked.append(message) + return self.verdict + + +class TestPipeline: + def test_delivers_the_transformed_item_when_all_filters_accept(self) -> None: + out: list[str] = [] + pipe: Pipeline[str, str] = Pipeline( + filters=(contains("error"),), transform=str.upper, sink=out.append + ) + assert pipe.process("error: disk full") is True + assert out == ["ERROR: DISK FULL"] + + def test_one_rejecting_filter_blocks_delivery(self) -> None: + out: list[str] = [] + pipe: Pipeline[str, str] = Pipeline( + filters=(contains("error"), contains("disk")), transform=identity, sink=out.append + ) + assert pipe.process("error: bad password") is False + assert out == [] + + def test_filters_short_circuit_in_declaration_order(self) -> None: + # Order is behavior: a stateful filter placed after a veto must never + # even be consulted for the vetoed item. + recorder = Recording() + pipe: Pipeline[str, str] = Pipeline( + filters=(contains("error"), recorder), transform=identity, sink=lambda _line: None + ) + pipe.process("all fine") + assert recorder.asked == [] # vetoed before the recorder saw it + pipe.process("error: disk full") + assert recorder.asked == ["error: disk full"] + + def test_identity_is_a_real_transform(self) -> None: + assert identity("unchanged") == "unchanged" + + +class TestComposedLogger: + def test_behavior_comes_from_the_composed_pieces(self) -> None: + loud_errors = Logger(filters=(contains("error"),), transform=str.upper) + loud_errors.log("error: disk full") + loud_errors.log("all fine") + assert loud_errors.sink == ["ERROR: DISK FULL"] + + def test_a_new_combination_is_a_constructor_call_not_a_class(self) -> None: + quiet = Logger(filters=(contains("error"), contains("disk"))) + quiet.log("error: disk full") + quiet.log("error: bad password") # fails the second filter + assert quiet.sink == ["error: disk full"] + assert type(quiet) is Logger # same one class covers every combination + + def test_no_filters_means_everything_passes(self) -> None: + logger = Logger() + logger.log("anything") + assert logger.sink == ["anything"] # default transform is identity diff --git a/patterns/principle/composition_over_inheritance/tests/test_composition_over_inheritance.py b/patterns/principle/composition_over_inheritance/tests/test_composition_over_inheritance.py deleted file mode 100644 index 5681935..0000000 --- a/patterns/principle/composition_over_inheritance/tests/test_composition_over_inheritance.py +++ /dev/null @@ -1,45 +0,0 @@ -"""Behavioral tests for the composition-over-inheritance unit.""" - -from patterns.principle.composition_over_inheritance import naive, pythonic, real_world - - -class TestNaive: - def test_combination_class_works_but_had_to_exist(self) -> None: - sink: list[str] = [] - logger = naive.FilteredUppercaseLogger("error", sink) - logger.log("error: disk full") - logger.log("all fine") - assert sink == ["ERROR: DISK FULL"] - - def test_the_explosion_is_real(self) -> None: - # Four classes for two axes -- the M x N cost, in the flesh. - assert issubclass(naive.FilteredUppercaseLogger, naive.FilteredLogger) - assert issubclass(naive.FilteredLogger, naive.Logger) - - -class TestPythonic: - def test_composed_behavior_matches_the_combination_class(self) -> None: - logger = pythonic.Logger(filters=(pythonic.contains("error"),), transform=str.upper) - logger.log("error: disk full") - logger.log("all fine") - assert logger.sink == ["ERROR: DISK FULL"] - - def test_new_combination_is_a_constructor_call(self) -> None: - plain = pythonic.Logger(filters=(pythonic.contains("warn"),)) - plain.log("warn: low disk") - plain.log("error: ignored here") - assert plain.sink == ["warn: low disk"] - - def test_no_filters_means_log_everything(self) -> None: - logger = pythonic.Logger() - logger.log("anything") - assert logger.sink == ["anything"] - - -class TestRealWorld: - def test_stdlib_logging_composes_filter_and_handler(self) -> None: - sink: list[str] = [] - logger = real_world.build_error_logger("pdp-test", sink) - logger.info("error: disk full") - logger.info("all fine") - assert sink == ["error: disk full"] diff --git a/patterns/principle/composition_over_inheritance/tests/test_notification_router.py b/patterns/principle/composition_over_inheritance/tests/test_notification_router.py new file mode 100644 index 0000000..455002a --- /dev/null +++ b/patterns/principle/composition_over_inheritance/tests/test_notification_router.py @@ -0,0 +1,128 @@ +"""Behavioral tests for the notification-router mini-project.""" + +from __future__ import annotations + +import json +from collections.abc import Callable + +import pytest + +from patterns.principle.composition_over_inheritance.examples.notification_router import ( + Alert, + Dedup, + FakeWebhook, + MemorySink, + Notifier, + Router, + as_json, + console, + min_severity, + plain_text, +) +from patterns.principle.composition_over_inheritance.examples.notification_router.__main__ import ( + main, +) +from patterns.principle.composition_over_inheritance.pattern import Filter + + +def alert(severity: int, message: str = "m", source: str = "api") -> Alert: + return Alert(source, severity, message) + + +class TestAxes: + def test_min_severity_parameterizes_instead_of_subclassing(self) -> None: + assert min_severity(3)(alert(3)) is True + assert min_severity(3)(alert(2)) is False + + def test_dedup_passes_each_alert_once(self) -> None: + dedup = Dedup() + assert dedup(alert(5, "primary down", "db")) is True + assert dedup(alert(5, "primary down", "db")) is False + assert dedup(alert(5, "replica down", "db")) is True # different message + + +SAMPLE = Alert("db", 5, "primary down") + +#: Expected payload per format for SAMPLE — content, not just counts. +EXPECTED = { + "plain_text": "[5] db: primary down", + "as_json": json.dumps({"source": "db", "severity": 5, "message": "primary down"}), +} + + +class TestEveryCombination: + """The headline claim, demonstrated: 2 filters x 2 formats x 3 sinks = 12 + behaviors from 7 small pieces and zero subclasses.""" + + @pytest.mark.parametrize("filter_name", ["min_severity", "dedup"]) + @pytest.mark.parametrize("format_name", ["plain_text", "as_json"]) + @pytest.mark.parametrize("sink_name", ["memory", "webhook", "console"]) + def test_the_full_cross_product_delivers_the_right_content( + self, + filter_name: str, + format_name: str, + sink_name: str, + capsys: pytest.CaptureFixture[str], + ) -> None: + chosen_filter: Filter[Alert] = min_severity(4) if filter_name == "min_severity" else Dedup() + transform = {"plain_text": plain_text, "as_json": as_json}[format_name] + + sink: Callable[[str], None] + delivered: Callable[[], str] + if sink_name == "memory": + memory = MemorySink() + sink, delivered = memory, lambda: memory.lines[0] + elif sink_name == "webhook": + webhook = FakeWebhook("https://pager.example/hook") + sink, delivered = webhook, lambda: webhook.posted[0] + else: + sink, delivered = console, lambda: capsys.readouterr().out.rstrip("\n") + + notifier = Notifier(filters=(chosen_filter,), transform=transform, sink=sink) + assert notifier.process(SAMPLE) is True + assert delivered() == EXPECTED[format_name] + + +class TestComposition: + def test_two_behaviors_one_class_zero_subclasses(self) -> None: + chatty, pager = MemorySink(), MemorySink() + router = Router( + notifiers=( + Notifier(filters=(min_severity(2),), transform=plain_text, sink=chatty), + Notifier(filters=(min_severity(4), Dedup()), transform=as_json, sink=pager), + ) + ) + router.broadcast(alert(2, "latency rising")) + router.broadcast(alert(5, "primary down", "db")) + router.broadcast(alert(5, "primary down", "db")) # duplicate + assert chatty.lines == [ + "[2] api: latency rising", + "[5] db: primary down", + "[5] db: primary down", # console repeats; that is its policy + ] + assert pager.lines == [EXPECTED["as_json"]] # webhook policy dedups + assert type(router.notifiers[0]) is type(router.notifiers[1]) # one class + + def test_filter_order_is_policy_dedup_must_not_see_vetoed_alerts(self) -> None: + # min_severity runs before Dedup, so a sub-threshold alert must not + # poison the dedup memory: the later legitimate page still goes out. + pager = MemorySink() + notifier = Notifier(filters=(min_severity(4), Dedup()), transform=plain_text, sink=pager) + assert notifier.process(alert(1, "primary down", "db")) is False # vetoed + assert notifier.process(alert(5, "primary down", "db")) is True # delivered + assert pager.lines == ["[5] db: primary down"] + + def test_process_reports_whether_delivery_happened(self) -> None: + sink = MemorySink() + notifier = Notifier(filters=(min_severity(4),), transform=plain_text, sink=sink) + assert notifier.process(alert(5)) is True + assert notifier.process(alert(1)) is False + assert sink.lines == ["[5] api: m"] + + +class TestDemo: + def test_main_dedups_the_duplicate_page(self, capsys: pytest.CaptureFixture[str]) -> None: + main() + out = capsys.readouterr().out + assert "webhook received 1 page(s)" in out + assert "[5] db: primary down" in out # console saw it (twice, its policy) diff --git a/patterns/python/global_object/README.md b/patterns/python/global_object/README.md index 796b215..956ec11 100644 --- a/patterns/python/global_object/README.md +++ b/patterns/python/global_object/README.md @@ -14,32 +14,17 @@ stdlib_sightings: [os.environ, calendar.day_name, math.pi] # Global Object -## Problem - -Many parts of a program need the same value — a constant table, a compiled -regex, a configured client. Passing it through every call chain is noise; -building it repeatedly is waste. - -## Naive solution - -`naive.py` shows the two classic misuses: hidden *mutable* module state that -couples callers together, and import-time I/O that makes `import` slow, -fragile, and untestable. - -## Pythonic solution - -`pythonic.py` shows the pattern done well: immutable constants computed at -import time (cheap, deterministic), a pre-built global object whose -construction is pure, and lazy initialization for anything expensive — -so importing the module never costs more than defining functions. - -## In the wild - -`math.pi` is the Constant Pattern; `calendar.day_name` is an import-time -computed global object; `os.environ` is the rare *documented* mutable global, -mutation being its entire purpose. - -## Verdict - -**Use with care.** Constants and immutable pre-built objects: freely. Mutable -globals: only when shared mutation is the feature, not an accident. +Share a constant or a pre-built object program-wide by assigning it at module +level — Python's native singleton. **Verdict: use with care** — constants +freely, expensive things lazily, mutation only where it is the documented job. + +| Where | What | +|---|---| +| [`pattern/`](pattern/) | The importable code: `Lazy` (deferred construction + test-reset seam) | +| [`docs/`](docs/) | [Fundamentals](docs/fundamentals.md) · [Implementation guide](docs/implementation.md) · [External examples](docs/examples.md) | +| [`examples/settings_module/`](examples/settings_module/) | Mini-project: an app settings module with all three kinds of global | +| [`tests/`](tests/) | Behavioral tests for the pattern and the mini-project | + +```bash +uv run python -m patterns.python.global_object.examples.settings_module +``` diff --git a/patterns/python/global_object/__init__.py b/patterns/python/global_object/__init__.py index acbaef7..d652803 100644 --- a/patterns/python/global_object/__init__.py +++ b/patterns/python/global_object/__init__.py @@ -1 +1,8 @@ -"""Global Object: module-level constants and shared instances.""" +"""Global Object — public API. + +>>> from patterns.python.global_object import Lazy +""" + +from patterns.python.global_object.pattern import Lazy + +__all__ = ["Lazy"] diff --git a/patterns/python/global_object/docs/examples.md b/patterns/python/global_object/docs/examples.md new file mode 100644 index 0000000..1c82075 --- /dev/null +++ b/patterns/python/global_object/docs/examples.md @@ -0,0 +1,34 @@ +# Global Object — where it lives outside this repo + +Cited, real implementations to study (or point an agent at) when designing or +reviewing module-global code. + +## Python standard library + +- **`math.pi`, `calendar.day_name`.** The Constant Pattern and the prebuilt + global object: computed at import, immutable in practice, shared by every + importer. [docs.python.org/3/library/calendar.html](https://docs.python.org/3/library/calendar.html) +- **`os.environ`.** The rare *documented* mutable global — mutation is its + entire job, which is exactly the bar a mutable module global must clear. + [docs.python.org/3/library/os.html#os.environ](https://docs.python.org/3/library/os.html#os.environ) +- **`logging.root` and the module-logger convention.** `logger = + logging.getLogger(__name__)` at module top is a per-module global object + built through a cached factory. + [docs.python.org/3/library/logging.html](https://docs.python.org/3/library/logging.html) + +## Major ecosystems + +- **`django.conf.settings`.** A lazy global object: importing it is free, the + wrapped settings module materializes on first attribute access — the same + deferral this unit's `Lazy` provides. + [docs.djangoproject.com/en/stable/topics/settings/](https://docs.djangoproject.com/en/stable/topics/settings/) +- **The guide chapter.** The import-time-I/O prohibition, the dunder-constant + conventions, and the Constant/Global Object distinction this unit encodes. + [python-patterns.guide/python/module-globals](https://python-patterns.guide/python/module-globals/) + +## What to notice across all of them + +Every healthy global is either immutable, lazily built, or mutable *by +documented design* — there is no fourth kind in the wild. When reviewing, +classify each module global into one of the three; whatever resists +classification is the bug. diff --git a/patterns/python/global_object/docs/fundamentals.md b/patterns/python/global_object/docs/fundamentals.md new file mode 100644 index 0000000..719827f --- /dev/null +++ b/patterns/python/global_object/docs/fundamentals.md @@ -0,0 +1,75 @@ +# Global Object — fundamentals + +## Intent + +Give a whole program shared access to a constant or a pre-built object by +assigning it once at module level. A Python module is created once and cached +in `sys.modules`, so a module-level name **is** the language's native shared +instance — this pattern is what Singleton actually wants to be in Python. +Source chapter: [python-patterns.guide/python/module-globals](https://python-patterns.guide/python/module-globals/). + +## Participants + +| Role | What it is | +|---|---| +| The module | The shared namespace, constructed exactly once per process | +| Constants | Immutable values named at module level (`RETRY_LIMIT = 3`) | +| Prebuilt objects | Cheap, pure import-time construction (a compiled regex, a parsed table) | +| The lazy global | Expensive construction deferred to first use — `Lazy` in [`pattern/lazy.py`](../pattern/lazy.py) | +| Importers | Every consumer; they share the one instance by importing the name | + +## Mechanism + +1. Truly constant values are assigned at module level and never mutated. +2. Objects that are cheap and *pure* to build (no I/O, deterministic) may be + built at import time. +3. Anything expensive — file parses, network, big tables — goes behind a lazy + accessor: importing costs nothing, the first `get()` pays once, and + `reset()` restores test order-independence. +4. Mutable module globals are reserved for objects whose mutation is their + documented job (`os.environ`), never an accident of convenience. + +## The classic misuses, and the discipline that replaces them + +The pattern is defined as much by what it forbids as what it allows. The two +classic misuses: + +```python +# Misuse 1: hidden mutable state — every caller is coupled to every other. +_counts: dict[str, int] = {} + + +def tally(word: str) -> int: + _counts[word] = _counts.get(word, 0) + 1 # tests now order-dependent + return _counts[word] + + +# Misuse 2: work at import time — every importer pays, before anyone asks. +CATALOG = json.load(open("catalog.json")) # I/O on import: slow, fragile +``` + +The first couples strangers through invisible state and makes test order +matter. The second makes `import` slow, order-dependent, and untestable — the +guide's hardest rule is **never do I/O at import time**. The discipline: +constants freely, pure-and-cheap prebuilt objects freely, everything else +lazily, mutation only where mutation is the documented contract. + +## When to use it + +- A value many modules need, whose identity should be shared (config, a + compiled regex, a lookup table, a client object). +- You are about to write a Singleton class — a module global is the same + guarantee without the ceremony. + +## When not to use it + +- The value differs per request/test/tenant → pass it explicitly (see + `modern/dependency_injection`). +- Callers need to mutate it and mutation is not the object's documented + purpose → the coupling will outlive whoever wrote it. + +## Verdict: use with care + +Constants and immutable prebuilt objects: freely. Expensive things: behind +`Lazy`. Mutable globals: only when shared mutation is the feature — and the +docs say so. diff --git a/patterns/python/global_object/docs/implementation.md b/patterns/python/global_object/docs/implementation.md new file mode 100644 index 0000000..216b248 --- /dev/null +++ b/patterns/python/global_object/docs/implementation.md @@ -0,0 +1,69 @@ +# Global Object — putting it into a system + +## The smell it fixes + +The same value rebuilt everywhere, or threaded through every call chain as +noise: + +```python +def handler(request, config, slug_re, zone_table): # everyone carries the bags + ... +def other_handler(request): + zone_table = load_zone_table() # ... or rebuilds them +``` + +## Steps + +1. **Sort your globals into the three kinds.** Constant? Cheap-and-pure + prebuilt? Expensive? The kind decides the treatment; nothing else does. +2. **Assign constants and cheap prebuilt objects at module level.** Name them + in CAPS; make them immutable types (`frozenset`, `tuple`, compiled regex) + so mutation is impossible rather than discouraged. +3. **Wrap every expensive construction in `Lazy`.** + `TABLE: Lazy[dict[str, int]] = Lazy(_build_table)` — importing stays free, + the first `TABLE.get()` pays once. +4. **Give tests the reset seam.** A fixture calling `TABLE.reset()` restores + order-independence; a counter in the factory lets a test *prove* import + does no work (see the worked example's `FACTORY_RUNS`). +5. **Audit for the two misuses.** Anything mutable at module level needs a + sentence of justification; any import-time I/O is a bug, full stop. + +```python +from patterns.python.global_object import Lazy + +PRICES: Lazy[dict[str, int]] = Lazy(_load_prices) # import: free +PRICES.get()["basic"] # first use: built once +``` + +## Python idioms that keep it small + +- `frozenset`/`tuple`/`re.compile` make constants self-enforcing. +- The module *is* the singleton — resist wrapping globals in a class whose + only job is to hold them. +- A `_private` name plus a public accessor (`get_settings()`) is the escape + hatch when construction later needs parameters; `Lazy` gives you that + accessor for free. + +## Pitfalls + +- **Import-time I/O** — the cardinal sin: every importer pays, test runs + touch disk/network, and import order starts to matter. +- **The convenience mutable global.** `CACHE: dict = {}` at module level + couples every caller; if shared mutation is not the documented job, inject + the object instead. +- **Lazy globals that capture config.** If the factory reads other globals, + a test that tweaks config after first use sees stale state — reset in the + fixture, or pass config explicitly. +- **Hidden identity assumptions.** Two modules importing the name share one + object; if a consumer mutates a "constant" list, everyone sees it. Immutable + types close the hole. + +## Worked example + +[`examples/settings_module/`](../examples/settings_module/) ships one global +of each kind — constant, prebuilt regex, lazy zone table — and a test that +proves import does no work. Run it: + +```bash +uv run python -m patterns.python.global_object.examples.settings_module +``` diff --git a/patterns/python/global_object/examples/__init__.py b/patterns/python/global_object/examples/__init__.py new file mode 100644 index 0000000..10598d3 --- /dev/null +++ b/patterns/python/global_object/examples/__init__.py @@ -0,0 +1 @@ +"""Mini-projects demonstrating the Global Object pattern in practice.""" diff --git a/patterns/python/global_object/examples/settings_module/__init__.py b/patterns/python/global_object/examples/settings_module/__init__.py new file mode 100644 index 0000000..7e2989c --- /dev/null +++ b/patterns/python/global_object/examples/settings_module/__init__.py @@ -0,0 +1,24 @@ +"""A small app's settings module done right, built on the Global Object pattern. + +Run it: ``uv run python -m patterns.python.global_object.examples.settings_module`` +""" + +from patterns.python.global_object.examples.settings_module.settings import ( + RETRY_LIMIT, + SLUG, + SUPPORTED_LOCALES, + ZONE_TABLE, +) +from patterns.python.global_object.examples.settings_module.shipping import ( + is_valid_slug, + shipping_zone, +) + +__all__ = [ + "RETRY_LIMIT", + "SLUG", + "SUPPORTED_LOCALES", + "ZONE_TABLE", + "is_valid_slug", + "shipping_zone", +] diff --git a/patterns/python/global_object/examples/settings_module/__main__.py b/patterns/python/global_object/examples/settings_module/__main__.py new file mode 100644 index 0000000..ba6c051 --- /dev/null +++ b/patterns/python/global_object/examples/settings_module/__main__.py @@ -0,0 +1,22 @@ +"""Demo: the three kinds of global, and when each one pays its cost.""" + +from __future__ import annotations + +from patterns.python.global_object.examples.settings_module import settings +from patterns.python.global_object.examples.settings_module.shipping import ( + is_valid_slug, + shipping_zone, +) + + +def main() -> None: + print(f"constant: RETRY_LIMIT = {settings.RETRY_LIMIT}") + print(f"prebuilt regex: is_valid_slug('summer-sale') = {is_valid_slug('summer-sale')}") + print(f"lazy built at import? {settings.ZONE_TABLE.initialized}") + print(f"shipping_zone('FR'): {shipping_zone('FR')}") + print(f"lazy built after use? {settings.ZONE_TABLE.initialized}") + print(f"factory ran: {settings.FACTORY_RUNS} time(s)") + + +if __name__ == "__main__": + main() diff --git a/patterns/python/global_object/examples/settings_module/settings.py b/patterns/python/global_object/examples/settings_module/settings.py new file mode 100644 index 0000000..70bff71 --- /dev/null +++ b/patterns/python/global_object/examples/settings_module/settings.py @@ -0,0 +1,35 @@ +"""The application's shared globals, one of each legitimate kind. + +Constants and a cheap prebuilt object are assigned at import time; the one +expensive resource hides behind ``Lazy`` so importing this module never does +more work than defining names. ``FACTORY_RUNS`` exists so tests can *prove* +that discipline instead of trusting it. +""" + +from __future__ import annotations + +import re + +from patterns.python.global_object.pattern import Lazy + +#: The Constant Pattern: immutable, named, computed once. +RETRY_LIMIT = 3 +SUPPORTED_LOCALES = frozenset({"en", "fr", "de"}) + +#: A cheap, pure prebuilt object — a compiled regex is fine at import time. +SLUG = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*") + +#: Counts factory runs; tests assert it stays 0 until first use, then 1. +FACTORY_RUNS = 0 + + +def _build_zone_table() -> dict[str, int]: + """Stand-in for the expensive load (file parse, DNS, warehouse query).""" + global FACTORY_RUNS + FACTORY_RUNS += 1 + zones = {"CA": 1, "US": 1, "MX": 2, "FR": 3, "DE": 3} + return {country: zone for country, zone in sorted(zones.items())} + + +#: The expensive global: constructed on first ``ZONE_TABLE.get()``, never at import. +ZONE_TABLE: Lazy[dict[str, int]] = Lazy(_build_zone_table) diff --git a/patterns/python/global_object/examples/settings_module/shipping.py b/patterns/python/global_object/examples/settings_module/shipping.py new file mode 100644 index 0000000..63429f9 --- /dev/null +++ b/patterns/python/global_object/examples/settings_module/shipping.py @@ -0,0 +1,18 @@ +"""A consumer module: uses the shared globals, never rebuilds them.""" + +from __future__ import annotations + +from patterns.python.global_object.examples.settings_module import settings + + +def is_valid_slug(candidate: str) -> bool: + return settings.SLUG.fullmatch(candidate) is not None + + +def shipping_zone(country: str) -> int: + """Zone lookup pays the table's construction cost on the first call only.""" + table = settings.ZONE_TABLE.get() + try: + return table[country] + except KeyError: + raise ValueError(f"no shipping zone for {country!r}") from None diff --git a/patterns/python/global_object/naive.py b/patterns/python/global_object/naive.py deleted file mode 100644 index 579dbd2..0000000 --- a/patterns/python/global_object/naive.py +++ /dev/null @@ -1,33 +0,0 @@ -"""The two classic misuses of module globals. - -1. Hidden mutable state: every caller of ``tally`` is coupled to every other. -2. Import-time I/O (simulated): importing becomes slow, order-dependent, and - untestable. Real code that does ``open()``/network at module level fails - in exactly the ways this pretends to. -""" - -from __future__ import annotations - -# Misuse 1: a mutable global that functions quietly share. -_counts: dict[str, int] = {} - - -def tally(word: str) -> int: - """Two callers who have never met now share state through _counts.""" - _counts[word] = _counts.get(word, 0) + 1 - return _counts[word] - - -# Misuse 2: work at import time. Here it is only a computation standing in -# for the real sin (reading files, opening sockets) -- but note that it runs -# before any caller has asked for anything. -IMPORT_TIME_WORK: list[int] = [n * n for n in range(1000)] - - -def main() -> None: - print(f"tally('a') twice: {tally('a')}, {tally('a')}") - print(f"import already paid for {len(IMPORT_TIME_WORK)} squares nobody asked for") - - -if __name__ == "__main__": - main() diff --git a/patterns/python/global_object/pattern/__init__.py b/patterns/python/global_object/pattern/__init__.py new file mode 100644 index 0000000..ded7211 --- /dev/null +++ b/patterns/python/global_object/pattern/__init__.py @@ -0,0 +1,5 @@ +"""The Global Object pattern, importable as library code.""" + +from patterns.python.global_object.pattern.lazy import Lazy + +__all__ = ["Lazy"] diff --git a/patterns/python/global_object/pattern/lazy.py b/patterns/python/global_object/pattern/lazy.py new file mode 100644 index 0000000..68ce510 --- /dev/null +++ b/patterns/python/global_object/pattern/lazy.py @@ -0,0 +1,54 @@ +"""The Global Object pattern's one reusable tool: deferred construction. + +Constants and cheap prebuilt objects need no machinery — assign them at +module level and stop. What the pattern *does* need code for is the expensive +global: ``Lazy`` defers construction to first use, keeps ``import`` free of +I/O, and gives tests a reset seam. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Generic, TypeVar, cast + +T = TypeVar("T") + +_UNSET = object() # a sentinel (see python/sentinel_object): None may be a value + + +class Lazy(Generic[T]): + """A module-global built on first ``get()``, not at import time. + + Why not ``functools.cache`` on the factory? Two reasons this class earns + its ~15 lines: the ``_UNSET`` sentinel means a factory that legitimately + returns ``None`` is still cached exactly once (an ``is None`` check would + rebuild it forever), and ``reset()``/``initialized`` give tests the seam + and the proof that the import stayed cheap. + + Not thread-safe: two threads racing the first ``get()`` may both run the + factory. Fine for the import-time-globals use this pattern serves; wrap + ``get()`` in a lock if a threaded first touch is real for you. + + >>> table = Lazy(load_expensive_table) # import: nothing happens + >>> table.get() # first use: built once + >>> table.reset() # tests: order-independence back + """ + + def __init__(self, factory: Callable[[], T]) -> None: + self._factory = factory + self._value: object = _UNSET + + @property + def initialized(self) -> bool: + """Whether the factory has run — importable proof of a cheap import.""" + return self._value is not _UNSET + + def get(self) -> T: + """Return the value, constructing it on the first call only.""" + if self._value is _UNSET: + self._value = self._factory() + return cast("T", self._value) + + def reset(self) -> None: + """Discard the value so the next ``get()`` rebuilds — the test seam.""" + self._value = _UNSET diff --git a/patterns/python/global_object/pythonic.py b/patterns/python/global_object/pythonic.py deleted file mode 100644 index a030ae6..0000000 --- a/patterns/python/global_object/pythonic.py +++ /dev/null @@ -1,44 +0,0 @@ -"""The Global Object pattern done well. - -Constants, cheap deterministic import-time computation, and lazy -initialization for anything expensive. Importing this module does no I/O -and mutates nothing observable. -""" - -from __future__ import annotations - -import re - -#: The Constant Pattern: immutable, named, computed once. -MONTHS_PER_YEAR = 12 -VOWELS = frozenset("aeiou") - -#: Import-time computation is fine when it is cheap and pure: -#: a compiled regex is the guide's own example of a good global object. -IDENTIFIER = re.compile(r"[A-Za-z_][A-Za-z0-9_]*") - - -def count_vowels(text: str) -> int: - return sum(1 for ch in text if ch in VOWELS) - - -# Lazy initialization: pay for expensive construction on first use, not import. -_big_table: dict[int, int] | None = None - - -def big_table() -> dict[int, int]: - global _big_table - if _big_table is None: - _big_table = {n: n * n for n in range(10_000)} - return _big_table - - -def main() -> None: - print(f"constant: {MONTHS_PER_YEAR}") - print(f"regex global: {bool(IDENTIFIER.fullmatch('valid_name'))}") - print(f"vowels in text: {count_vowels('global object')}") - print(f"lazy table size: {len(big_table())}") - - -if __name__ == "__main__": - main() diff --git a/patterns/python/global_object/real_world.py b/patterns/python/global_object/real_world.py deleted file mode 100644 index 7293ec1..0000000 --- a/patterns/python/global_object/real_world.py +++ /dev/null @@ -1,39 +0,0 @@ -"""Global objects the stdlib ships. - -``math.pi``: the Constant Pattern. ``calendar.day_name``: an import-time -built global object. ``os.environ``: the rare mutable global whose mutation -is its documented job. -""" - -from __future__ import annotations - -import calendar -import math -import os - - -def midweek_day() -> str: - return str(calendar.day_name[2]) - - -def circle_area(radius: float) -> float: - return math.pi * radius**2 - - -def with_temp_env(key: str, value: str) -> str: - """os.environ is mutable by design; clean up what you touch.""" - os.environ[key] = value - try: - return os.environ[key] - finally: - del os.environ[key] - - -def main() -> None: - print(f"constant pattern: math.pi = {math.pi}") - print(f"global object: day_name[2] = {midweek_day()}") - print(f"mutable global: {with_temp_env('DEMO_KEY', 'demo')}") - - -if __name__ == "__main__": - main() diff --git a/patterns/python/global_object/tests/test_global_object.py b/patterns/python/global_object/tests/test_global_object.py deleted file mode 100644 index a94d31a..0000000 --- a/patterns/python/global_object/tests/test_global_object.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Behavioral tests for all three global-object variants.""" - -import math -import os - -from patterns.python.global_object import naive, pythonic, real_world - - -class TestNaive: - def test_mutable_global_couples_callers(self) -> None: - naive._counts.clear() - naive.tally("x") - # A "different caller" is affected by the first one's state: - assert naive.tally("x") == 2 - - def test_import_time_work_already_happened(self) -> None: - assert len(naive.IMPORT_TIME_WORK) == 1000 - - -class TestPythonic: - def test_constants_are_immutable_types(self) -> None: - assert isinstance(pythonic.VOWELS, frozenset) - assert pythonic.count_vowels("aeiou xyz") == 5 - - def test_compiled_regex_global(self) -> None: - assert pythonic.IDENTIFIER.fullmatch("valid_name") - assert not pythonic.IDENTIFIER.fullmatch("1bad") - - def test_lazy_table_builds_once(self) -> None: - assert pythonic.big_table() is pythonic.big_table() - assert pythonic.big_table()[99] == 9801 - - -class TestRealWorld: - def test_stdlib_globals(self) -> None: - assert real_world.midweek_day() == "Wednesday" - assert real_world.circle_area(1.0) == math.pi - - def test_environ_mutation_cleans_up(self) -> None: - assert real_world.with_temp_env("PDP_TEST_KEY", "v") == "v" - assert "PDP_TEST_KEY" not in os.environ diff --git a/patterns/python/global_object/tests/test_lazy.py b/patterns/python/global_object/tests/test_lazy.py new file mode 100644 index 0000000..e2f8a26 --- /dev/null +++ b/patterns/python/global_object/tests/test_lazy.py @@ -0,0 +1,50 @@ +"""Behavioral tests for the pattern's Lazy global.""" + +from __future__ import annotations + +from patterns.python.global_object import Lazy + + +class TestLazy: + def test_construction_does_not_run_the_factory(self) -> None: + runs: list[int] = [] + + def factory() -> str: + runs.append(1) + return "value" + + lazy = Lazy(factory) + assert runs == [] + assert lazy.initialized is False + + def test_first_get_builds_exactly_once(self) -> None: + runs: list[int] = [] + + def factory() -> str: + runs.append(1) + return "value" + + lazy = Lazy(factory) + assert lazy.get() == "value" + assert lazy.get() == "value" + assert runs == [1] + assert lazy.initialized is True + + def test_reset_forces_a_rebuild(self) -> None: + counter = iter(range(100)) + lazy = Lazy(lambda: next(counter)) + assert lazy.get() == 0 + lazy.reset() + assert lazy.initialized is False + assert lazy.get() == 1 + + def test_none_is_a_legitimate_lazy_value(self) -> None: + runs: list[int] = [] + + def factory() -> None: + runs.append(1) + + lazy: Lazy[None] = Lazy(factory) + assert lazy.get() is None + assert lazy.get() is None + assert runs == [1] # a stored None is not mistaken for "unbuilt" diff --git a/patterns/python/global_object/tests/test_settings_module.py b/patterns/python/global_object/tests/test_settings_module.py new file mode 100644 index 0000000..8eb64b6 --- /dev/null +++ b/patterns/python/global_object/tests/test_settings_module.py @@ -0,0 +1,90 @@ +"""Behavioral tests for the settings-module mini-project. + +The load-bearing assertion: importing the settings module does no expensive +work — the zone table's factory runs zero times until first use, once ever. +""" + +from __future__ import annotations + +import subprocess +import sys +from collections.abc import Iterator +from pathlib import Path + +import pytest + +from patterns.python.global_object.examples.settings_module import settings +from patterns.python.global_object.examples.settings_module.__main__ import main +from patterns.python.global_object.examples.settings_module.shipping import ( + is_valid_slug, + shipping_zone, +) + + +@pytest.fixture(autouse=True) +def fresh_lazy_state() -> Iterator[None]: + """The pattern's test seam: order-independence via reset().""" + settings.ZONE_TABLE.reset() + settings.FACTORY_RUNS = 0 + yield + settings.ZONE_TABLE.reset() + settings.FACTORY_RUNS = 0 + + +REPO_ROOT = Path(__file__).resolve().parents[4] + + +class TestImportStaysCheap: + def test_import_did_not_build_the_expensive_table(self) -> None: + # A fresh subprocess is the only honest witness: in-process, this + # module is already imported and the autouse reset fixture would have + # erased the evidence either way. Here nothing can reset before the + # assertion reads the counter. + probe = ( + "from patterns.python.global_object.examples.settings_module import settings; " + "assert settings.FACTORY_RUNS == 0, settings.FACTORY_RUNS; " + "assert settings.ZONE_TABLE.initialized is False" + ) + result = subprocess.run( + [sys.executable, "-c", probe], + cwd=REPO_ROOT, + capture_output=True, + text=True, + timeout=30, + check=False, + ) + assert result.returncode == 0, result.stderr + + def test_first_use_builds_once_and_only_once(self) -> None: + assert shipping_zone("CA") == 1 + assert shipping_zone("DE") == 3 + assert settings.FACTORY_RUNS == 1 + + +class TestSettingsBehavior: + def test_constants_are_immutable_types(self) -> None: + assert isinstance(settings.SUPPORTED_LOCALES, frozenset) + assert settings.RETRY_LIMIT == 3 + + def test_prebuilt_regex_validates_slugs(self) -> None: + assert is_valid_slug("summer-sale") + assert not is_valid_slug("Summer Sale!") + # Fails past position 0: fullmatch must reject what match would accept. + assert not is_valid_slug("summer sale!") + + def test_unknown_country_is_a_domain_error(self) -> None: + with pytest.raises(ValueError, match="no shipping zone"): + shipping_zone("ZZ") + + def test_consumers_share_one_table_instance(self) -> None: + assert settings.ZONE_TABLE.get() is settings.ZONE_TABLE.get() + + +class TestDemo: + def test_demo_tells_the_lazy_story(self, capsys: pytest.CaptureFixture[str]) -> None: + main() + out = capsys.readouterr().out + assert "lazy built at import? False" in out + assert "shipping_zone('FR'): 3" in out + assert "lazy built after use? True" in out + assert "factory ran: 1 time(s)" in out diff --git a/patterns/python/prebound_method/README.md b/patterns/python/prebound_method/README.md index e007341..adb1060 100644 --- a/patterns/python/prebound_method/README.md +++ b/patterns/python/prebound_method/README.md @@ -9,36 +9,22 @@ verdict: pythonic caveats: - "Build the hidden instance cheaply and without I/O — it is constructed at import time." - "Keep the class public too, so users needing isolated state can instantiate their own (exactly as random.Random allows)." -stdlib_sightings: [random.random, random.seed, secrets.token_hex] +stdlib_sightings: [random.random, random.seed, secrets.choice] --- # Prebound Method -## Problem +A `random.random()`-style module API: one hidden instance built at import, +its bound methods published as module functions, the class kept public for +isolation. **Verdict: pythonic** — the stdlib's own favorite move. -You want the ergonomic module-level API — `random.random()`, not -`random.get_default_generator().random()` — but the functions must share -state (a seed, a counter, a connection). +| Where | What | +|---|---| +| [`pattern/`](pattern/) | The importable code: canonical `Counter` + prebound `increment`/`peek`, and `shares_instance` (proves the wiring) | +| [`docs/`](docs/) | [Fundamentals](docs/fundamentals.md) · [Implementation guide](docs/implementation.md) · [External examples](docs/examples.md) | +| [`examples/metrics/`](examples/metrics/) | Mini-project: process-wide metrics API prebound from a hidden collector | +| [`tests/`](tests/) | Behavioral tests for the pattern and the mini-project | -## Naive solution - -`naive.py` shows the alternatives the guide rejects: bare module functions -mutating a loose module global (state and behavior drift apart), or making -every caller instantiate the class themselves (ergonomics lost). - -## Pythonic solution - -Define a normal class, build **one instance** at module level, then assign -its bound methods to module-global names: `roll = _instance.roll`. Callers -get plain functions; the instance travels along inside each bound method. - -## In the wild - -`random.random`, `random.seed`, and friends are exactly this — bound methods -of a hidden `random.Random()` built at import; `random.Random` stays public -for anyone needing isolated streams. - -## Verdict - -**Pythonic.** The stdlib's own favorite way to put a friendly face on shared -state. +```bash +uv run python -m patterns.python.prebound_method.examples.metrics +``` diff --git a/patterns/python/prebound_method/__init__.py b/patterns/python/prebound_method/__init__.py index d8c039d..d677cc8 100644 --- a/patterns/python/prebound_method/__init__.py +++ b/patterns/python/prebound_method/__init__.py @@ -1 +1,13 @@ -"""Prebound Method: module functions that are bound methods of one hidden instance.""" +"""Prebound Method — public API. + +>>> from patterns.python.prebound_method import increment, peek +""" + +from patterns.python.prebound_method.pattern import ( + Counter, + increment, + peek, + shares_instance, +) + +__all__ = ["Counter", "increment", "peek", "shares_instance"] diff --git a/patterns/python/prebound_method/docs/examples.md b/patterns/python/prebound_method/docs/examples.md new file mode 100644 index 0000000..fed2a8a --- /dev/null +++ b/patterns/python/prebound_method/docs/examples.md @@ -0,0 +1,34 @@ +# Prebound Method — where it lives outside this repo + +Cited, real implementations to study (or point an agent at) when designing or +reviewing module-API code. + +## Python standard library + +- **`random`.** The flagship: `random.random`, `random.seed`, and friends are + bound methods of one hidden `Random()` built at import, and `random.Random` + stays public for isolated streams. + [docs.python.org/3/library/random.html](https://docs.python.org/3/library/random.html) · + [source](https://github.com/python/cpython/blob/main/Lib/random.py) +- **`secrets`.** `choice` and `randbits` are prebound from a module-level + `SystemRandom` instance — the same wiring with a different engine. (The + useful contrast: `token_hex`/`token_bytes` are plain module functions that + merely *call* it — they have no `__self__` and would fail this unit's own + `shares_instance()` check.) + [docs.python.org/3/library/secrets.html](https://docs.python.org/3/library/secrets.html) +- **`calendar`.** Module functions like `calendar.month` delegate to a + module-level `TextCalendar` instance. + [source](https://github.com/python/cpython/blob/main/Lib/calendar.py) + +## The chapter + +- The naming conventions, the import-time-cost warning, and the + keep-the-class-public rule this unit encodes: + [python-patterns.guide/python/prebound-methods](https://python-patterns.guide/python/prebound-methods/) + +## What to notice across all of them + +Every stdlib example keeps **both doors open**: the convenient module +functions for the common case, the public class for isolation. When reviewing +a module-level API over shared state, check for the second door — its absence +is tomorrow's monkeypatch. diff --git a/patterns/python/prebound_method/docs/fundamentals.md b/patterns/python/prebound_method/docs/fundamentals.md new file mode 100644 index 0000000..354b46c --- /dev/null +++ b/patterns/python/prebound_method/docs/fundamentals.md @@ -0,0 +1,75 @@ +# Prebound Method — fundamentals + +## Intent + +Offer module-level functions that share state, by building **one instance** at +module import and binding its methods to module-global names. Callers get the +ergonomic API — `random.random()`, not +`random.get_default_generator().random()` — while the shared state rides along +inside each bound method. Source chapter: +[python-patterns.guide/python/prebound-methods](https://python-patterns.guide/python/prebound-methods/). + +## Participants + +| Role | What it is | +|---|---| +| The public class | An ordinary class holding the shared state (`Counter`, `random.Random`) — public so isolation stays possible | +| The hidden instance | One module-private instance, built at import (`_instance = Counter()`) | +| The prebound names | Module globals assigned from bound methods (`increment = _instance.increment`) | +| Callers | Import and call plain functions, never seeing the instance | + +## Mechanism + +1. Write the class as if the pattern did not exist: state in `__init__`, + behavior in methods, fully testable in isolation. +2. At module level, build one instance — cheaply and without I/O, because + this line runs at import time. +3. Assign the instance's bound methods to module-global names. A bound method + carries its `__self__`, so every call reaches the same state. +4. Leave the class public. Anyone needing an isolated copy instantiates it — + exactly as `random.Random` stays available beside `random.random`. + +## The alternatives it replaces + +The two shapes the guide rejects, side by side: + +```python +# Alternative A: bare functions over a loose module global — the state and +# the functions guarding it drift apart, and a second counter later means +# rewriting every caller. +_count = 0 + + +def increment() -> int: + global _count + _count += 1 + return _count + + +# Alternative B: no module API at all — every caller, everywhere, forever: +counter = Counter() +counter.increment() +``` + +The prebound form keeps A's ergonomics and B's design: the state lives in a +real class; only the *default instance* is module-level. Migrating to +per-caller instances later is a class already shipped, not a rewrite. + +## When to use it + +- A module-level convenience API over genuinely shared state: metrics, + default RNG, a default registry, a process-wide clock. +- You are tempted by Alternative A — this is the same ergonomics without + orphaned state. + +## When not to use it + +- Construction is expensive or does I/O → it would run at import; use a lazy + accessor instead (see `python/global_object`). +- The state should not be shared by default (per-request, per-tenant) → + instantiate explicitly or inject (see `modern/dependency_injection`). + +## Verdict: pythonic + +The stdlib's own favorite way to put a friendly face on shared state — +`random`, `secrets`, and `calendar` all ship it. diff --git a/patterns/python/prebound_method/docs/implementation.md b/patterns/python/prebound_method/docs/implementation.md new file mode 100644 index 0000000..3f296f8 --- /dev/null +++ b/patterns/python/prebound_method/docs/implementation.md @@ -0,0 +1,69 @@ +# Prebound Method — putting it into a system + +## The smell it fixes + +Module functions guarding a loose global, or a "helper instance" every caller +must construct and thread: + +```python +_registry: dict[str, Handler] = {} # state ... + + +def register(name, handler): ... # ... and its functions, drifting apart +``` + +## Steps + +1. **Write the class first.** State in `__init__`, behavior in methods, its + own unit tests. If the class is not worth testing alone, the pattern is + overkill — keep plain functions. +2. **Build one module-private instance** (`_collector = MetricsCollector()`). + The construction runs at import: it must be cheap, pure, and I/O-free. +3. **Prebind the public surface**, one line per name: + + ```python + increment = _collector.increment + timing = _collector.timing + snapshot = _collector.snapshot + ``` + + Bind only what callers need — the instance's full surface stays behind + the underscore. +4. **Keep the class exported.** The isolation escape hatch is half the + pattern; tests and libraries build their own instance instead of fighting + the shared one. +5. **Give tests a seam.** Either prebind a `reset()` (as the metrics example + does) or have fixtures instantiate a fresh class — never let tests depend + on the shared instance's accumulated state. + +## Python idioms that keep it small + +- A bound method is just an object: assignment is the whole mechanism, no + wrapper functions, no `functools`. +- `shares_instance(f, g)` (in this unit's `pattern/`) proves the wiring in a + test: every prebound name carries the same `__self__`. +- Docstring the *module*, not each prebound name — the class's docstrings + already travel with the bound methods. + +## Pitfalls + +- **Import-time construction that grows up.** The instance starts cheap; a + refactor adds a config read and suddenly every import does I/O. Guard it + with a test, or switch to `Lazy` from `python/global_object`. +- **Hiding the class.** Making `Counter` private forces monkeypatching where + instantiation would have done — the escape hatch is load-bearing. +- **Shared state leaking across tests.** The prebound API is process-global + by design; tests that use it must reset it (or use their own instance). +- **Prebinding mutable attributes** instead of methods — attribute access + copies the reference once; later rebinding on the instance is invisible to + importers. + +## Worked example + +[`examples/metrics/`](../examples/metrics/) ships a process-wide metrics API +(`increment`/`timing`/`snapshot`/`reset`) prebound from a hidden +`MetricsCollector`, with the class public for isolated collectors. Run it: + +```bash +uv run python -m patterns.python.prebound_method.examples.metrics +``` diff --git a/patterns/python/prebound_method/examples/__init__.py b/patterns/python/prebound_method/examples/__init__.py new file mode 100644 index 0000000..01f4e23 --- /dev/null +++ b/patterns/python/prebound_method/examples/__init__.py @@ -0,0 +1 @@ +"""Mini-projects demonstrating the Prebound Method pattern in practice.""" diff --git a/patterns/python/prebound_method/examples/metrics/__init__.py b/patterns/python/prebound_method/examples/metrics/__init__.py new file mode 100644 index 0000000..affa698 --- /dev/null +++ b/patterns/python/prebound_method/examples/metrics/__init__.py @@ -0,0 +1,14 @@ +"""Process-wide metrics built on the Prebound Method pattern. + +Run it: ``uv run python -m patterns.python.prebound_method.examples.metrics`` +""" + +from patterns.python.prebound_method.examples.metrics.api import ( + increment, + reset, + snapshot, + timing, +) +from patterns.python.prebound_method.examples.metrics.collector import MetricsCollector + +__all__ = ["MetricsCollector", "increment", "reset", "snapshot", "timing"] diff --git a/patterns/python/prebound_method/examples/metrics/__main__.py b/patterns/python/prebound_method/examples/metrics/__main__.py new file mode 100644 index 0000000..5eb323a --- /dev/null +++ b/patterns/python/prebound_method/examples/metrics/__main__.py @@ -0,0 +1,28 @@ +"""Demo: modules that never met, reporting into one hidden collector.""" + +from __future__ import annotations + +from patterns.python.prebound_method.examples.metrics import api +from patterns.python.prebound_method.pattern import shares_instance + + +def take_order(order_id: str) -> None: + api.increment("orders") + api.timing("checkout_ms", 12.5 if order_id.endswith("2") else 8.0) + + +def failed_payment() -> None: + api.increment("payment_errors") + + +def main() -> None: + api.reset() + for order_id in ("o-1", "o-2", "o-3"): + take_order(order_id) + failed_payment() + print(f"one hidden instance: {shares_instance(api.increment, api.timing, api.snapshot)}") + print(f"snapshot: {api.snapshot()}") + + +if __name__ == "__main__": + main() diff --git a/patterns/python/prebound_method/examples/metrics/api.py b/patterns/python/prebound_method/examples/metrics/api.py new file mode 100644 index 0000000..1979c32 --- /dev/null +++ b/patterns/python/prebound_method/examples/metrics/api.py @@ -0,0 +1,19 @@ +"""The pattern applied: a ``random``-style module API over shared state. + +One hidden collector is built at import time (cheap: two empty dicts); its +bound methods become the module's public functions. Callers write +``metrics.increment("orders")`` — no instance in sight — while the instance +rides along inside each bound method. +""" + +from __future__ import annotations + +from patterns.python.prebound_method.examples.metrics.collector import MetricsCollector + +_collector = MetricsCollector() + +#: The pattern: module-level names bound to the hidden instance's methods. +increment = _collector.increment +timing = _collector.timing +snapshot = _collector.snapshot +reset = _collector.reset diff --git a/patterns/python/prebound_method/examples/metrics/collector.py b/patterns/python/prebound_method/examples/metrics/collector.py new file mode 100644 index 0000000..3337957 --- /dev/null +++ b/patterns/python/prebound_method/examples/metrics/collector.py @@ -0,0 +1,36 @@ +"""The public class behind the metrics module's friendly face. + +Stays public on purpose: libraries and tests build their own isolated +collector, exactly as ``random.Random`` stays public beside ``random.random``. +""" + +from __future__ import annotations + + +class MetricsCollector: + """Counts and timings for one scope (the process, or one test).""" + + def __init__(self) -> None: + self._counts: dict[str, int] = {} + self._timings: dict[str, list[float]] = {} + + def increment(self, name: str, by: int = 1) -> int: + """Bump a counter; returns its new value.""" + self._counts[name] = self._counts.get(name, 0) + by + return self._counts[name] + + def timing(self, name: str, milliseconds: float) -> None: + """Record one duration observation.""" + self._timings.setdefault(name, []).append(milliseconds) + + def snapshot(self) -> dict[str, object]: + """An immutable-ish view: counters plus per-name timing averages.""" + averages = { + name: sum(values) / len(values) for name, values in self._timings.items() if values + } + return {"counts": dict(self._counts), "timing_avg_ms": averages} + + def reset(self) -> None: + """Forget everything — the seam tests use between cases.""" + self._counts.clear() + self._timings.clear() diff --git a/patterns/python/prebound_method/naive.py b/patterns/python/prebound_method/naive.py deleted file mode 100644 index 7ec58e4..0000000 --- a/patterns/python/prebound_method/naive.py +++ /dev/null @@ -1,39 +0,0 @@ -"""The alternatives the guide rejects. - -Option A: bare functions over a loose module global -- state and the -functions that guard it are separated, and moving to two independent -counters later means rewriting every caller. - -Option B: no module-level API at all -- every caller instantiates. -""" - -from __future__ import annotations - -# Option A: the state is just ... lying there. -_count = 0 - - -def increment() -> int: - global _count - _count += 1 - return _count - - -# Option B: callers must build and thread their own instance. -class Counter: - def __init__(self) -> None: - self.count = 0 - - def increment(self) -> int: - self.count += 1 - return self.count - - -def main() -> None: - print(f"loose global: {increment()}, {increment()}") - counter = Counter() # every caller, everywhere, forever - print(f"DIY instance: {counter.increment()}") - - -if __name__ == "__main__": - main() diff --git a/patterns/python/prebound_method/pattern/__init__.py b/patterns/python/prebound_method/pattern/__init__.py new file mode 100644 index 0000000..7b322a2 --- /dev/null +++ b/patterns/python/prebound_method/pattern/__init__.py @@ -0,0 +1,10 @@ +"""The Prebound Method pattern, importable as library code.""" + +from patterns.python.prebound_method.pattern.prebound import ( + Counter, + increment, + peek, + shares_instance, +) + +__all__ = ["Counter", "increment", "peek", "shares_instance"] diff --git a/patterns/python/prebound_method/pattern/prebound.py b/patterns/python/prebound_method/pattern/prebound.py new file mode 100644 index 0000000..fb9f3fe --- /dev/null +++ b/patterns/python/prebound_method/pattern/prebound.py @@ -0,0 +1,43 @@ +"""The Prebound Method pattern as importable, typed building blocks. + +The pattern itself is one line — ``name = _instance.method`` at module level — +so the library code here is the canonical minimal instance plus the one +verification helper worth sharing: ``shares_instance`` proves that a set of +module functions really are bound methods of a single hidden object (the +check ``random.random`` and ``random.seed`` would pass). +""" + +from __future__ import annotations + +from collections.abc import Callable + + +class Counter: + """The canonical shape: an ordinary class, instantiable for isolation.""" + + def __init__(self) -> None: + self.count = 0 + + def increment(self) -> int: + self.count += 1 + return self.count + + def peek(self) -> int: + return self.count + + +#: The pattern, applied to itself: one hidden instance built at import +#: (cheaply, no I/O), its bound methods published as module functions. +_instance = Counter() +increment = _instance.increment +peek = _instance.peek + + +def shares_instance(*functions: Callable[..., object]) -> bool: + """True if every function is a bound method of one identical instance. + + The introspective proof of the pattern: ``shares_instance(random.random, + random.seed)`` holds because both carry the same ``__self__``. + """ + owners = [getattr(function, "__self__", None) for function in functions] + return len(owners) > 0 and owners[0] is not None and all(o is owners[0] for o in owners) diff --git a/patterns/python/prebound_method/pythonic.py b/patterns/python/prebound_method/pythonic.py deleted file mode 100644 index c99ea67..0000000 --- a/patterns/python/prebound_method/pythonic.py +++ /dev/null @@ -1,38 +0,0 @@ -"""The Prebound Method pattern. - -One hidden instance built at import time; its bound methods become the -module's public functions. The class stays public for isolated state. -""" - -from __future__ import annotations - - -class Counter: - """An ordinary class; instantiable by anyone needing isolation.""" - - def __init__(self) -> None: - self.count = 0 - - def increment(self) -> int: - self.count += 1 - return self.count - - def peek(self) -> int: - return self.count - - -_instance = Counter() - -#: The pattern: module-level names bound to one instance's methods. -increment = _instance.increment -peek = _instance.peek - - -def main() -> None: - print(f"module API: {increment()}, {increment()}, peek={peek()}") - isolated = Counter() - print(f"isolated instance unaffected: {isolated.peek()}") - - -if __name__ == "__main__": - main() diff --git a/patterns/python/prebound_method/real_world.py b/patterns/python/prebound_method/real_world.py deleted file mode 100644 index e6aef94..0000000 --- a/patterns/python/prebound_method/real_world.py +++ /dev/null @@ -1,31 +0,0 @@ -"""``random``: the stdlib's flagship prebound methods. - -``random.random`` and ``random.seed`` are bound methods of one hidden -``Random`` instance built when the module is imported. -""" - -from __future__ import annotations - -import random - - -def module_functions_share_one_instance() -> bool: - """Both prebound methods carry the same __self__.""" - a = getattr(random.random, "__self__", None) - b = getattr(random.seed, "__self__", None) - return a is not None and a is b and isinstance(a, random.Random) - - -def seeded_sequence(seed: int, n: int) -> list[float]: - """Seeding through one prebound method changes what the other returns.""" - random.seed(seed) - return [random.random() for _ in range(n)] - - -def main() -> None: - print(f"one hidden instance: {module_functions_share_one_instance()}") - print(f"reproducible: {seeded_sequence(42, 2) == seeded_sequence(42, 2)}") - - -if __name__ == "__main__": - main() diff --git a/patterns/python/prebound_method/tests/test_metrics.py b/patterns/python/prebound_method/tests/test_metrics.py new file mode 100644 index 0000000..1ac92bd --- /dev/null +++ b/patterns/python/prebound_method/tests/test_metrics.py @@ -0,0 +1,56 @@ +"""Behavioral tests for the metrics mini-project.""" + +from __future__ import annotations + +from collections.abc import Iterator + +import pytest + +from patterns.python.prebound_method.examples.metrics import MetricsCollector, api +from patterns.python.prebound_method.examples.metrics.__main__ import main +from patterns.python.prebound_method.pattern import shares_instance + + +@pytest.fixture(autouse=True) +def clean_shared_collector() -> Iterator[None]: + api.reset() + yield + api.reset() + + +class TestModuleApi: + def test_all_prebound_names_share_the_hidden_collector(self) -> None: + assert shares_instance(api.increment, api.timing, api.snapshot, api.reset) + + def test_modules_that_never_met_report_into_one_place(self) -> None: + api.increment("orders") + api.increment("orders", by=2) + api.timing("checkout_ms", 10.0) + api.timing("checkout_ms", 20.0) + snap = api.snapshot() + assert snap["counts"] == {"orders": 3} + assert snap["timing_avg_ms"] == {"checkout_ms": 15.0} + + def test_reset_is_the_test_seam(self) -> None: + api.increment("orders") + api.reset() + assert api.snapshot() == {"counts": {}, "timing_avg_ms": {}} + + +class TestIsolation: + def test_an_isolated_collector_leaves_the_shared_one_alone(self) -> None: + own = MetricsCollector() + own.increment("private") + assert api.snapshot() == {"counts": {}, "timing_avg_ms": {}} + assert own.snapshot()["counts"] == {"private": 1} + + +class TestDemo: + def test_main_reports_orders_and_the_shared_instance( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + main() + out = capsys.readouterr().out + assert "one hidden instance: True" in out + assert "'orders': 3" in out + assert "'payment_errors': 1" in out diff --git a/patterns/python/prebound_method/tests/test_prebound.py b/patterns/python/prebound_method/tests/test_prebound.py new file mode 100644 index 0000000..2204b81 --- /dev/null +++ b/patterns/python/prebound_method/tests/test_prebound.py @@ -0,0 +1,41 @@ +"""Behavioral tests for the pattern's canonical prebound Counter.""" + +from __future__ import annotations + +import random + +from patterns.python.prebound_method import Counter, increment, peek, shares_instance + + +class TestPreboundCounter: + def test_module_functions_share_one_instance(self) -> None: + assert shares_instance(increment, peek) + + def test_calls_through_either_name_hit_the_same_state(self) -> None: + before = peek() + value = increment() + assert value == before + 1 + assert peek() == value + + def test_isolated_instances_do_not_touch_the_shared_one(self) -> None: + shared_before = peek() + isolated = Counter() + assert isolated.increment() == 1 + assert peek() == shared_before + + +class TestSharesInstance: + def test_the_stdlib_flagship_passes(self) -> None: + assert shares_instance(random.random, random.seed) + + def test_plain_functions_fail(self) -> None: + def f() -> None: ... + def g() -> None: ... + + assert not shares_instance(f, g) + + def test_methods_of_different_instances_fail(self) -> None: + assert not shares_instance(Counter().increment, Counter().increment) + + def test_empty_call_is_not_a_vacuous_pass(self) -> None: + assert not shares_instance() diff --git a/patterns/python/prebound_method/tests/test_prebound_method.py b/patterns/python/prebound_method/tests/test_prebound_method.py deleted file mode 100644 index 9f87f68..0000000 --- a/patterns/python/prebound_method/tests/test_prebound_method.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Behavioral tests for all three prebound-method variants.""" - -from typing import Any - -from patterns.python.prebound_method import naive, pythonic, real_world - - -class TestNaive: - def test_loose_global_counts(self) -> None: - start = naive.increment() - assert naive.increment() == start + 1 - - def test_diy_instances_are_isolated(self) -> None: - a, b = naive.Counter(), naive.Counter() - a.increment() - assert b.count == 0 - - -class TestPythonic: - def test_module_functions_share_the_hidden_instance(self) -> None: - before = pythonic.peek() - pythonic.increment() - assert pythonic.peek() == before + 1 - - def test_functions_are_bound_methods_of_one_instance(self) -> None: - increment: Any = pythonic.increment - peek: Any = pythonic.peek - assert increment.__self__ is peek.__self__ - - def test_public_class_gives_isolation(self) -> None: - isolated = pythonic.Counter() - pythonic.increment() - assert isolated.peek() == 0 - - -class TestRealWorld: - def test_random_module_is_prebound(self) -> None: - assert real_world.module_functions_share_one_instance() - - def test_seeding_is_shared_state(self) -> None: - assert real_world.seeded_sequence(7, 3) == real_world.seeded_sequence(7, 3) diff --git a/patterns/python/sentinel_object/README.md b/patterns/python/sentinel_object/README.md index 6c377b2..e7d42be 100644 --- a/patterns/python/sentinel_object/README.md +++ b/patterns/python/sentinel_object/README.md @@ -1,7 +1,7 @@ --- id: python/sentinel_object name: Sentinel Object -aliases: [sentinel, missing-marker, null-object] +aliases: [sentinel, missing-marker] guide_url: https://python-patterns.guide/python/sentinel-object/ problem: "Mark 'no value here' unambiguously when None itself is a legitimate value." symptoms: ["None is a valid value", "distinguish missing from null", "default argument that could be None", "str.find returns -1"] @@ -10,36 +10,22 @@ caveats: - "A sentinel must be compared with `is`, never `==` — its identity is its meaning." - "Sentinel *values* like -1 (str.find) live inside the value's own type and eventually collide; a fresh object() cannot." - "Fowler's Null Object pattern — a do-nothing stand-in with real methods — is the neighboring cure when callers would otherwise be littered with None checks." -stdlib_sightings: [dataclasses.MISSING, iter(callable, sentinel), str.find] +stdlib_sightings: [dataclasses.MISSING, iter(callable, sentinel)] --- # Sentinel Object -## Problem +Mark "no value here" with an unforgeable object, so a legitimate `None` and +a genuine absence stop colliding. **Verdict: pythonic** — one named marker +per meaning, compared with `is`. -A cache stores `None` as a legitimate value; a keyword argument treats `None` -as meaningful. Now "the value is None" and "there is no value" collide, and -`get(...) or default` bugs follow. +| Where | What | +|---|---| +| [`pattern/`](pattern/) | The importable code: `Sentinel` (named marker) and `MISSING` | +| [`docs/`](docs/) | [Fundamentals](docs/fundamentals.md) · [Implementation guide](docs/implementation.md) · [External examples](docs/examples.md) | +| [`examples/layered_config/`](examples/layered_config/) | Mini-project: CLI ← file ← defaults config where `None` means "explicitly disabled", plus a `NullNotifier` | +| [`tests/`](tests/) | Behavioral tests for the pattern and the mini-project | -## Naive solution - -`naive.py` shows both classic failures: the in-band sentinel *value* -(`str.find`-style `-1` that arithmetic happily consumes), and `None`-as-missing -in a cache that stores `None`. - -## Pythonic solution - -A fresh `_MISSING = object()` is unforgeable: it lives in no domain, equals -nothing but itself, and is checked by identity. `pythonic.py` uses it for a -cache and a default argument, and includes a small Null Object — a real -do-nothing logger — for the case where callers shouldn't branch at all. - -## In the wild - -`dataclasses.MISSING` distinguishes "no default" from "default is None"; -two-argument `iter(read, b"")` takes an explicit sentinel that terminates -iteration; `str.find`'s `-1` survives as a cautionary in-band sentinel value. - -## Verdict - -**Pythonic.** One module-private `object()` per meaning, compared with `is`. +```bash +uv run python -m patterns.python.sentinel_object.examples.layered_config +``` diff --git a/patterns/python/sentinel_object/__init__.py b/patterns/python/sentinel_object/__init__.py index 965bf2f..9f4e79f 100644 --- a/patterns/python/sentinel_object/__init__.py +++ b/patterns/python/sentinel_object/__init__.py @@ -1 +1,8 @@ -"""Sentinel Object: an unforgeable marker for missing, when None is a real value.""" +"""Sentinel Object — public API. + +>>> from patterns.python.sentinel_object import MISSING, Sentinel +""" + +from patterns.python.sentinel_object.pattern import MISSING, Sentinel + +__all__ = ["MISSING", "Sentinel"] diff --git a/patterns/python/sentinel_object/docs/examples.md b/patterns/python/sentinel_object/docs/examples.md new file mode 100644 index 0000000..78d480e --- /dev/null +++ b/patterns/python/sentinel_object/docs/examples.md @@ -0,0 +1,36 @@ +# Sentinel Object — where it lives outside this repo + +Cited, real implementations to study (or point an agent at) when designing or +reviewing absence-handling code. + +## Python standard library + +- **`dataclasses.MISSING`.** The stdlib's public missing-marker: it lets + introspection distinguish "no default" from "default is None". + [docs.python.org/3/library/dataclasses.html](https://docs.python.org/3/library/dataclasses.html) +- **`inspect.Parameter.empty`.** The sentinel for "no default / no + annotation" in signature introspection. + [docs.python.org/3/library/inspect.html](https://docs.python.org/3/library/inspect.html) +- **`unittest.mock.sentinel` and `mock.DEFAULT`.** Named sentinels offered + *as* API — mint-a-marker as a service. + [docs.python.org/3/library/unittest.mock.html](https://docs.python.org/3/library/unittest.mock.html) +- **Two-argument `iter(callable, sentinel)`.** A sentinel baked into a + builtin's signature: iteration stops when the sentinel appears. + [docs.python.org/3/library/functions.html#iter](https://docs.python.org/3/library/functions.html#iter) +- **`str.find`'s `-1`.** The cautionary in-band sentinel *value* — legal + integer, silently consumable by arithmetic. + +## Language evolution + +- **PEP 661 — Sentinel Values.** The problem is real enough to have a PEP: + naming, repr, and copy/pickle semantics for sentinels. + [peps.python.org/pep-0661](https://peps.python.org/pep-0661/) +- **The guide chapter** — sentinel values vs the sentinel object vs the Null + Object, with history. + [python-patterns.guide/python/sentinel-object](https://python-patterns.guide/python/sentinel-object/) + +## What to notice across all of them + +The healthy examples are all **out-of-band** (a fresh object no domain can +produce) and **named** (debuggable). The stdlib's one in-band survivor, +`str.find`, is the pattern's standing warning label. diff --git a/patterns/python/sentinel_object/docs/fundamentals.md b/patterns/python/sentinel_object/docs/fundamentals.md new file mode 100644 index 0000000..9a69fb3 --- /dev/null +++ b/patterns/python/sentinel_object/docs/fundamentals.md @@ -0,0 +1,68 @@ +# Sentinel Object — fundamentals + +## Intent + +Mark "no value here" unambiguously when `None` itself is a legitimate value. +A fresh object exists in no domain: it equals nothing but itself, cannot be +forged, and is checked by identity — so "the value is None" and "there is no +value" stop colliding. Source chapter: +[python-patterns.guide/python/sentinel-object](https://python-patterns.guide/python/sentinel-object/). + +## Participants + +| Role | What it is | +|---|---| +| The sentinel | One module-level marker — `Sentinel`/`MISSING` in [`pattern/sentinel.py`](../pattern/sentinel.py) | +| The APIs | Functions/containers that accept or return it in place of "absent" | +| The identity check | `value is MISSING` — identity *is* the meaning | +| The Null Object | The neighboring cure (Fowler/Woolf): a do-nothing stand-in with real methods, for when callers should not branch at all | + +## Mechanism + +1. Create one marker per distinct meaning, module-level, named: + `MISSING = Sentinel("MISSING")`. +2. Use it wherever "absent" must be distinguishable from every legal value — + dict lookups (`d.get(k, MISSING)`), default arguments, cache slots. +3. Check with `is`, never `==` — equality can be overloaded; identity cannot. +4. Where absence would make every caller branch, upgrade to a Null Object: an + implementation of the real interface that intentionally does nothing. + +## The failure modes it replaces + +```python +# Failure 1: the in-band sentinel VALUE. str.find's -1 is a legal integer, +# so forgetting the check produces plausible garbage, not an error: +position = text.find(needle) # -1 when absent ... +return text[position - 1] # ... silently becomes text[-2] + +# Failure 2: None-as-missing where None is storable. A cache holding a +# legitimate None cannot tell a hit from a miss: +value = self._data.get(key) +if value is None: # ... but None might BE the cached value! + value = compute() +``` + +An in-band sentinel lives inside the value's own type and eventually +collides; `None` is just the most common in-band sentinel of all. A fresh +`object()` closes both holes — which is why the problem earned a PEP +([PEP 661](https://peps.python.org/pep-0661/)). + +## When to use it + +- `None` (or `-1`, or `""`) is a legitimate stored/passed value and "not + provided" must remain distinct. +- Default arguments where "caller passed None" and "caller passed nothing" + behave differently. + +## When not to use it + +- `None` genuinely means absent and nothing stores it → `None` is simpler + and idiomatic; do not invent markers for their own sake. +- Callers branch on the sentinel everywhere → that is the Null Object's job; + hand back a do-nothing implementation instead. + +## Verdict: pythonic + +One module-private marker per meaning, compared with `is`. The stdlib ships +it as `dataclasses.MISSING`, `inspect.Parameter.empty`, and two-argument +`iter`. diff --git a/patterns/python/sentinel_object/docs/implementation.md b/patterns/python/sentinel_object/docs/implementation.md new file mode 100644 index 0000000..6a6d5d4 --- /dev/null +++ b/patterns/python/sentinel_object/docs/implementation.md @@ -0,0 +1,77 @@ +# Sentinel Object — putting it into a system + +## The smell it fixes + +`get(...) or default` bugs, and absence checks that quietly eat legal values: + +```python +timeout = config.get("timeout") or 30 # a configured 0 becomes 30 +if cached is None: # a cached None recomputes forever + cached = expensive() +``` + +## Steps + +1. **Find the collision.** Which legal value is doubling as "absent"? + (`None`, `0`, `""`, `-1` are the usual suspects.) +2. **Mint one named sentinel per meaning** — `MISSING = Sentinel("MISSING")` + from this unit's `pattern/`, or a bare `_MISSING = object()` when a repr + does not matter. One marker per *meaning*, not per call site. +3. **Thread it through the boundary**: `layer.get(key, MISSING)` inside, + a clean `Value` type outside. The sentinel should not leak into public + return types — resolve it (raise, or apply the caller's default) before + returning. +4. **Check by identity** — `value is MISSING`, the rule the pattern lives + by: equality is overloadable, and a type check (`isinstance(value, + Sentinel)`) would swallow any *other* sentinel stored as a legitimate + value. Under `mypy --strict` a `cast` at the return keeps the public + type clean; identity remains the semantic guard. +5. **Upgrade chronic branching to a Null Object.** If many callers test the + sentinel just to skip work, return a do-nothing implementation of the real + interface instead (`NullNotifier` in the worked example). + +```python +from patterns.python.sentinel_object import MISSING, Sentinel + + +def get(self, key: str, default: Value | Sentinel = MISSING) -> Value: + for layer in self._layers: + value = layer.get(key, MISSING) + if value is not MISSING: + return cast("Value", value) # a stored None wins here + if default is MISSING: + raise KeyError(key) + return cast("Value", default) +``` + +## Python idioms that keep it small + +- `dict.get(key, MISSING)` turns "key present?" plus "value None?" into one + identity check. +- A keyword default of `MISSING` distinguishes "not passed" from "passed + None" without `**kwargs` games. +- `__slots__` and a `__repr__` on a tiny `Sentinel` class cost three lines + and make debugger output say `` instead of ``. + +## Pitfalls + +- **`==` instead of `is`.** Equality is overloadable; identity is the + contract. `value == MISSING` invites a `__eq__` to lie. +- **Sentinels escaping the API.** A public function returning `MISSING` + forces every caller to import your marker — resolve absence at the + boundary. +- **Pickle/copy round-trips.** A copied sentinel is a different object; + identity checks fail across process boundaries. Keep sentinels inside one + process's logic (PEP 661 discusses the fix). +- **In-band "improvements".** Replacing the sentinel with `-1`/`""` to avoid + the import reintroduces the original bug one type away. + +## Worked example + +[`examples/layered_config/`](../examples/layered_config/) resolves settings +CLI ← file ← defaults where a stored `None` means "explicitly disabled", and +hands back a `NullNotifier` so callers never branch. Run it: + +```bash +uv run python -m patterns.python.sentinel_object.examples.layered_config +``` diff --git a/patterns/python/sentinel_object/examples/__init__.py b/patterns/python/sentinel_object/examples/__init__.py new file mode 100644 index 0000000..08830c5 --- /dev/null +++ b/patterns/python/sentinel_object/examples/__init__.py @@ -0,0 +1 @@ +"""Mini-projects demonstrating the Sentinel Object pattern in practice.""" diff --git a/patterns/python/sentinel_object/examples/layered_config/__init__.py b/patterns/python/sentinel_object/examples/layered_config/__init__.py new file mode 100644 index 0000000..fab235b --- /dev/null +++ b/patterns/python/sentinel_object/examples/layered_config/__init__.py @@ -0,0 +1,16 @@ +"""Layered app configuration built on the Sentinel Object pattern. + +Run it: ``uv run python -m patterns.python.sentinel_object.examples.layered_config`` +""" + +from patterns.python.sentinel_object.examples.layered_config.config import ( + LayeredConfig, + Value, +) +from patterns.python.sentinel_object.examples.layered_config.notifier import ( + EmailNotifier, + NullNotifier, + notifier_for, +) + +__all__ = ["EmailNotifier", "LayeredConfig", "NullNotifier", "Value", "notifier_for"] diff --git a/patterns/python/sentinel_object/examples/layered_config/__main__.py b/patterns/python/sentinel_object/examples/layered_config/__main__.py new file mode 100644 index 0000000..c036860 --- /dev/null +++ b/patterns/python/sentinel_object/examples/layered_config/__main__.py @@ -0,0 +1,23 @@ +"""Demo: None-vs-missing through three config layers.""" + +from __future__ import annotations + +from patterns.python.sentinel_object.examples.layered_config.config import LayeredConfig +from patterns.python.sentinel_object.examples.layered_config.notifier import notifier_for + + +def main() -> None: + config = LayeredConfig( + defaults={"timeout_s": 30, "alert_email": "ops@example.com", "proxy": None}, + file={"timeout_s": 60, "alert_email": None}, # None: someone turned alerts OFF + cli={"timeout_s": 10}, + ) + for key in ("timeout_s", "alert_email", "proxy"): + print(f"{key:12} = {config.get(key)!r:22} (from {config.source_of(key)})") + notifier = notifier_for(config) + notifier.notify("disk almost full") + print(f"notifier = {type(notifier).__name__} (file layer's None disabled alerts)") + + +if __name__ == "__main__": + main() diff --git a/patterns/python/sentinel_object/examples/layered_config/config.py b/patterns/python/sentinel_object/examples/layered_config/config.py new file mode 100644 index 0000000..6d0ead6 --- /dev/null +++ b/patterns/python/sentinel_object/examples/layered_config/config.py @@ -0,0 +1,55 @@ +"""Layered configuration where ``None`` is a value, not an absence. + +Settings resolve CLI ← file ← defaults. A stored ``None`` means "explicitly +disabled" — a real decision someone made — so "missing" needs its own marker: +the sentinel, checked by identity. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import cast + +from patterns.python.sentinel_object.pattern import MISSING, Sentinel + +Value = str | int | None + + +class LayeredConfig: + """Lookup through override layers, None-safe at every step.""" + + def __init__( + self, + defaults: Mapping[str, Value], + file: Mapping[str, Value] | None = None, + cli: Mapping[str, Value] | None = None, + ) -> None: + # Highest priority first. + self._layers: tuple[tuple[str, Mapping[str, Value]], ...] = ( + ("cli", dict(cli or {})), + ("file", dict(file or {})), + ("defaults", dict(defaults)), + ) + + def get(self, key: str, default: Value | Sentinel = MISSING) -> Value: + """The first layer that *has* the key wins — even when its value is None. + + Checks are by identity (``is MISSING``), the unit's own rule: equality + is overloadable and a type check would swallow any *other* sentinel a + caller legitimately stored as a value. The ``cast``s are for the type + checker only — identity is the semantic guard. + """ + for _, layer in self._layers: + value = layer.get(key, MISSING) + if value is not MISSING: + return cast("Value", value) + if default is MISSING: + raise KeyError(f"{key!r} not set in any layer and no default given") + return cast("Value", default) + + def source_of(self, key: str) -> str: + """Which layer answers for a key — 'unset' if none does.""" + for name, layer in self._layers: + if key in layer: + return name + return "unset" diff --git a/patterns/python/sentinel_object/examples/layered_config/notifier.py b/patterns/python/sentinel_object/examples/layered_config/notifier.py new file mode 100644 index 0000000..8576421 --- /dev/null +++ b/patterns/python/sentinel_object/examples/layered_config/notifier.py @@ -0,0 +1,36 @@ +"""The neighboring Null Object cure, in the same domain. + +``alert_email = None`` means notifications are explicitly disabled. Instead +of every caller branching on that, ``notifier_for`` hands back a NullNotifier +— a real object that intentionally does nothing — and callers stop checking. +""" + +from __future__ import annotations + +from patterns.python.sentinel_object.examples.layered_config.config import LayeredConfig + + +class EmailNotifier: + """Fake outbound email; records sends so behavior is testable.""" + + def __init__(self, address: str) -> None: + self.address = address + self.sent: list[str] = [] + + def notify(self, message: str) -> None: + self.sent.append(f"to {self.address}: {message}") + + +class NullNotifier: + """The Null Object: same interface, deliberate no-op, no None checks.""" + + def notify(self, message: str) -> None: + pass + + +def notifier_for(config: LayeredConfig) -> EmailNotifier | NullNotifier: + """None (explicitly disabled) and unset both mean: the silent notifier.""" + address = config.get("alert_email", default=None) + if isinstance(address, str): + return EmailNotifier(address) + return NullNotifier() diff --git a/patterns/python/sentinel_object/naive.py b/patterns/python/sentinel_object/naive.py deleted file mode 100644 index 899e61b..0000000 --- a/patterns/python/sentinel_object/naive.py +++ /dev/null @@ -1,48 +0,0 @@ -"""The failure modes sentinels fix. - -1. The in-band sentinel value: str.find's -1 is a legal integer, so forgetting - the check produces a *plausible* wrong answer instead of an error. -2. None-as-missing: a cache that stores None cannot tell a hit from a miss. -""" - -from __future__ import annotations - - -def last_char_before(text: str, needle: str) -> str: - """BUG (deliberate): when needle is absent, find() returns -1 and the - index silently becomes text[-2] -- plausible garbage, no exception.""" - position = text.find(needle) - return text[position - 1] - - -class NoneCache: - """A cache where storing None is indistinguishable from a miss.""" - - def __init__(self) -> None: - self._data: dict[str, object | None] = {} - - def put(self, key: str, value: object | None) -> None: - self._data[key] = value - - def get_or_compute(self, key: str, compute_calls: list[str]) -> object | None: - value = self._data.get(key) - if value is None: # ... but None might BE the cached value! - compute_calls.append(key) - value = None # pretend we recomputed - self._data[key] = value - return value - - -def main() -> None: - print(f"present: {last_char_before('hello', 'e')!r}") - print(f"absent -- plausible garbage: {last_char_before('hello', 'z')!r}") - cache = NoneCache() - calls: list[str] = [] - cache.put("k", None) - cache.get_or_compute("k", calls) - cache.get_or_compute("k", calls) - print(f"cached None recomputed every time: {calls}") - - -if __name__ == "__main__": - main() diff --git a/patterns/python/sentinel_object/pattern/__init__.py b/patterns/python/sentinel_object/pattern/__init__.py new file mode 100644 index 0000000..8bc8420 --- /dev/null +++ b/patterns/python/sentinel_object/pattern/__init__.py @@ -0,0 +1,5 @@ +"""The Sentinel Object pattern, importable as library code.""" + +from patterns.python.sentinel_object.pattern.sentinel import MISSING, Sentinel + +__all__ = ["MISSING", "Sentinel"] diff --git a/patterns/python/sentinel_object/pattern/sentinel.py b/patterns/python/sentinel_object/pattern/sentinel.py new file mode 100644 index 0000000..f16e7e4 --- /dev/null +++ b/patterns/python/sentinel_object/pattern/sentinel.py @@ -0,0 +1,29 @@ +"""The Sentinel Object pattern as importable, typed building blocks. + +``Sentinel`` is a named, unforgeable marker (a PEP 661-inspired shape; +unlike the PEP's proposal, two same-named sentinels here are deliberately +distinct objects — tested). ``MISSING`` is the one most APIs need. A +sentinel's identity is its meaning: compare with ``is``, never ``==``. +""" + +from __future__ import annotations + + +class Sentinel: + """A unique marker object with a readable repr. + + >>> MISSING = Sentinel("MISSING") + >>> value is MISSING # identity is the only correct check + """ + + __slots__ = ("_name",) + + def __init__(self, name: str) -> None: + self._name = name + + def __repr__(self) -> str: + return f"<{self._name}>" + + +#: The workhorse: "no value here", even where None is a legitimate value. +MISSING = Sentinel("MISSING") diff --git a/patterns/python/sentinel_object/pythonic.py b/patterns/python/sentinel_object/pythonic.py deleted file mode 100644 index e094626..0000000 --- a/patterns/python/sentinel_object/pythonic.py +++ /dev/null @@ -1,57 +0,0 @@ -"""Sentinel objects, done right -- plus a small Null Object. - -``_MISSING = object()`` is unforgeable and out-of-band; identity comparison -makes the miss check exact even when None is stored. -""" - -from __future__ import annotations - -from collections.abc import Callable - -_MISSING = object() - - -class Cache: - """A cache where None is an ordinary, cacheable value.""" - - def __init__(self) -> None: - self._data: dict[str, object] = {} - - def put(self, key: str, value: object) -> None: - self._data[key] = value - - def get_or_compute(self, key: str, compute: Callable[[], object]) -> object: - value = self._data.get(key, _MISSING) - if value is _MISSING: # identity: the only correct sentinel check - value = compute() - self._data[key] = value - return value - - -def greet(name: str, greeting: object = _MISSING) -> str: - """Distinguish 'not passed' from 'passed None' in a default argument.""" - if greeting is _MISSING: - return f"hello {name}" - return f"{greeting} {name}" if greeting is not None else name - - -class NullLogger: - """Fowler's Null Object: a real object that intentionally does nothing, - so callers never branch on 'is there a logger?'.""" - - def log(self, message: str) -> None: - pass - - -def main() -> None: - cache = Cache() - cache.put("k", None) - calls: list[str] = [] - cache.get_or_compute("k", lambda: calls.append("computed")) - print(f"cached None respected (no recompute): {calls == []}") - print(greet("ada"), "|", greet("ada", None), "|", greet("ada", "yo")) - NullLogger().log("silently fine") - - -if __name__ == "__main__": - main() diff --git a/patterns/python/sentinel_object/real_world.py b/patterns/python/sentinel_object/real_world.py deleted file mode 100644 index fc2bfbd..0000000 --- a/patterns/python/sentinel_object/real_world.py +++ /dev/null @@ -1,43 +0,0 @@ -"""Sentinels in the stdlib. - -``dataclasses.MISSING`` separates "no default" from "default is None"; -two-argument ``iter(callable, sentinel)`` stops when the sentinel appears. -""" - -from __future__ import annotations - -import dataclasses -from dataclasses import dataclass, field, fields - - -@dataclass -class Config: - name: str - retries: int | None = None - tags: list[str] = field(default_factory=list) - - -def has_default(field_name: str) -> bool: - """MISSING lets introspection distinguish no-default from None-default.""" - for f in fields(Config): - if f.name == field_name: - return ( - f.default is not dataclasses.MISSING or f.default_factory is not dataclasses.MISSING - ) - raise KeyError(field_name) - - -def read_until_blank(chunks: list[str]) -> list[str]: - """iter(callable, sentinel): the empty string terminates the stream.""" - supply = iter(chunks).__next__ - return list(iter(supply, "")) - - -def main() -> None: - print(f"'name' has default: {has_default('name')}") - print(f"'retries' has default: {has_default('retries')}") - print(f"read until blank: {read_until_blank(['a', 'b', '', 'c'])}") - - -if __name__ == "__main__": - main() diff --git a/patterns/python/sentinel_object/tests/test_layered_config.py b/patterns/python/sentinel_object/tests/test_layered_config.py new file mode 100644 index 0000000..d97b3d4 --- /dev/null +++ b/patterns/python/sentinel_object/tests/test_layered_config.py @@ -0,0 +1,108 @@ +"""Behavioral tests for the layered-config mini-project.""" + +from __future__ import annotations + +import pytest + +from patterns.python.sentinel_object.examples.layered_config import ( + EmailNotifier, + LayeredConfig, + NullNotifier, + notifier_for, +) +from patterns.python.sentinel_object.examples.layered_config.__main__ import main +from patterns.python.sentinel_object.pattern import Sentinel + + +def build_config() -> LayeredConfig: + return LayeredConfig( + defaults={"timeout_s": 30, "alert_email": "ops@example.com", "proxy": None}, + file={"timeout_s": 60, "alert_email": None}, + cli={"timeout_s": 10}, + ) + + +class TestLayering: + def test_highest_layer_with_the_key_wins(self) -> None: + config = build_config() + assert config.get("timeout_s") == 10 + assert config.source_of("timeout_s") == "cli" + + def test_a_stored_none_wins_over_lower_layers(self) -> None: + # The file layer explicitly disabled alerts; the default email below + # it must NOT shine through — None is a value, not a hole. + config = build_config() + assert config.get("alert_email") is None + assert config.source_of("alert_email") == "file" + + def test_none_in_defaults_is_still_a_value(self) -> None: + config = build_config() + assert config.get("proxy") is None + assert config.source_of("proxy") == "defaults" + + def test_missing_key_without_default_raises(self) -> None: + with pytest.raises(KeyError, match="not set in any layer"): + build_config().get("nope") + + def test_missing_key_with_default_returns_it(self) -> None: + config = build_config() + assert config.get("nope", default=42) == 42 + assert config.get("nope", default=None) is None # None is a usable default + assert config.source_of("nope") == "unset" + + +class TestNullObject: + def test_disabled_alerts_yield_the_null_notifier(self) -> None: + notifier = notifier_for(build_config()) + assert isinstance(notifier, NullNotifier) + notifier.notify("nobody hears this") # and that is fine — no branching + + def test_configured_email_yields_a_real_notifier(self) -> None: + config = LayeredConfig(defaults={"alert_email": "oncall@example.com"}) + notifier = notifier_for(config) + assert isinstance(notifier, EmailNotifier) + notifier.notify("disk almost full") + assert notifier.sent == ["to oncall@example.com: disk almost full"] + + +class TestDemo: + def test_main_shows_provenance_and_the_null_notifier( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + main() + out = capsys.readouterr().out + assert "(from cli)" in out + assert "(from file)" in out + assert "NullNotifier" in out + + +class _EqualsEverything: + """A value whose __eq__ lies — only identity checks survive it.""" + + def __eq__(self, other: object) -> bool: + return True + + def __hash__(self) -> int: + return 0 + + +class TestIdentityIsTheGuard: + def test_get_checks_identity_not_equality(self) -> None: + liar = _EqualsEverything() + config = LayeredConfig(defaults={"trap": liar}) # type: ignore[dict-item] + got: object = config.get("trap") + assert got is liar # `==` would treat the liar as MISSING + + def test_a_different_sentinel_stored_as_a_value_is_not_swallowed(self) -> None: + other = Sentinel("OTHER") + config = LayeredConfig(defaults={"marker": other}) # type: ignore[dict-item] + stored: object = config.get("marker") + assert stored is other # an isinstance check would eat it + assert config.source_of("marker") == "defaults" + + +class TestNullNotifierPaths: + def test_absent_key_also_means_the_silent_notifier(self) -> None: + # The default=None argument exists exactly for the fully-unset case. + config = LayeredConfig(defaults={}) + assert isinstance(notifier_for(config), NullNotifier) diff --git a/patterns/python/sentinel_object/tests/test_sentinel.py b/patterns/python/sentinel_object/tests/test_sentinel.py new file mode 100644 index 0000000..d431dff --- /dev/null +++ b/patterns/python/sentinel_object/tests/test_sentinel.py @@ -0,0 +1,43 @@ +"""Behavioral tests for the pattern's Sentinel and MISSING.""" + +from __future__ import annotations + +from patterns.python.sentinel_object import MISSING, Sentinel + + +class TestSentinel: + def test_identity_is_the_meaning(self) -> None: + assert MISSING is MISSING + assert Sentinel("MISSING") is not MISSING # same name, different marker + + def test_a_sentinel_lives_in_no_value_domain(self) -> None: + store: dict[str, object] = {"none": None, "zero": 0, "empty": ""} + assert all(store.get(k, MISSING) is not MISSING for k in store) + assert store.get("absent", MISSING) is MISSING + + def test_repr_is_debuggable(self) -> None: + assert repr(MISSING) == "" + assert repr(Sentinel("NOT_GIVEN")) == "" + + def test_distinguishes_stored_none_from_absence(self) -> None: + cache: dict[str, str | None] = {"hit": None} + assert cache.get("hit", MISSING) is None + assert cache.get("miss", MISSING) is MISSING + + def test_equality_cannot_forge_a_sentinel(self) -> None: + # No name-based __eq__: a same-named marker is a different marker. + assert (Sentinel("MISSING") == MISSING) is False + assert Sentinel("MISSING") != MISSING + + def test_a_sentinel_is_truthy(self) -> None: + # "if value:" must never mistake the marker for an absence/falsy value. + assert bool(MISSING) is True + + def test_every_import_path_yields_the_same_marker(self) -> None: + from patterns.python.sentinel_object import MISSING as unit_missing + from patterns.python.sentinel_object.pattern import MISSING as pkg_missing + from patterns.python.sentinel_object.pattern.sentinel import ( + MISSING as module_missing, + ) + + assert unit_missing is pkg_missing is module_missing diff --git a/patterns/python/sentinel_object/tests/test_sentinel_object.py b/patterns/python/sentinel_object/tests/test_sentinel_object.py deleted file mode 100644 index f9fcadc..0000000 --- a/patterns/python/sentinel_object/tests/test_sentinel_object.py +++ /dev/null @@ -1,56 +0,0 @@ -"""Behavioral tests for all three sentinel-object variants.""" - -from patterns.python.sentinel_object import naive, pythonic, real_world - - -class TestNaive: - def test_in_band_sentinel_produces_plausible_garbage(self) -> None: - # The bug on display: absent needle silently indexes text[-2]. - assert naive.last_char_before("hello", "z") == "l" - - def test_none_cache_cannot_hold_none(self) -> None: - cache = naive.NoneCache() - calls: list[str] = [] - cache.put("k", None) - cache.get_or_compute("k", calls) - cache.get_or_compute("k", calls) - assert calls == ["k", "k"] # recomputed on every access - - -class TestPythonic: - def test_cache_distinguishes_stored_none_from_miss(self) -> None: - cache = pythonic.Cache() - cache.put("k", None) - calls: list[str] = [] - assert cache.get_or_compute("k", lambda: calls.append("x")) is None - assert calls == [] - - def test_miss_computes_once(self) -> None: - cache = pythonic.Cache() - calls: list[str] = [] - - def compute() -> object: - calls.append("x") - return 42 - - assert cache.get_or_compute("k", compute) == 42 - assert cache.get_or_compute("k", compute) == 42 - assert calls == ["x"] - - def test_default_argument_three_ways(self) -> None: - assert pythonic.greet("ada") == "hello ada" - assert pythonic.greet("ada", None) == "ada" - assert pythonic.greet("ada", "yo") == "yo ada" - - def test_null_object_never_raises(self) -> None: - pythonic.NullLogger().log("anything") - - -class TestRealWorld: - def test_missing_separates_no_default_from_none_default(self) -> None: - assert not real_world.has_default("name") - assert real_world.has_default("retries") - assert real_world.has_default("tags") - - def test_iter_with_sentinel_stops_at_blank(self) -> None: - assert real_world.read_until_blank(["a", "b", "", "c"]) == ["a", "b"] diff --git a/src/design_patterns/mcp/server.py b/src/design_patterns/mcp/server.py index 2abf98e..eec4a34 100644 --- a/src/design_patterns/mcp/server.py +++ b/src/design_patterns/mcp/server.py @@ -37,14 +37,13 @@ def get_index() -> SearchIndex: "Design patterns in Python: 32 units covering all 23 GoF patterns, " "Python-native patterns, and modern additions, each with an honest " "verdict. Start with search_patterns or recommend_pattern; verdicts of " - "'prefer-alternative' tell you what to write instead. Migrated " - "(module-shape) units offer three access levels: get_pattern_docs " + "'prefer-alternative' tell you what to write instead. Every unit " + "offers three access levels: get_pattern_docs " "(fundamentals/implementation/examples), then list_examples + " "run_example(example=...) for runnable mini-projects, then read_source " - "(the pattern/ package). Legacy (not yet migrated) units instead ship " - "flat variant files, served via get_pattern(variant=...) and " - "run_example(variant=...) with variant one of 'naive', 'pythonic', " - "'real_world' (their literal filenames)." + "(the pattern/ package). Units predating the module shape would ship " + "flat variant files via get_pattern(variant=...) and " + "run_example(variant=...); the current catalog has none." ), ) @@ -140,6 +139,10 @@ def run_example( and return its real output. For migrated (module-shape) patterns pass example=; for legacy patterns pass variant='naive'|'pythonic'|'real_world'. Exactly one of the two.""" + # Legacy-shape support (the variant= arm below and its relatives) is kept + # deliberately: every real unit is module-shape now, but the loader + # contract still admits legacy units and the synthetic test fixtures + # exercise these paths. Removal is a wrap-phase decision, tracked there. if (variant is None) == (example is None): raise ValueError("pass exactly one of 'variant' (legacy) or 'example' (module-shape)") if example is not None: diff --git a/tests/test_catalog.py b/tests/test_catalog.py index 67f1949..84c3306 100644 --- a/tests/test_catalog.py +++ b/tests/test_catalog.py @@ -7,6 +7,7 @@ from design_patterns.catalog import ( VERDICTS, + Catalog, CatalogError, find_patterns_root, load_catalog, @@ -19,13 +20,20 @@ def test_loads_all_units(self) -> None: assert len(catalog.patterns) == 32 assert "structural/decorator" in catalog.ids() - def test_catalog_contains_both_shapes_during_migration(self) -> None: - # The pilot migrated at least one unit; a silent regression of a - # module unit back to legacy shape must fail here, not skip a branch. + def test_real_catalog_is_fully_module_shape(self) -> None: + # The migration is complete: every real unit is module-shape. A unit + # regressing to legacy shape must fail here, not silently downgrade. shapes = {p.shape for p in load_catalog().patterns} - assert "module" in shapes - module_ids = {p.id for p in load_catalog().patterns if p.shape == "module"} - assert "behavioral/chain_of_responsibility" in module_ids + assert shapes == {"module"} + + def test_legacy_loader_branch_stays_covered_by_the_synthetic_unit( + self, legacy_catalog: Catalog + ) -> None: + # No real unit is legacy any more; this pins that the loader's legacy + # branch (and the tests that rely on it) still have a living subject. + (pattern,) = legacy_catalog.patterns + assert pattern.shape == "legacy" + assert sorted(pattern.variants()) == ["naive", "pythonic", "real_world"] 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