From 37846c1eade1e9cfa6b1909ab429c7ea595d0754 Mon Sep 17 00:00:00 2001 From: SuperElectron Date: Thu, 27 Aug 2026 11:56:14 -0700 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20v2=20modules=20=E2=80=94=20python?= =?UTF-8?q?=20group=20+=20composition=5Fover=5Finheritance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrates python/global_object, python/prebound_method, python/sentinel_object, principle/composition_over_inheritance to the module template: pattern/ (Lazy; prebound Counter + shares_instance; Sentinel/MISSING; Filter/Transform/Sink axes + composed Logger), docs/{fundamentals,implementation,examples}.md, mini-projects (settings_module, metrics, layered_config, notification_router), isolated tests. Legacy variant files removed. Co-Authored-By: Claude Fable 5 --- .../composition_over_inheritance/README.md | 42 ++++------ .../composition_over_inheritance/__init__.py | 15 +++- .../docs/examples.md | 31 +++++++ .../docs/fundamentals.md | 74 ++++++++++++++++ .../docs/implementation.md | 72 ++++++++++++++++ .../examples/__init__.py | 1 + .../examples/notification_router/__init__.py | 35 ++++++++ .../examples/notification_router/__main__.py | 42 ++++++++++ .../examples/notification_router/axes.py | 81 ++++++++++++++++++ .../examples/notification_router/models.py | 14 ++++ .../examples/notification_router/router.py | 36 ++++++++ .../composition_over_inheritance/naive.py | 53 ------------ .../pattern/__init__.py | 11 +++ .../pattern/compose.py | 40 +++++++++ .../composition_over_inheritance/pythonic.py | 45 ---------- .../real_world.py | 37 -------- .../tests/test_compose.py | 32 +++++++ .../test_composition_over_inheritance.py | 45 ---------- .../tests/test_notification_router.py | 84 +++++++++++++++++++ patterns/python/global_object/README.md | 43 ++++------ patterns/python/global_object/__init__.py | 9 +- .../python/global_object/docs/examples.md | 34 ++++++++ .../python/global_object/docs/fundamentals.md | 75 +++++++++++++++++ .../global_object/docs/implementation.md | 69 +++++++++++++++ .../python/global_object/examples/__init__.py | 1 + .../examples/settings_module/__init__.py | 24 ++++++ .../examples/settings_module/__main__.py | 22 +++++ .../examples/settings_module/settings.py | 35 ++++++++ .../examples/settings_module/shipping.py | 18 ++++ patterns/python/global_object/naive.py | 33 -------- .../python/global_object/pattern/__init__.py | 5 ++ patterns/python/global_object/pattern/lazy.py | 44 ++++++++++ patterns/python/global_object/pythonic.py | 44 ---------- patterns/python/global_object/real_world.py | 39 --------- .../global_object/tests/test_global_object.py | 41 --------- .../python/global_object/tests/test_lazy.py | 50 +++++++++++ .../tests/test_settings_module.py | 57 +++++++++++++ patterns/python/prebound_method/README.md | 42 ++++------ patterns/python/prebound_method/__init__.py | 14 +++- .../python/prebound_method/docs/examples.md | 31 +++++++ .../prebound_method/docs/fundamentals.md | 75 +++++++++++++++++ .../prebound_method/docs/implementation.md | 69 +++++++++++++++ .../prebound_method/examples/__init__.py | 1 + .../examples/metrics/__init__.py | 14 ++++ .../examples/metrics/__main__.py | 28 +++++++ .../prebound_method/examples/metrics/api.py | 19 +++++ .../examples/metrics/collector.py | 36 ++++++++ patterns/python/prebound_method/naive.py | 39 --------- .../prebound_method/pattern/__init__.py | 10 +++ .../prebound_method/pattern/prebound.py | 43 ++++++++++ patterns/python/prebound_method/pythonic.py | 38 --------- patterns/python/prebound_method/real_world.py | 31 ------- .../prebound_method/tests/test_metrics.py | 56 +++++++++++++ .../prebound_method/tests/test_prebound.py | 41 +++++++++ .../tests/test_prebound_method.py | 41 --------- patterns/python/sentinel_object/README.md | 42 ++++------ patterns/python/sentinel_object/__init__.py | 9 +- .../python/sentinel_object/docs/examples.md | 36 ++++++++ .../sentinel_object/docs/fundamentals.md | 68 +++++++++++++++ .../sentinel_object/docs/implementation.md | 75 +++++++++++++++++ .../sentinel_object/examples/__init__.py | 1 + .../examples/layered_config/__init__.py | 16 ++++ .../examples/layered_config/__main__.py | 23 +++++ .../examples/layered_config/config.py | 48 +++++++++++ .../examples/layered_config/notifier.py | 36 ++++++++ patterns/python/sentinel_object/naive.py | 48 ----------- .../sentinel_object/pattern/__init__.py | 5 ++ .../sentinel_object/pattern/sentinel.py | 28 +++++++ patterns/python/sentinel_object/pythonic.py | 57 ------------- patterns/python/sentinel_object/real_world.py | 43 ---------- .../tests/test_layered_config.py | 75 +++++++++++++++++ .../sentinel_object/tests/test_sentinel.py | 25 ++++++ .../tests/test_sentinel_object.py | 56 ------------- 73 files changed, 1945 insertions(+), 807 deletions(-) create mode 100644 patterns/principle/composition_over_inheritance/docs/examples.md create mode 100644 patterns/principle/composition_over_inheritance/docs/fundamentals.md create mode 100644 patterns/principle/composition_over_inheritance/docs/implementation.md create mode 100644 patterns/principle/composition_over_inheritance/examples/__init__.py create mode 100644 patterns/principle/composition_over_inheritance/examples/notification_router/__init__.py create mode 100644 patterns/principle/composition_over_inheritance/examples/notification_router/__main__.py create mode 100644 patterns/principle/composition_over_inheritance/examples/notification_router/axes.py create mode 100644 patterns/principle/composition_over_inheritance/examples/notification_router/models.py create mode 100644 patterns/principle/composition_over_inheritance/examples/notification_router/router.py delete mode 100644 patterns/principle/composition_over_inheritance/naive.py create mode 100644 patterns/principle/composition_over_inheritance/pattern/__init__.py create mode 100644 patterns/principle/composition_over_inheritance/pattern/compose.py delete mode 100644 patterns/principle/composition_over_inheritance/pythonic.py delete mode 100644 patterns/principle/composition_over_inheritance/real_world.py create mode 100644 patterns/principle/composition_over_inheritance/tests/test_compose.py delete mode 100644 patterns/principle/composition_over_inheritance/tests/test_composition_over_inheritance.py create mode 100644 patterns/principle/composition_over_inheritance/tests/test_notification_router.py create mode 100644 patterns/python/global_object/docs/examples.md create mode 100644 patterns/python/global_object/docs/fundamentals.md create mode 100644 patterns/python/global_object/docs/implementation.md create mode 100644 patterns/python/global_object/examples/__init__.py create mode 100644 patterns/python/global_object/examples/settings_module/__init__.py create mode 100644 patterns/python/global_object/examples/settings_module/__main__.py create mode 100644 patterns/python/global_object/examples/settings_module/settings.py create mode 100644 patterns/python/global_object/examples/settings_module/shipping.py delete mode 100644 patterns/python/global_object/naive.py create mode 100644 patterns/python/global_object/pattern/__init__.py create mode 100644 patterns/python/global_object/pattern/lazy.py delete mode 100644 patterns/python/global_object/pythonic.py delete mode 100644 patterns/python/global_object/real_world.py delete mode 100644 patterns/python/global_object/tests/test_global_object.py create mode 100644 patterns/python/global_object/tests/test_lazy.py create mode 100644 patterns/python/global_object/tests/test_settings_module.py create mode 100644 patterns/python/prebound_method/docs/examples.md create mode 100644 patterns/python/prebound_method/docs/fundamentals.md create mode 100644 patterns/python/prebound_method/docs/implementation.md create mode 100644 patterns/python/prebound_method/examples/__init__.py create mode 100644 patterns/python/prebound_method/examples/metrics/__init__.py create mode 100644 patterns/python/prebound_method/examples/metrics/__main__.py create mode 100644 patterns/python/prebound_method/examples/metrics/api.py create mode 100644 patterns/python/prebound_method/examples/metrics/collector.py delete mode 100644 patterns/python/prebound_method/naive.py create mode 100644 patterns/python/prebound_method/pattern/__init__.py create mode 100644 patterns/python/prebound_method/pattern/prebound.py delete mode 100644 patterns/python/prebound_method/pythonic.py delete mode 100644 patterns/python/prebound_method/real_world.py create mode 100644 patterns/python/prebound_method/tests/test_metrics.py create mode 100644 patterns/python/prebound_method/tests/test_prebound.py delete mode 100644 patterns/python/prebound_method/tests/test_prebound_method.py create mode 100644 patterns/python/sentinel_object/docs/examples.md create mode 100644 patterns/python/sentinel_object/docs/fundamentals.md create mode 100644 patterns/python/sentinel_object/docs/implementation.md create mode 100644 patterns/python/sentinel_object/examples/__init__.py create mode 100644 patterns/python/sentinel_object/examples/layered_config/__init__.py create mode 100644 patterns/python/sentinel_object/examples/layered_config/__main__.py create mode 100644 patterns/python/sentinel_object/examples/layered_config/config.py create mode 100644 patterns/python/sentinel_object/examples/layered_config/notifier.py delete mode 100644 patterns/python/sentinel_object/naive.py create mode 100644 patterns/python/sentinel_object/pattern/__init__.py create mode 100644 patterns/python/sentinel_object/pattern/sentinel.py delete mode 100644 patterns/python/sentinel_object/pythonic.py delete mode 100644 patterns/python/sentinel_object/real_world.py create mode 100644 patterns/python/sentinel_object/tests/test_layered_config.py create mode 100644 patterns/python/sentinel_object/tests/test_sentinel.py delete mode 100644 patterns/python/sentinel_object/tests/test_sentinel_object.py diff --git a/patterns/principle/composition_over_inheritance/README.md b/patterns/principle/composition_over_inheritance/README.md index 8af7d3d..76d9f00 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: `Filter`/`Transform`/`Sink` axis aliases + the composed `Logger` | +| [`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..7e3c4c4 100644 --- a/patterns/principle/composition_over_inheritance/__init__.py +++ b/patterns/principle/composition_over_inheritance/__init__.py @@ -1 +1,14 @@ -"""Composition over inheritance: objects per axis, not classes per combination.""" +"""Composition Over Inheritance — public API. + +>>> from patterns.principle.composition_over_inheritance import Filter, Logger +""" + +from patterns.principle.composition_over_inheritance.pattern import ( + Filter, + Logger, + Sink, + Transform, + identity, +) + +__all__ = ["Filter", "Logger", "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..6c64660 --- /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 — four 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..14091c2 --- /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 (`Logger`, the example's `Notifier`) | +| 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..1b96eb2 --- /dev/null +++ b/patterns/principle/composition_over_inheritance/docs/implementation.md @@ -0,0 +1,72 @@ +# 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 Filter, Sink, Transform + +pager = Notifier(filters=(min_severity(4), Dedup()), format=as_json, deliver=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 before format, format before deliver). + +## 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..ef6c072 --- /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),), format=plain_text, deliver=console), + Notifier(filters=(min_severity(4), Dedup()), format=as_json, deliver=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..f166b3a --- /dev/null +++ b/patterns/principle/composition_over_inheritance/examples/notification_router/router.py @@ -0,0 +1,36 @@ +"""The composition point: one Notifier class, any behavior combination.""" + +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 Filter, Sink, Transform + + +@dataclass +class Notifier: + """One class, ever. A new combination is a constructor call, not a subclass.""" + + filters: tuple[Filter[Alert], ...] + format: Transform[Alert, str] + deliver: Sink[str] + + def send(self, alert: Alert) -> bool: + """Deliver if every filter accepts; report whether delivery happened.""" + if not all(accepts(alert) for accepts in self.filters): + return False + self.deliver(self.format(alert)) + return True + + +@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.send(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..0729c5a --- /dev/null +++ b/patterns/principle/composition_over_inheritance/pattern/__init__.py @@ -0,0 +1,11 @@ +"""Composition Over Inheritance, importable as library code.""" + +from patterns.principle.composition_over_inheritance.pattern.compose import ( + Filter, + Logger, + Sink, + Transform, + identity, +) + +__all__ = ["Filter", "Logger", "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..c783486 --- /dev/null +++ b/patterns/principle/composition_over_inheritance/pattern/compose.py @@ -0,0 +1,40 @@ +"""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 behavior is assembled +by *composing* them, not by subclassing per combination. ``Logger`` is the +guide's own worked shape: 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 TypeVar + +T = TypeVar("T") +U = TypeVar("U") + +#: 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: + 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[str], ...] = () + transform: Transform[str, str] = identity + + def log(self, message: str) -> None: + if all(accepts(message) for accepts in self.filters): + self.sink.append(self.transform(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..7e98990 --- /dev/null +++ b/patterns/principle/composition_over_inheritance/tests/test_compose.py @@ -0,0 +1,32 @@ +"""Behavioral tests for the pattern's composed Logger and axis aliases.""" + +from __future__ import annotations + +from patterns.principle.composition_over_inheritance import Filter, Logger + + +def contains(pattern: str) -> Filter[str]: + def accepts(message: str) -> bool: + return pattern in message + + return accepts + + +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"] 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..263ba62 --- /dev/null +++ b/patterns/principle/composition_over_inheritance/tests/test_notification_router.py @@ -0,0 +1,84 @@ +"""Behavioral tests for the notification-router mini-project.""" + +from __future__ import annotations + +import json + +import pytest + +from patterns.principle.composition_over_inheritance.examples.notification_router import ( + Alert, + Dedup, + FakeWebhook, + MemorySink, + Notifier, + Router, + as_json, + min_severity, + plain_text, +) +from patterns.principle.composition_over_inheritance.examples.notification_router.__main__ import ( + main, +) + + +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 + + def test_formats_reshape_the_same_alert_differently(self) -> None: + sample = alert(5, "primary down", "db") + assert plain_text(sample) == "[5] db: primary down" + assert json.loads(as_json(sample)) == { + "source": "db", + "severity": 5, + "message": "primary down", + } + + +class TestComposition: + def test_two_behaviors_one_class_zero_subclasses(self) -> None: + chatty, pager = MemorySink(), MemorySink() + router = Router( + notifiers=( + Notifier(filters=(min_severity(2),), format=plain_text, deliver=chatty), + Notifier(filters=(min_severity(4), Dedup()), format=as_json, deliver=pager), + ) + ) + router.broadcast(alert(2, "latency rising")) + router.broadcast(alert(5, "primary down", "db")) + router.broadcast(alert(5, "primary down", "db")) # duplicate + assert len(chatty.lines) == 3 # console repeats; that is its policy + assert len(pager.lines) == 1 # webhook policy dedups + assert type(router.notifiers[0]) is type(router.notifiers[1]) # one class + + def test_send_reports_whether_delivery_happened(self) -> None: + sink = MemorySink() + notifier = Notifier(filters=(min_severity(4),), format=plain_text, deliver=sink) + assert notifier.send(alert(5)) is True + assert notifier.send(alert(1)) is False + assert len(sink.lines) == 1 + + def test_fake_webhook_records_payloads(self) -> None: + webhook = FakeWebhook("https://pager.example/hook") + Notifier(filters=(), format=as_json, deliver=webhook).send(alert(5, "down", "db")) + assert json.loads(webhook.posted[0])["source"] == "db" + + +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..dc117f7 --- /dev/null +++ b/patterns/python/global_object/pattern/lazy.py @@ -0,0 +1,44 @@ +"""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. + + >>> 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..8cd707c --- /dev/null +++ b/patterns/python/global_object/tests/test_settings_module.py @@ -0,0 +1,57 @@ +"""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 + +from collections.abc import Iterator + +import pytest + +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, +) + + +@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 + + +class TestImportStaysCheap: + def test_import_did_not_build_the_expensive_table(self) -> None: + # The module (and its consumers) are imported above; the fixture just + # reset — merely importing must leave the lazy global unbuilt. + assert settings.ZONE_TABLE.initialized is False + assert settings.FACTORY_RUNS == 0 + + 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!") + + 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() diff --git a/patterns/python/prebound_method/README.md b/patterns/python/prebound_method/README.md index e007341..1ec83ce 100644 --- a/patterns/python/prebound_method/README.md +++ b/patterns/python/prebound_method/README.md @@ -14,31 +14,17 @@ stdlib_sightings: [random.random, random.seed, secrets.token_hex] # Prebound Method -## Problem - -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). - -## 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. +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. + +| 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 | + +```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..b1bbd03 --- /dev/null +++ b/patterns/python/prebound_method/docs/examples.md @@ -0,0 +1,31 @@ +# 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`.** `token_hex`, `token_bytes`, `choice` come from a module-level + `SystemRandom` instance — the same wiring with a different engine. + [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..8d9adb7 100644 --- a/patterns/python/sentinel_object/README.md +++ b/patterns/python/sentinel_object/README.md @@ -15,31 +15,17 @@ stdlib_sightings: [dataclasses.MISSING, iter(callable, sentinel), str.find] # Sentinel Object -## Problem - -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. - -## 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`. +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`. + +| 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 | + +```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..f2dd94c --- /dev/null +++ b/patterns/python/sentinel_object/docs/implementation.md @@ -0,0 +1,75 @@ +# 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**, and let the type-checker help: `isinstance(value, + Sentinel)` narrows types under `mypy --strict` where a raw `object()` + cannot. +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 not isinstance(value, Sentinel): + return value # a stored None wins here + if isinstance(default, Sentinel): + raise KeyError(key) + return 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..0bbb5c0 --- /dev/null +++ b/patterns/python/sentinel_object/examples/layered_config/config.py @@ -0,0 +1,48 @@ +"""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 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.""" + for _, layer in self._layers: + value = layer.get(key, MISSING) + if not isinstance(value, Sentinel): + return value + if isinstance(default, Sentinel): + raise KeyError(f"{key!r} not set in any layer and no default given") + return 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..487f68a --- /dev/null +++ b/patterns/python/sentinel_object/pattern/sentinel.py @@ -0,0 +1,28 @@ +"""The Sentinel Object pattern as importable, typed building blocks. + +``Sentinel`` is a named, unforgeable marker (PEP 661's shape); ``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..f9a2391 --- /dev/null +++ b/patterns/python/sentinel_object/tests/test_layered_config.py @@ -0,0 +1,75 @@ +"""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 + + +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 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..122fcb1 --- /dev/null +++ b/patterns/python/sentinel_object/tests/test_sentinel.py @@ -0,0 +1,25 @@ +"""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 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"] From 8166ec810ed6ae2336a6e07e4c997d4b507e4f7f Mon Sep 17 00:00:00 2001 From: SuperElectron Date: Thu, 27 Aug 2026 13:37:50 -0700 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20python+principle=20review=20findings?= =?UTF-8?q?=20=E2=80=94=20subprocess=20import=20proof,=20Pipeline=20core,?= =?UTF-8?q?=20module-flow=20MCP=20surface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit global_object's laziness proven in a subprocess the fixtures can't erase; composition's pattern/ owns the Pipeline core the example composes; MCP instructions describe the module flow for every unit (legacy demoted, kept for synthetic fixtures, removal deferred to wrap); sentinel identity semantics pinned and honest. Co-Authored-By: Claude Fable 5 --- docs/mcp.md | 19 ++-- .../composition_over_inheritance/README.md | 2 +- .../composition_over_inheritance/__init__.py | 5 +- .../docs/examples.md | 2 +- .../docs/fundamentals.md | 2 +- .../docs/implementation.md | 8 +- .../examples/notification_router/__main__.py | 4 +- .../examples/notification_router/router.py | 24 ++--- .../pattern/__init__.py | 3 +- .../pattern/compose.py | 45 ++++++++-- .../tests/test_compose.py | 56 +++++++++++- .../tests/test_notification_router.py | 88 ++++++++++++++----- patterns/python/global_object/pattern/lazy.py | 10 +++ .../tests/test_settings_module.py | 41 ++++++++- patterns/python/prebound_method/README.md | 2 +- .../python/prebound_method/docs/examples.md | 7 +- patterns/python/sentinel_object/README.md | 4 +- .../sentinel_object/docs/implementation.md | 16 ++-- .../examples/layered_config/config.py | 17 ++-- .../sentinel_object/pattern/sentinel.py | 7 +- .../tests/test_layered_config.py | 33 +++++++ .../sentinel_object/tests/test_sentinel.py | 18 ++++ src/design_patterns/mcp/server.py | 15 ++-- tests/test_catalog.py | 20 +++-- 24 files changed, 341 insertions(+), 107 deletions(-) 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 76d9f00..250293c 100644 --- a/patterns/principle/composition_over_inheritance/README.md +++ b/patterns/principle/composition_over_inheritance/README.md @@ -20,7 +20,7 @@ load-bearing idea behind the rest of this catalog. | Where | What | |---|---| -| [`pattern/`](pattern/) | The importable code: `Filter`/`Transform`/`Sink` axis aliases + the composed `Logger` | +| [`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 | diff --git a/patterns/principle/composition_over_inheritance/__init__.py b/patterns/principle/composition_over_inheritance/__init__.py index 7e3c4c4..ca5dfe6 100644 --- a/patterns/principle/composition_over_inheritance/__init__.py +++ b/patterns/principle/composition_over_inheritance/__init__.py @@ -1,14 +1,15 @@ """Composition Over Inheritance — public API. ->>> from patterns.principle.composition_over_inheritance import Filter, Logger +>>> 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", "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 index 6c64660..360383c 100644 --- a/patterns/principle/composition_over_inheritance/docs/examples.md +++ b/patterns/principle/composition_over_inheritance/docs/examples.md @@ -6,7 +6,7 @@ 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 — four orthogonal axes, + 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 diff --git a/patterns/principle/composition_over_inheritance/docs/fundamentals.md b/patterns/principle/composition_over_inheritance/docs/fundamentals.md index 14091c2..2e54e6e 100644 --- a/patterns/principle/composition_over_inheritance/docs/fundamentals.md +++ b/patterns/principle/composition_over_inheritance/docs/fundamentals.md @@ -15,7 +15,7 @@ injected where needed; M + N pieces cover M × N behaviors. Source chapter: |---|---| | 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 (`Logger`, the example's `Notifier`) | +| 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 diff --git a/patterns/principle/composition_over_inheritance/docs/implementation.md b/patterns/principle/composition_over_inheritance/docs/implementation.md index 1b96eb2..3e1c851 100644 --- a/patterns/principle/composition_over_inheritance/docs/implementation.md +++ b/patterns/principle/composition_over_inheritance/docs/implementation.md @@ -32,9 +32,10 @@ class DedupJsonWebhookNotifier(...): ... # and severity thresholds arrive Monda its tests become tests of a *combination*, which now read as configuration. ```python -from patterns.principle.composition_over_inheritance import Filter, Sink, Transform +from patterns.principle.composition_over_inheritance import Pipeline -pager = Notifier(filters=(min_severity(4), Dedup()), format=as_json, deliver=webhook) +# 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 @@ -58,7 +59,8 @@ pager = Notifier(filters=(min_severity(4), Dedup()), format=as_json, deliver=web 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 before format, format before deliver). + document and test it (filters run in order and short-circuit — a stateful + filter placed after a veto never records vetoed items). ## Worked example diff --git a/patterns/principle/composition_over_inheritance/examples/notification_router/__main__.py b/patterns/principle/composition_over_inheritance/examples/notification_router/__main__.py index ef6c072..af7d2e2 100644 --- a/patterns/principle/composition_over_inheritance/examples/notification_router/__main__.py +++ b/patterns/principle/composition_over_inheritance/examples/notification_router/__main__.py @@ -23,8 +23,8 @@ def main() -> None: webhook = FakeWebhook("https://pager.example/hook") router = Router( notifiers=( - Notifier(filters=(min_severity(2),), format=plain_text, deliver=console), - Notifier(filters=(min_severity(4), Dedup()), format=as_json, deliver=webhook), + Notifier(filters=(min_severity(2),), transform=plain_text, sink=console), + Notifier(filters=(min_severity(4), Dedup()), transform=as_json, sink=webhook), ) ) alerts = [ diff --git a/patterns/principle/composition_over_inheritance/examples/notification_router/router.py b/patterns/principle/composition_over_inheritance/examples/notification_router/router.py index f166b3a..44e3f90 100644 --- a/patterns/principle/composition_over_inheritance/examples/notification_router/router.py +++ b/patterns/principle/composition_over_inheritance/examples/notification_router/router.py @@ -1,4 +1,4 @@ -"""The composition point: one Notifier class, any behavior combination.""" +"""The composition point, instantiated for the notification domain.""" from __future__ import annotations @@ -7,23 +7,11 @@ from patterns.principle.composition_over_inheritance.examples.notification_router.models import ( Alert, ) -from patterns.principle.composition_over_inheritance.pattern import Filter, Sink, Transform +from patterns.principle.composition_over_inheritance.pattern import Pipeline - -@dataclass -class Notifier: - """One class, ever. A new combination is a constructor call, not a subclass.""" - - filters: tuple[Filter[Alert], ...] - format: Transform[Alert, str] - deliver: Sink[str] - - def send(self, alert: Alert) -> bool: - """Deliver if every filter accepts; report whether delivery happened.""" - if not all(accepts(alert) for accepts in self.filters): - return False - self.deliver(self.format(alert)) - return True +#: 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 @@ -33,4 +21,4 @@ class Router: notifiers: tuple[Notifier, ...] def broadcast(self, alert: Alert) -> int: - return sum(1 for notifier in self.notifiers if notifier.send(alert)) + return sum(1 for notifier in self.notifiers if notifier.process(alert)) diff --git a/patterns/principle/composition_over_inheritance/pattern/__init__.py b/patterns/principle/composition_over_inheritance/pattern/__init__.py index 0729c5a..3b18b5f 100644 --- a/patterns/principle/composition_over_inheritance/pattern/__init__.py +++ b/patterns/principle/composition_over_inheritance/pattern/__init__.py @@ -3,9 +3,10 @@ from patterns.principle.composition_over_inheritance.pattern.compose import ( Filter, Logger, + Pipeline, Sink, Transform, identity, ) -__all__ = ["Filter", "Logger", "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 index c783486..ef13ce4 100644 --- a/patterns/principle/composition_over_inheritance/pattern/compose.py +++ b/patterns/principle/composition_over_inheritance/pattern/compose.py @@ -2,20 +2,22 @@ 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 behavior is assembled -by *composing* them, not by subclassing per combination. ``Logger`` is the -guide's own worked shape: M filters + N transforms cover M x N behaviors -with M + N pieces. +(``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 TypeVar +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] @@ -24,17 +26,44 @@ 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: - """One logger class, ever. Behavior comes from what you compose into it.""" + """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: - if all(accepts(message) for accepts in self.filters): - self.sink.append(self.transform(message)) + Pipeline(self.filters, self.transform, self.sink.append).process(message) diff --git a/patterns/principle/composition_over_inheritance/tests/test_compose.py b/patterns/principle/composition_over_inheritance/tests/test_compose.py index 7e98990..6490438 100644 --- a/patterns/principle/composition_over_inheritance/tests/test_compose.py +++ b/patterns/principle/composition_over_inheritance/tests/test_compose.py @@ -1,8 +1,13 @@ -"""Behavioral tests for the pattern's composed Logger and axis aliases.""" +"""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 +from patterns.principle.composition_over_inheritance import ( + Filter, + Logger, + Pipeline, + identity, +) def contains(pattern: str) -> Filter[str]: @@ -12,6 +17,51 @@ def accepts(message: str) -> bool: 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) @@ -29,4 +79,4 @@ def test_a_new_combination_is_a_constructor_call_not_a_class(self) -> None: def test_no_filters_means_everything_passes(self) -> None: logger = Logger() logger.log("anything") - assert logger.sink == ["anything"] + assert logger.sink == ["anything"] # default transform is identity diff --git a/patterns/principle/composition_over_inheritance/tests/test_notification_router.py b/patterns/principle/composition_over_inheritance/tests/test_notification_router.py index 263ba62..455002a 100644 --- a/patterns/principle/composition_over_inheritance/tests/test_notification_router.py +++ b/patterns/principle/composition_over_inheritance/tests/test_notification_router.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +from collections.abc import Callable import pytest @@ -14,12 +15,14 @@ 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: @@ -37,14 +40,47 @@ def test_dedup_passes_each_alert_once(self) -> None: assert dedup(alert(5, "primary down", "db")) is False assert dedup(alert(5, "replica down", "db")) is True # different message - def test_formats_reshape_the_same_alert_differently(self) -> None: - sample = alert(5, "primary down", "db") - assert plain_text(sample) == "[5] db: primary down" - assert json.loads(as_json(sample)) == { - "source": "db", - "severity": 5, - "message": "primary down", - } + +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: @@ -52,28 +88,36 @@ def test_two_behaviors_one_class_zero_subclasses(self) -> None: chatty, pager = MemorySink(), MemorySink() router = Router( notifiers=( - Notifier(filters=(min_severity(2),), format=plain_text, deliver=chatty), - Notifier(filters=(min_severity(4), Dedup()), format=as_json, deliver=pager), + 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 len(chatty.lines) == 3 # console repeats; that is its policy - assert len(pager.lines) == 1 # webhook policy dedups + 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_send_reports_whether_delivery_happened(self) -> None: + 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),), format=plain_text, deliver=sink) - assert notifier.send(alert(5)) is True - assert notifier.send(alert(1)) is False - assert len(sink.lines) == 1 - - def test_fake_webhook_records_payloads(self) -> None: - webhook = FakeWebhook("https://pager.example/hook") - Notifier(filters=(), format=as_json, deliver=webhook).send(alert(5, "down", "db")) - assert json.loads(webhook.posted[0])["source"] == "db" + 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: diff --git a/patterns/python/global_object/pattern/lazy.py b/patterns/python/global_object/pattern/lazy.py index dc117f7..68ce510 100644 --- a/patterns/python/global_object/pattern/lazy.py +++ b/patterns/python/global_object/pattern/lazy.py @@ -19,6 +19,16 @@ 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 diff --git a/patterns/python/global_object/tests/test_settings_module.py b/patterns/python/global_object/tests/test_settings_module.py index 8cd707c..8eb64b6 100644 --- a/patterns/python/global_object/tests/test_settings_module.py +++ b/patterns/python/global_object/tests/test_settings_module.py @@ -6,11 +6,15 @@ 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, @@ -27,12 +31,29 @@ def fresh_lazy_state() -> Iterator[None]: settings.FACTORY_RUNS = 0 +REPO_ROOT = Path(__file__).resolve().parents[4] + + class TestImportStaysCheap: def test_import_did_not_build_the_expensive_table(self) -> None: - # The module (and its consumers) are imported above; the fixture just - # reset — merely importing must leave the lazy global unbuilt. - assert settings.ZONE_TABLE.initialized is False - assert settings.FACTORY_RUNS == 0 + # 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 @@ -48,6 +69,8 @@ def test_constants_are_immutable_types(self) -> None: 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"): @@ -55,3 +78,13 @@ def test_unknown_country_is_a_domain_error(self) -> None: 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 1ec83ce..adb1060 100644 --- a/patterns/python/prebound_method/README.md +++ b/patterns/python/prebound_method/README.md @@ -9,7 +9,7 @@ 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 diff --git a/patterns/python/prebound_method/docs/examples.md b/patterns/python/prebound_method/docs/examples.md index b1bbd03..fed2a8a 100644 --- a/patterns/python/prebound_method/docs/examples.md +++ b/patterns/python/prebound_method/docs/examples.md @@ -10,8 +10,11 @@ reviewing module-API code. 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`.** `token_hex`, `token_bytes`, `choice` come from a module-level - `SystemRandom` instance — the same wiring with a different engine. +- **`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. diff --git a/patterns/python/sentinel_object/README.md b/patterns/python/sentinel_object/README.md index 8d9adb7..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,7 +10,7 @@ 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 diff --git a/patterns/python/sentinel_object/docs/implementation.md b/patterns/python/sentinel_object/docs/implementation.md index f2dd94c..6a6d5d4 100644 --- a/patterns/python/sentinel_object/docs/implementation.md +++ b/patterns/python/sentinel_object/docs/implementation.md @@ -21,9 +21,11 @@ if cached is None: # a cached None recomputes forever 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**, and let the type-checker help: `isinstance(value, - Sentinel)` narrows types under `mypy --strict` where a raw `object()` - cannot. +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). @@ -35,11 +37,11 @@ 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 not isinstance(value, Sentinel): - return value # a stored None wins here - if isinstance(default, Sentinel): + if value is not MISSING: + return cast("Value", value) # a stored None wins here + if default is MISSING: raise KeyError(key) - return default + return cast("Value", default) ``` ## Python idioms that keep it small diff --git a/patterns/python/sentinel_object/examples/layered_config/config.py b/patterns/python/sentinel_object/examples/layered_config/config.py index 0bbb5c0..6d0ead6 100644 --- a/patterns/python/sentinel_object/examples/layered_config/config.py +++ b/patterns/python/sentinel_object/examples/layered_config/config.py @@ -8,6 +8,7 @@ from __future__ import annotations from collections.abc import Mapping +from typing import cast from patterns.python.sentinel_object.pattern import MISSING, Sentinel @@ -31,14 +32,20 @@ def __init__( ) def get(self, key: str, default: Value | Sentinel = MISSING) -> Value: - """The first layer that *has* the key wins — even when its value is None.""" + """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 not isinstance(value, Sentinel): - return value - if isinstance(default, Sentinel): + 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 default + return cast("Value", default) def source_of(self, key: str) -> str: """Which layer answers for a key — 'unset' if none does.""" diff --git a/patterns/python/sentinel_object/pattern/sentinel.py b/patterns/python/sentinel_object/pattern/sentinel.py index 487f68a..f16e7e4 100644 --- a/patterns/python/sentinel_object/pattern/sentinel.py +++ b/patterns/python/sentinel_object/pattern/sentinel.py @@ -1,8 +1,9 @@ """The Sentinel Object pattern as importable, typed building blocks. -``Sentinel`` is a named, unforgeable marker (PEP 661's shape); ``MISSING`` is -the one most APIs need. A sentinel's identity is its meaning: compare with -``is``, never ``==``. +``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 diff --git a/patterns/python/sentinel_object/tests/test_layered_config.py b/patterns/python/sentinel_object/tests/test_layered_config.py index f9a2391..d97b3d4 100644 --- a/patterns/python/sentinel_object/tests/test_layered_config.py +++ b/patterns/python/sentinel_object/tests/test_layered_config.py @@ -11,6 +11,7 @@ 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: @@ -73,3 +74,35 @@ def test_main_shows_provenance_and_the_null_notifier( 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 index 122fcb1..d431dff 100644 --- a/patterns/python/sentinel_object/tests/test_sentinel.py +++ b/patterns/python/sentinel_object/tests/test_sentinel.py @@ -23,3 +23,21 @@ 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/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