From 5b1f789ad21eb85ced902cb4edac1658b0e47f83 Mon Sep 17 00:00:00 2001 From: SuperElectron Date: Thu, 27 Aug 2026 11:53:13 -0700 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20v2=20migration=20=E2=80=94=20adapte?= =?UTF-8?q?r,=20bridge,=20composite=20as=20modules?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Structural batch 1 of the patterns-as-modules refactor: - adapter: pattern/ DelegatingAdapter; examples/payment_gateways (one checkout over two mismatched vendor SDKs, failure conventions unified) - bridge: pattern/ Transport protocol + notifiers (PR #14 seed promoted); examples/notification_center (per-team transport routing) - composite: pattern/ generic Composite/HasTotal with honest interfaces; examples/org_chart (headcount+cost rollups in one pass) - three docs files per unit; legacy variant files removed Co-Authored-By: Claude Fable 5 --- patterns/structural/adapter/README.md | 42 ++++------ patterns/structural/adapter/__init__.py | 9 ++- patterns/structural/adapter/docs/examples.md | 36 +++++++++ .../structural/adapter/docs/fundamentals.md | 69 ++++++++++++++++ .../structural/adapter/docs/implementation.md | 78 ++++++++++++++++++ .../structural/adapter/examples/__init__.py | 1 + .../examples/payment_gateways/__init__.py | 27 +++++++ .../examples/payment_gateways/__main__.py | 30 +++++++ .../examples/payment_gateways/adapters.py | 57 +++++++++++++ .../examples/payment_gateways/checkout.py | 23 ++++++ .../examples/payment_gateways/vendors.py | 34 ++++++++ patterns/structural/adapter/naive.py | 43 ---------- .../structural/adapter/pattern/__init__.py | 5 ++ .../structural/adapter/pattern/adapter.py | 36 +++++++++ patterns/structural/adapter/pythonic.py | 49 ----------- patterns/structural/adapter/real_world.py | 24 ------ .../structural/adapter/tests/test_adapter.py | 55 ++++++++----- .../adapter/tests/test_payment_gateways.py | 70 ++++++++++++++++ patterns/structural/bridge/README.md | 45 ++++------- patterns/structural/bridge/__init__.py | 23 +++++- patterns/structural/bridge/docs/examples.md | 35 ++++++++ .../structural/bridge/docs/fundamentals.md | 80 ++++++++++++++++++ .../structural/bridge/docs/implementation.md | 81 +++++++++++++++++++ .../structural/bridge/examples/__init__.py | 1 + .../examples/notification_center/__init__.py | 11 +++ .../examples/notification_center/__main__.py | 28 +++++++ .../examples/notification_center/center.py | 47 +++++++++++ patterns/structural/bridge/naive.py | 54 ------------- .../structural/bridge/pattern/__init__.py | 19 +++++ .../bridge/{pythonic.py => pattern/bridge.py} | 24 ++---- patterns/structural/bridge/real_world.py | 39 --------- .../structural/bridge/tests/test_bridge.py | 75 +++++++++-------- .../bridge/tests/test_notification_center.py | 61 ++++++++++++++ patterns/structural/composite/README.md | 44 ++++------ patterns/structural/composite/__init__.py | 9 ++- .../structural/composite/docs/examples.md | 40 +++++++++ .../structural/composite/docs/fundamentals.md | 73 +++++++++++++++++ .../composite/docs/implementation.md | 79 ++++++++++++++++++ .../structural/composite/examples/__init__.py | 1 + .../composite/examples/org_chart/__init__.py | 12 +++ .../composite/examples/org_chart/__main__.py | 25 ++++++ .../composite/examples/org_chart/org.py | 51 ++++++++++++ patterns/structural/composite/naive.py | 60 -------------- .../structural/composite/pattern/__init__.py | 5 ++ patterns/structural/composite/pattern/tree.py | 52 ++++++++++++ patterns/structural/composite/pythonic.py | 53 ------------ patterns/structural/composite/real_world.py | 34 -------- .../composite/tests/test_composite.py | 79 +++++++++++------- .../composite/tests/test_org_chart.py | 52 ++++++++++++ 49 files changed, 1426 insertions(+), 554 deletions(-) create mode 100644 patterns/structural/adapter/docs/examples.md create mode 100644 patterns/structural/adapter/docs/fundamentals.md create mode 100644 patterns/structural/adapter/docs/implementation.md create mode 100644 patterns/structural/adapter/examples/__init__.py create mode 100644 patterns/structural/adapter/examples/payment_gateways/__init__.py create mode 100644 patterns/structural/adapter/examples/payment_gateways/__main__.py create mode 100644 patterns/structural/adapter/examples/payment_gateways/adapters.py create mode 100644 patterns/structural/adapter/examples/payment_gateways/checkout.py create mode 100644 patterns/structural/adapter/examples/payment_gateways/vendors.py delete mode 100644 patterns/structural/adapter/naive.py create mode 100644 patterns/structural/adapter/pattern/__init__.py create mode 100644 patterns/structural/adapter/pattern/adapter.py delete mode 100644 patterns/structural/adapter/pythonic.py delete mode 100644 patterns/structural/adapter/real_world.py create mode 100644 patterns/structural/adapter/tests/test_payment_gateways.py create mode 100644 patterns/structural/bridge/docs/examples.md create mode 100644 patterns/structural/bridge/docs/fundamentals.md create mode 100644 patterns/structural/bridge/docs/implementation.md create mode 100644 patterns/structural/bridge/examples/__init__.py create mode 100644 patterns/structural/bridge/examples/notification_center/__init__.py create mode 100644 patterns/structural/bridge/examples/notification_center/__main__.py create mode 100644 patterns/structural/bridge/examples/notification_center/center.py delete mode 100644 patterns/structural/bridge/naive.py create mode 100644 patterns/structural/bridge/pattern/__init__.py rename patterns/structural/bridge/{pythonic.py => pattern/bridge.py} (67%) delete mode 100644 patterns/structural/bridge/real_world.py create mode 100644 patterns/structural/bridge/tests/test_notification_center.py create mode 100644 patterns/structural/composite/docs/examples.md create mode 100644 patterns/structural/composite/docs/fundamentals.md create mode 100644 patterns/structural/composite/docs/implementation.md create mode 100644 patterns/structural/composite/examples/__init__.py create mode 100644 patterns/structural/composite/examples/org_chart/__init__.py create mode 100644 patterns/structural/composite/examples/org_chart/__main__.py create mode 100644 patterns/structural/composite/examples/org_chart/org.py delete mode 100644 patterns/structural/composite/naive.py create mode 100644 patterns/structural/composite/pattern/__init__.py create mode 100644 patterns/structural/composite/pattern/tree.py delete mode 100644 patterns/structural/composite/pythonic.py delete mode 100644 patterns/structural/composite/real_world.py create mode 100644 patterns/structural/composite/tests/test_org_chart.py diff --git a/patterns/structural/adapter/README.md b/patterns/structural/adapter/README.md index e83cda4..87b5653 100644 --- a/patterns/structural/adapter/README.md +++ b/patterns/structural/adapter/README.md @@ -14,31 +14,17 @@ stdlib_sightings: [io.TextIOWrapper, socket.makefile, functools.cmp_to_key] # Adapter -## Problem - -Your code speaks one interface; a class you cannot edit speaks another. A -sensor library reports Fahrenheit; your thermostat logic is written against -`celsius()`. - -## Naive solution - -`naive.py` is the GoF object adapter: a class implementing the target -interface, holding the adaptee, translating every call. - -## Pythonic solution - -Duck typing shrinks the job: adapt *only* what your code calls, and when -that's one method, a plain function is the whole adapter. `pythonic.py` shows -both the one-function adapter and a `__getattr__`-forwarding class for wider -surfaces. - -## In the wild - -`io.TextIOWrapper` adapts a binary stream to the text-file interface — -the stdlib's flagship adapter. `socket.makefile()` adapts a socket to a -file-like object; `functools.cmp_to_key` adapts old comparator functions to -the `key=` interface. - -## Verdict - -**Pythonic.** The honest way to reconcile interfaces you don't control. +Make a class you can't edit speak the interface your code expects — translate +what differs, forward the rest. **Verdict: pythonic** — the honest way to +reconcile interfaces you don't control. + +| Where | What | +|---|---| +| [`pattern/`](pattern/) | The importable code: `DelegatingAdapter` | +| [`docs/`](docs/) | [Fundamentals](docs/fundamentals.md) · [Implementation guide](docs/implementation.md) · [External examples](docs/examples.md) | +| [`examples/payment_gateways/`](examples/payment_gateways/) | Mini-project: one checkout over two mismatched vendor SDKs | +| [`tests/`](tests/) | Behavioral tests for the pattern and the mini-project | + +```bash +uv run python -m patterns.structural.adapter.examples.payment_gateways +``` diff --git a/patterns/structural/adapter/__init__.py b/patterns/structural/adapter/__init__.py index 362c561..d1af636 100644 --- a/patterns/structural/adapter/__init__.py +++ b/patterns/structural/adapter/__init__.py @@ -1 +1,8 @@ -"""Adapter: make a given class speak the interface your code expects.""" +"""Adapter — public API. + +>>> from patterns.structural.adapter import DelegatingAdapter +""" + +from patterns.structural.adapter.pattern import DelegatingAdapter + +__all__ = ["DelegatingAdapter"] diff --git a/patterns/structural/adapter/docs/examples.md b/patterns/structural/adapter/docs/examples.md new file mode 100644 index 0000000..c4c6030 --- /dev/null +++ b/patterns/structural/adapter/docs/examples.md @@ -0,0 +1,36 @@ +# Adapter — where it lives outside this repo + +Cited, real implementations to study (or point an agent at) when designing or +reviewing adapter-shaped code. + +## Python standard library + +- **`io.TextIOWrapper`** — the stdlib's flagship adapter: wraps a binary + stream and exposes the text-file interface; your code reads `str` while + bytes flow underneath. + [docs.python.org/3/library/io.html#io.TextIOWrapper](https://docs.python.org/3/library/io.html#io.TextIOWrapper) +- **`functools.cmp_to_key`** — adapts an old-style two-argument comparator to + the one-argument `key=` interface; an entire adapter in function form. + [docs.python.org/3/library/functools.html#functools.cmp_to_key](https://docs.python.org/3/library/functools.html#functools.cmp_to_key) +- **`socket.makefile()`** — adapts a socket to a file-like object so + file-consuming code can speak to the network. + [docs.python.org/3/library/socket.html#socket.socket.makefile](https://docs.python.org/3/library/socket.html#socket.socket.makefile) + +## Major ecosystems + +- **`requests` transport adapters.** `HTTPAdapter` adapts urllib3's + connection machinery to the `Session` API, and users mount custom adapters + per URL prefix — the pattern offered as a public extension point. + [requests.readthedocs.io/en/latest/user/advanced/#transport-adapters](https://requests.readthedocs.io/en/latest/user/advanced/#transport-adapters) +- **SQLAlchemy dialects.** Each dialect adapts one DBAPI driver's quirks + (paramstyles, type handling) to a single Core interface, which is why one + query API spans many databases. + [docs.sqlalchemy.org/en/20/dialects/](https://docs.sqlalchemy.org/en/20/dialects/) + +## What to notice across all of them + +Every production adapter translates *conventions*, not just method names: +`cmp_to_key` bridges calling conventions, `TextIOWrapper` bridges data +models (bytes vs text), dialects bridge error hierarchies. When reviewing an +adapter, ask what happens to the adaptee's failure modes — an adapter that +only renames methods has usually left the hard mismatch in the client. diff --git a/patterns/structural/adapter/docs/fundamentals.md b/patterns/structural/adapter/docs/fundamentals.md new file mode 100644 index 0000000..df011f5 --- /dev/null +++ b/patterns/structural/adapter/docs/fundamentals.md @@ -0,0 +1,69 @@ +# Adapter — fundamentals + +## Intent + +Convert the interface of a class into the interface clients expect, so +classes that could not otherwise work together can — without editing either +side. You control neither the caller's shape nor the callee's; the adapter is +the one piece you do control. + +## Participants + +| Role | Classic (GoF) form | Python form | +|---|---|---| +| Target | Abstract class the client is written against | A `Protocol` (or just the duck-typed calls the client makes) | +| Adaptee | The class with the wrong interface | Same — the vendor SDK, legacy module, stdlib object you can't edit | +| Adapter | A class implementing Target, holding the Adaptee | A plain function when one method differs; a small class (see [`pattern/adapter.py`](../pattern/adapter.py)) for wider surfaces | +| Client | Calls Target methods only | Same — and never imports the adaptee | + +## Mechanism + +1. Write the target interface from the *client's* needs — only the calls it + actually makes. +2. The adapter holds the adaptee and translates each target call: units, + argument shapes, naming, and failure conventions. +3. The client is constructed with any adapter; swapping adaptees is now a + wiring change, not an edit. + +## The classic form, and what Python absorbs + +The textbook object adapter builds a full class triangle even for one method: + +```python +class Thermometer(ABC): # Target, as an abstract class + @abstractmethod + def celsius(self) -> float: ... + + +class SensorAdapter(Thermometer): # Adapter subclasses the Target + def __init__(self, sensor: FahrenheitSensor) -> None: + self._sensor = sensor + + def celsius(self) -> float: + return (self._sensor.get_fahrenheit() - 32) * 5 / 9 +``` + +Python absorbs most of that ceremony. Duck typing means there is no Target +class to subclass — the adapter only needs the methods the client calls. A +one-method mismatch collapses to a function returning a closure. And for wide +surfaces, `__getattr__` forwarding (what `DelegatingAdapter` packages) means +the adapter lists only the *differences*, never the whole interface. + +## When to use it + +- A third-party or legacy interface has the wrong shape and you can't (or + shouldn't) edit it. +- Two vendors do the same job differently and the rest of the system should + not know which one is wired in. + +## When not to use it + +- You own both sides — change one of them instead of adding a layer. +- The "adapter" starts adding behavior (retries, caching, validation) — that + is Decorator or Proxy territory; keep translation pure. + +## Verdict: pythonic + +The honest way to reconcile interfaces you don't control. Size it to the +mismatch: function for one method, `DelegatingAdapter` subclass for a few, +and stop before it becomes a facade over many objects. diff --git a/patterns/structural/adapter/docs/implementation.md b/patterns/structural/adapter/docs/implementation.md new file mode 100644 index 0000000..119e6bd --- /dev/null +++ b/patterns/structural/adapter/docs/implementation.md @@ -0,0 +1,78 @@ +# Adapter — putting it into a system + +## The smell it fixes + +Vendor-specific shapes leaking through code that shouldn't care: + +```python +def checkout(order, vendor, client): + if vendor == "stripe": + outcome = client.create_charge(order.total_cents, "usd") + paid = outcome["status"] == "succeeded" + elif vendor == "paypal": + try: + ref = client.submit_payment(f"{order.total_cents / 100:.2f}", "USD") + paid = True + except ValueError: + paid = False + ... +``` + +Every vendor difference — units, naming, error convention — is re-decided at +every call site. The adapter moves each vendor's translation into one class, +and the call sites shrink to a single target interface. + +## Steps + +1. **Define the target from the client's needs.** List the calls the client + actually makes; type them as a `Protocol`. Do not copy either vendor's + surface — the target belongs to *your* domain. +2. **Write one adapter per adaptee.** Translate units and argument shapes, + and — the step most often missed — translate **failure conventions** + (status dict vs exception) into one result type. +3. **Pick the adapter's size.** One method → a plain function or tiny class. + A few methods over a wide surface → subclass + `DelegatingAdapter` and define only what differs; the rest forwards. +4. **Construct at the edge.** Adapters are wired where the app is assembled + (config, DI, factory) — client modules import the target type only. +5. **Test through the target.** One test suite, parameterized over every + adapter, pins that all vendors behave identically from the client's seat. + +```python +from patterns.structural.adapter.pattern import DelegatingAdapter + + +class StripeAdapter(DelegatingAdapter[StripeLikeClient]): + def charge(self, amount_cents: int, currency: str) -> PaymentResult: + outcome = self.adaptee.create_charge(amount_cents, currency.lower()) + ... +``` + +## Python idioms that keep it small + +- **`Protocol` for the target** — the client gets type-checked without any + runtime base class, and adapters satisfy it structurally. +- **A closure as the whole adapter** when the target is one callable: + `lambda: (sensor.get_fahrenheit() - 32) * 5 / 9`. +- **`__getattr__` forwarding** for pass-through surfaces — never re-list + methods you aren't translating. + +## Pitfalls + +- **Adapting the whole surface** instead of what the client calls — you end + up maintaining a second copy of the vendor's API. +- **Leaking adaptee types** through the adapter's returns (a vendor result + dict escaping to the client re-couples everything the adapter decoupled). +- **Unifying calls but not failures.** If one vendor raises and the other + returns an error status, the client is still vendor-aware. Normalize both. +- **Translation with opinions.** Retry, cache, or validation logic hiding in + an adapter belongs in a Decorator/Proxy where it is visible and reusable. + +## Worked example + +[`examples/payment_gateways/`](../examples/payment_gateways/) integrates two +mismatched fake vendor SDKs behind one `PaymentProcessor` — run it with: + +```bash +uv run python -m patterns.structural.adapter.examples.payment_gateways +``` diff --git a/patterns/structural/adapter/examples/__init__.py b/patterns/structural/adapter/examples/__init__.py new file mode 100644 index 0000000..1f409cb --- /dev/null +++ b/patterns/structural/adapter/examples/__init__.py @@ -0,0 +1 @@ +"""Mini-projects demonstrating the Adapter in practice.""" diff --git a/patterns/structural/adapter/examples/payment_gateways/__init__.py b/patterns/structural/adapter/examples/payment_gateways/__init__.py new file mode 100644 index 0000000..7a7c48c --- /dev/null +++ b/patterns/structural/adapter/examples/payment_gateways/__init__.py @@ -0,0 +1,27 @@ +"""Payment checkout over mismatched vendor SDKs, built on the Adapter. + +Run it: ``uv run python -m patterns.structural.adapter.examples.payment_gateways`` +""" + +from patterns.structural.adapter.examples.payment_gateways.adapters import ( + PaymentProcessor, + PaymentResult, + PayPalAdapter, + StripeAdapter, +) +from patterns.structural.adapter.examples.payment_gateways.checkout import Receipt, checkout +from patterns.structural.adapter.examples.payment_gateways.vendors import ( + PayPalLikeGateway, + StripeLikeClient, +) + +__all__ = [ + "PayPalAdapter", + "PayPalLikeGateway", + "PaymentProcessor", + "PaymentResult", + "Receipt", + "StripeAdapter", + "StripeLikeClient", + "checkout", +] diff --git a/patterns/structural/adapter/examples/payment_gateways/__main__.py b/patterns/structural/adapter/examples/payment_gateways/__main__.py new file mode 100644 index 0000000..f3ba24d --- /dev/null +++ b/patterns/structural/adapter/examples/payment_gateways/__main__.py @@ -0,0 +1,30 @@ +"""Demo: the same checkout code through two mismatched vendor SDKs.""" + +from __future__ import annotations + +from patterns.structural.adapter.examples.payment_gateways.adapters import ( + PaymentProcessor, + PayPalAdapter, + StripeAdapter, +) +from patterns.structural.adapter.examples.payment_gateways.checkout import checkout +from patterns.structural.adapter.examples.payment_gateways.vendors import ( + PayPalLikeGateway, + StripeLikeClient, +) + + +def main() -> None: + processors: dict[str, PaymentProcessor] = { + "stripe-like": StripeAdapter(StripeLikeClient()), + "paypal-like": PayPalAdapter(PayPalLikeGateway()), + } + for vendor, processor in processors.items(): + ok = checkout("A-1", 2_499, processor) + declined = checkout("A-2", 999_999, processor) + print(f"{vendor}: A-1 paid={ok.paid} ({ok.reference})") + print(f"{vendor}: A-2 paid={declined.paid} ({declined.note})") + + +if __name__ == "__main__": + main() diff --git a/patterns/structural/adapter/examples/payment_gateways/adapters.py b/patterns/structural/adapter/examples/payment_gateways/adapters.py new file mode 100644 index 0000000..8018f37 --- /dev/null +++ b/patterns/structural/adapter/examples/payment_gateways/adapters.py @@ -0,0 +1,57 @@ +"""One ``PaymentProcessor`` target; one adapter per vendor shape. + +The target interface is defined by what *checkout* needs — not by either +vendor. Each adapter translates amounts, currencies, and (crucially) the +vendors' different failure conventions into one ``PaymentResult``. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Protocol + +from patterns.structural.adapter.examples.payment_gateways.vendors import ( + PayPalLikeGateway, + StripeLikeClient, +) +from patterns.structural.adapter.pattern import DelegatingAdapter + + +@dataclass(frozen=True) +class PaymentResult: + """The one result shape checkout understands.""" + + ok: bool + reference: str + reason: str = "" + + +class PaymentProcessor(Protocol): + """The target interface — everything checkout will ever call.""" + + def charge(self, amount_cents: int, currency: str) -> PaymentResult: ... + + +class StripeAdapter(DelegatingAdapter[StripeLikeClient]): + """Translate ``charge``; the vendor's extras still reachable by forwarding.""" + + def charge(self, amount_cents: int, currency: str) -> PaymentResult: + outcome = self.adaptee.create_charge(amount_cents, currency.lower()) + if outcome["status"] == "succeeded": + return PaymentResult(ok=True, reference=outcome["id"]) + return PaymentResult(ok=False, reference=outcome["id"], reason=outcome["status"]) + + +class PayPalAdapter: + """A hand-rolled adapter: cents -> decimal string, exception -> result.""" + + def __init__(self, gateway: PayPalLikeGateway) -> None: + self._gateway = gateway + + def charge(self, amount_cents: int, currency: str) -> PaymentResult: + amount = f"{amount_cents / 100:.2f}" + try: + confirmation = self._gateway.submit_payment(amount, currency.upper()) + except ValueError as refusal: + return PaymentResult(ok=False, reference="", reason=str(refusal)) + return PaymentResult(ok=True, reference=confirmation) diff --git a/patterns/structural/adapter/examples/payment_gateways/checkout.py b/patterns/structural/adapter/examples/payment_gateways/checkout.py new file mode 100644 index 0000000..2fa5fb2 --- /dev/null +++ b/patterns/structural/adapter/examples/payment_gateways/checkout.py @@ -0,0 +1,23 @@ +"""The client code: written once against the target, never per vendor.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from patterns.structural.adapter.examples.payment_gateways.adapters import PaymentProcessor + + +@dataclass(frozen=True) +class Receipt: + order_id: str + paid: bool + reference: str + note: str = "" + + +def checkout(order_id: str, total_cents: int, processor: PaymentProcessor) -> Receipt: + """Charge an order through whichever vendor the adapter hides.""" + result = processor.charge(total_cents, "usd") + if result.ok: + return Receipt(order_id, paid=True, reference=result.reference) + return Receipt(order_id, paid=False, reference=result.reference, note=result.reason) diff --git a/patterns/structural/adapter/examples/payment_gateways/vendors.py b/patterns/structural/adapter/examples/payment_gateways/vendors.py new file mode 100644 index 0000000..230fbd0 --- /dev/null +++ b/patterns/structural/adapter/examples/payment_gateways/vendors.py @@ -0,0 +1,34 @@ +"""Two fake vendor SDKs we must integrate but cannot edit. + +Each has its own idea of amounts, currencies, and results — exactly the +mismatch the adapters in :mod:`adapters` reconcile. +""" + +from __future__ import annotations + + +class StripeLikeClient: + """Charges in integer cents; answers with a result dict.""" + + def create_charge(self, amount_cents: int, currency: str) -> dict[str, str]: + if amount_cents <= 0: + return {"id": "", "status": "invalid_amount"} + if amount_cents > 500_000: + return {"id": "ch_declined", "status": "card_declined"} + return {"id": f"ch_{amount_cents}", "status": "succeeded"} + + def diagnostics(self) -> str: + """Vendor extra our checkout never calls — but support scripts do.""" + return "stripe-like: all systems normal" + + +class PayPalLikeGateway: + """Charges via decimal strings; failure is an exception, not a status.""" + + def submit_payment(self, amount: str, currency_code: str) -> str: + value = float(amount) + if value <= 0: + raise ValueError("PAYPAL_INVALID_AMOUNT") + if value > 5000.0: + raise ValueError("PAYPAL_DECLINED") + return f"PAYPAL-OK-{amount}-{currency_code}" diff --git a/patterns/structural/adapter/naive.py b/patterns/structural/adapter/naive.py deleted file mode 100644 index 9f8b20f..0000000 --- a/patterns/structural/adapter/naive.py +++ /dev/null @@ -1,43 +0,0 @@ -"""The Gang of Four object adapter, translated literally. - -The adapter implements the target interface and holds the adaptee, -translating call by call. -""" - -from __future__ import annotations - -from abc import ABC, abstractmethod - - -class FahrenheitSensor: - """The adaptee: a class we cannot edit, with the wrong interface.""" - - def get_fahrenheit(self) -> float: - return 68.0 - - -class Thermometer(ABC): - """The target interface our code is written against.""" - - @abstractmethod - def celsius(self) -> float: ... - - -class SensorAdapter(Thermometer): - def __init__(self, sensor: FahrenheitSensor) -> None: - self._sensor = sensor - - def celsius(self) -> float: - return (self._sensor.get_fahrenheit() - 32) * 5 / 9 - - -def describe(thermometer: Thermometer) -> str: - return f"{thermometer.celsius():.1f} °C" - - -def main() -> None: - print(describe(SensorAdapter(FahrenheitSensor()))) - - -if __name__ == "__main__": - main() diff --git a/patterns/structural/adapter/pattern/__init__.py b/patterns/structural/adapter/pattern/__init__.py new file mode 100644 index 0000000..c791dce --- /dev/null +++ b/patterns/structural/adapter/pattern/__init__.py @@ -0,0 +1,5 @@ +"""The Adapter pattern, importable as library code.""" + +from patterns.structural.adapter.pattern.adapter import DelegatingAdapter + +__all__ = ["DelegatingAdapter"] diff --git a/patterns/structural/adapter/pattern/adapter.py b/patterns/structural/adapter/pattern/adapter.py new file mode 100644 index 0000000..4da873b --- /dev/null +++ b/patterns/structural/adapter/pattern/adapter.py @@ -0,0 +1,36 @@ +"""Adapter as an importable, typed building block. + +An adapter translates the calls your code makes into the calls a class you +cannot edit understands. Python needs less machinery than the classic form: +a one-method mismatch is just a function, and for wider surfaces +``DelegatingAdapter`` translates what differs and forwards the rest. +""" + +from __future__ import annotations + +from typing import Any, Generic, TypeVar + +Adaptee = TypeVar("Adaptee") + + +class DelegatingAdapter(Generic[Adaptee]): + """Translate the methods that differ; forward everything else. + + Subclass it, store nothing yourself, and define only the target-interface + methods your callers actually use. Attributes you don't define fall + through to the adaptee via ``__getattr__`` — the adapter never has to + re-list a surface it isn't changing. + """ + + def __init__(self, adaptee: Adaptee) -> None: + self._adaptee = adaptee + + @property + def adaptee(self) -> Adaptee: + """The wrapped object, for callers that need to reach past the adapter.""" + return self._adaptee + + def __getattr__(self, name: str) -> Any: + # Only called for names not found on the adapter itself, so a + # translated method always wins over the adaptee's original. + return getattr(self._adaptee, name) diff --git a/patterns/structural/adapter/pythonic.py b/patterns/structural/adapter/pythonic.py deleted file mode 100644 index d681bd4..0000000 --- a/patterns/structural/adapter/pythonic.py +++ /dev/null @@ -1,49 +0,0 @@ -"""Adapters at the right size. - -A single-method mismatch needs a function, not a class. A wider surface can -forward wholesale with ``__getattr__`` and translate only what differs. -""" - -from __future__ import annotations - -from collections.abc import Callable -from typing import Any - - -class FahrenheitSensor: - """The adaptee, unchanged.""" - - def get_fahrenheit(self) -> float: - return 68.0 - - def vendor_id(self) -> str: - return "acme-42" - - -def celsius_reader(sensor: FahrenheitSensor) -> Callable[[], float]: - """The one-function adapter: all the pattern that's needed here.""" - return lambda: (sensor.get_fahrenheit() - 32) * 5 / 9 - - -class CelsiusAdapter: - """Translate the one differing method; forward everything else.""" - - def __init__(self, sensor: FahrenheitSensor) -> None: - self._sensor = sensor - - def celsius(self) -> float: - return (self._sensor.get_fahrenheit() - 32) * 5 / 9 - - def __getattr__(self, name: str) -> Any: - return getattr(self._sensor, name) - - -def main() -> None: - read = celsius_reader(FahrenheitSensor()) - print(f"function adapter: {read():.1f} °C") - adapter = CelsiusAdapter(FahrenheitSensor()) - print(f"class adapter: {adapter.celsius():.1f} °C from {adapter.vendor_id()}") - - -if __name__ == "__main__": - main() diff --git a/patterns/structural/adapter/real_world.py b/patterns/structural/adapter/real_world.py deleted file mode 100644 index 4f177ea..0000000 --- a/patterns/structural/adapter/real_world.py +++ /dev/null @@ -1,24 +0,0 @@ -"""``io.TextIOWrapper``: the stdlib's flagship adapter. - -It wraps a binary stream and exposes the text-file interface -- your code -reads ``str`` while bytes flow underneath. -""" - -from __future__ import annotations - -import io - - -def read_as_text(binary_stream: io.BytesIO) -> str: - """Adapt any binary stream to the text interface.""" - return io.TextIOWrapper(binary_stream, encoding="utf-8").read() - - -def main() -> None: - binary = io.BytesIO("héllo bytes\n".encode()) - text = read_as_text(binary) - print(f"adapted read -> {type(text).__name__}: {text!r}") - - -if __name__ == "__main__": - main() diff --git a/patterns/structural/adapter/tests/test_adapter.py b/patterns/structural/adapter/tests/test_adapter.py index 11e1be4..179c003 100644 --- a/patterns/structural/adapter/tests/test_adapter.py +++ b/patterns/structural/adapter/tests/test_adapter.py @@ -1,32 +1,43 @@ -"""Behavioral tests for all three adapter variants.""" +"""Behavioral tests for the DelegatingAdapter building block.""" -import io +from __future__ import annotations -from patterns.structural.adapter import naive, pythonic, real_world +import pytest +from patterns.structural.adapter import DelegatingAdapter -class TestNaive: - def test_adapter_translates_the_interface(self) -> None: - adapter = naive.SensorAdapter(naive.FahrenheitSensor()) - assert adapter.celsius() == 20.0 - def test_client_code_sees_only_the_target_interface(self) -> None: - assert naive.describe(naive.SensorAdapter(naive.FahrenheitSensor())) == "20.0 °C" +class Legacy: + def speed_mph(self) -> float: + return 62.0 + def vendor_id(self) -> str: + return "acme-42" -class TestPythonic: - def test_function_adapter(self) -> None: - read = pythonic.celsius_reader(pythonic.FahrenheitSensor()) - assert read() == 20.0 - def test_class_adapter_translates_and_forwards(self) -> None: - adapter = pythonic.CelsiusAdapter(pythonic.FahrenheitSensor()) - assert adapter.celsius() == 20.0 - assert adapter.vendor_id() == "acme-42" # forwarded untouched +class MetricAdapter(DelegatingAdapter[Legacy]): + def speed_kmh(self) -> float: + return self.adaptee.speed_mph() * 1.609344 + def vendor_id(self) -> str: # deliberately shadows the adaptee's method + return "translated" -class TestRealWorld: - def test_textiowrapper_adapts_bytes_to_str(self) -> None: - text = real_world.read_as_text(io.BytesIO("héllo\n".encode())) - assert text == "héllo\n" - assert isinstance(text, str) + +class TestDelegatingAdapter: + def test_translated_method_converts(self) -> None: + assert MetricAdapter(Legacy()).speed_kmh() == pytest.approx(99.78, abs=0.01) + + def test_untranslated_methods_forward_to_the_adaptee(self) -> None: + adapter = MetricAdapter(Legacy()) + assert adapter.speed_mph() == 62.0 + + def test_a_defined_method_always_beats_forwarding(self) -> None: + assert MetricAdapter(Legacy()).vendor_id() == "translated" + + def test_missing_names_raise_attribute_error_not_silence(self) -> None: + with pytest.raises(AttributeError): + MetricAdapter(Legacy()).warp_drive() + + def test_the_adaptee_stays_reachable(self) -> None: + legacy = Legacy() + assert MetricAdapter(legacy).adaptee is legacy diff --git a/patterns/structural/adapter/tests/test_payment_gateways.py b/patterns/structural/adapter/tests/test_payment_gateways.py new file mode 100644 index 0000000..179723f --- /dev/null +++ b/patterns/structural/adapter/tests/test_payment_gateways.py @@ -0,0 +1,70 @@ +"""Behavioral tests for the payment-gateways mini-project. + +The point under test: checkout is written once against ``PaymentProcessor`` +and every vendor behaves identically from its seat — including failures. +""" + +from __future__ import annotations + +import pytest + +from patterns.structural.adapter.examples.payment_gateways import ( + PaymentProcessor, + PayPalAdapter, + PayPalLikeGateway, + StripeAdapter, + StripeLikeClient, + checkout, +) +from patterns.structural.adapter.examples.payment_gateways.__main__ import main + + +def stripe() -> PaymentProcessor: + return StripeAdapter(StripeLikeClient()) + + +def paypal() -> PaymentProcessor: + return PayPalAdapter(PayPalLikeGateway()) + + +@pytest.mark.parametrize("processor", [stripe(), paypal()], ids=["stripe-like", "paypal-like"]) +class TestAnyVendor: + """One suite, every adapter: the client contract is vendor-independent.""" + + def test_a_normal_charge_pays_the_order(self, processor: PaymentProcessor) -> None: + receipt = checkout("A-1", 2_499, processor) + assert receipt.paid + assert receipt.reference != "" + + def test_a_huge_charge_is_declined_not_raised(self, processor: PaymentProcessor) -> None: + receipt = checkout("A-2", 999_999, processor) + assert not receipt.paid + assert receipt.note != "" + + def test_a_zero_charge_is_refused(self, processor: PaymentProcessor) -> None: + assert not checkout("A-3", 0, processor).paid + + +class TestTranslationDetails: + def test_paypal_amounts_become_decimal_strings(self) -> None: + gateway = PayPalLikeGateway() + result = PayPalAdapter(gateway).charge(2_499, "usd") + assert result.reference == "PAYPAL-OK-24.99-USD" + + def test_paypal_exceptions_become_results(self) -> None: + result = PayPalAdapter(PayPalLikeGateway()).charge(999_999, "usd") + assert not result.ok + assert "DECLINED" in result.reason + + def test_stripe_extras_stay_reachable_through_forwarding(self) -> None: + adapter = StripeAdapter(StripeLikeClient()) + assert "all systems normal" in adapter.diagnostics() + + +class TestDemo: + def test_main_charges_both_vendors(self, capsys: pytest.CaptureFixture[str]) -> None: + main() + out = capsys.readouterr().out + assert "stripe-like: A-1 paid=True" in out + assert "paypal-like: A-1 paid=True" in out + assert "paid=False" in out diff --git a/patterns/structural/bridge/README.md b/patterns/structural/bridge/README.md index b26d87f..ac09dfc 100644 --- a/patterns/structural/bridge/README.md +++ b/patterns/structural/bridge/README.md @@ -14,34 +14,17 @@ stdlib_sightings: [logging.Logger with logging.Handler] # Bridge -## Problem - -Shapes (circle, square) need rendering backends (vector, raster). Inheriting -`VectorCircle`, `RasterCircle`, `VectorSquare`… multiplies the two axes into -one hierarchy — the same explosion Composition-Over-Inheritance warns about, -seen from the structural side. - -## Naive solution - -`naive.py` is the book's shape: an abstraction hierarchy (`Shape`) holding a -reference to an implementor hierarchy (`Renderer`), each extensible without -touching the other. - -## Pythonic solution - -Strip the ceremony and the Bridge is *composition with an injected -dependency* — which is why the verdict points there. `pythonic.py` bridges -notifiers (alerts, digests) over delivery transports (email, Slack, SMS): -the transport is a `Protocol`, notifiers are dataclasses holding one, and -"outage alert to Slack" is a constructor call, not a class. - -## In the wild - -`logging` is a Bridge you already use: `Logger` (the abstraction callers see) -delegates to interchangeable `Handler` implementations, and both sides grow -independently. - -## Verdict - -**Prefer an alternative** — plain composition/DI *is* the bridge. Keep the -lesson (name your axes), skip the taxonomy. +Two independent axes (what to do × how to carry it out) joined by one injected +reference, instead of a subclass per combination. **Verdict: prefer an +alternative** — composition with dependency injection *is* the bridge. + +| Where | What | +|---|---| +| [`pattern/`](pattern/) | The importable code: `Transport` protocol, transports, `AlertNotifier`, `DigestNotifier` | +| [`docs/`](docs/) | [Fundamentals](docs/fundamentals.md) · [Implementation guide](docs/implementation.md) · [External examples](docs/examples.md) | +| [`examples/notification_center/`](examples/notification_center/) | Mini-project: team alert/digest routing over per-team transports | +| [`tests/`](tests/) | Behavioral tests for the pattern and the mini-project | + +```bash +uv run python -m patterns.structural.bridge.examples.notification_center +``` diff --git a/patterns/structural/bridge/__init__.py b/patterns/structural/bridge/__init__.py index 7676ef1..f92f37e 100644 --- a/patterns/structural/bridge/__init__.py +++ b/patterns/structural/bridge/__init__.py @@ -1 +1,22 @@ -"""Bridge: decouple abstraction from implementation. Verdict: it is composition + DI.""" +"""Bridge — public API. + +>>> from patterns.structural.bridge import AlertNotifier, SlackTransport +""" + +from patterns.structural.bridge.pattern import ( + AlertNotifier, + DigestNotifier, + EmailTransport, + SlackTransport, + SmsTransport, + Transport, +) + +__all__ = [ + "AlertNotifier", + "DigestNotifier", + "EmailTransport", + "SlackTransport", + "SmsTransport", + "Transport", +] diff --git a/patterns/structural/bridge/docs/examples.md b/patterns/structural/bridge/docs/examples.md new file mode 100644 index 0000000..560c225 --- /dev/null +++ b/patterns/structural/bridge/docs/examples.md @@ -0,0 +1,35 @@ +# Bridge — where it lives outside this repo + +Cited, real implementations to study (or point an agent at) when designing or +reviewing bridge-shaped code. + +## Python standard library + +- **`logging.Logger` × `logging.Handler`.** The logger is the abstraction + callers hold; handlers are the interchangeable implementation hierarchy — + one `logger.info()` call fans out to console, file, or syslog backends, and + both sides grow independently. + [docs.python.org/3/library/logging.html](https://docs.python.org/3/library/logging.html) + +## Major ecosystems + +- **Matplotlib figures over rendering backends.** The `Figure`/`Artist` + layer is one stable abstraction; Agg, SVG, PDF, and GUI canvases are + swappable implementors selected at runtime — the canonical large-scale + bridge. + [matplotlib.org/stable/users/explain/figure/backends.html](https://matplotlib.org/stable/users/explain/figure/backends.html) +- **Django ORM over database backends.** One `QuerySet` abstraction compiles + through per-database implementor packages (PostgreSQL, MySQL, SQLite…); + application code never learns which. + [docs.djangoproject.com/en/stable/ref/databases/](https://docs.djangoproject.com/en/stable/ref/databases/) +- **SQLAlchemy `Engine` over `Dialect`/DBAPI.** The same split one level + down: Core's execution abstraction bridges to per-driver dialects. + [docs.sqlalchemy.org/en/20/core/engines.html](https://docs.sqlalchemy.org/en/20/core/engines.html) + +## What to notice across all of them + +The implementor interface is always *narrow and stable* — `Handler.emit`, +the backend canvas API, the dialect contract — while both sides multiply +freely behind it. When reviewing bridge-shaped code, check which axis a new +requirement lands on: if most changes touch both sides at once, the axes were +drawn in the wrong place. diff --git a/patterns/structural/bridge/docs/fundamentals.md b/patterns/structural/bridge/docs/fundamentals.md new file mode 100644 index 0000000..d283c17 --- /dev/null +++ b/patterns/structural/bridge/docs/fundamentals.md @@ -0,0 +1,80 @@ +# Bridge — fundamentals + +## Intent + +Decouple an abstraction from its implementation so the two can vary +independently. When one family of things (notifiers, shapes, reports) must +work over another family (transports, renderers, backends), inheritance +multiplies the axes into one hierarchy; the bridge keeps them as two, joined +by a single reference. + +## Participants + +| Role | Classic (GoF) form | Python form | +|---|---|---| +| Abstraction | Base class holding an implementor reference | A dataclass holding an injected dependency | +| RefinedAbstraction | Subclasses adding behavior | More dataclasses on the same bridge | +| Implementor | Abstract implementation interface | A `Protocol` — `Transport` in [`pattern/bridge.py`](../pattern/bridge.py) | +| ConcreteImplementor | Subclasses per backend | Any object satisfying the Protocol | + +## Mechanism + +1. Name the two axes explicitly (what varies about *what you do* vs *how it + is carried out*). +2. Type the implementor axis as a small interface the abstraction calls. +3. The abstraction holds one implementor, received at construction. +4. Each axis now grows without touching the other: M abstractions + N + implementors give M × N combinations from M + N classes. + +## The classic form, and what Python absorbs + +The textbook bridge builds two parallel class hierarchies and an abstract +base on each side: + +```python +class Renderer(ABC): # Implementor interface + @abstractmethod + def render_circle(self, radius: float) -> str: ... + + +class VectorRenderer(Renderer): ... # one subclass per backend + + +class RasterRenderer(Renderer): ... + + +class Shape(ABC): # Abstraction holds the bridge + def __init__(self, renderer: Renderer) -> None: + self.renderer = renderer + + +class Circle(Shape): + def draw(self) -> str: + return self.renderer.render_circle(self.radius) +``` + +Python absorbs nearly all of it: the implementor interface becomes a +`Protocol` (no base class for backends to inherit), the abstraction becomes a +frozen dataclass, and "connect abstraction to implementation" is just an +injected attribute. What survives is the design move, not the class diagram: +**name the two axes and join them with one reference** instead of subclassing +across both. + +## When to use it + +- Two independent dimensions are multiplying subclasses + (`VectorCircle`, `RasterCircle`, `VectorSquare`…). +- A stable front must run over swappable backends, and both sides are still + growing. + +## When not to use it + +- Only one axis actually varies — plain composition already covers it, no + naming ceremony needed. +- The "implementations" are one function each — pass callables, skip the + Protocol. + +## Verdict: prefer an alternative + +Composition with an injected dependency *is* the bridge in Python. Keep the +lesson (two named axes, one reference), skip the four-role taxonomy. diff --git a/patterns/structural/bridge/docs/implementation.md b/patterns/structural/bridge/docs/implementation.md new file mode 100644 index 0000000..a9c92ff --- /dev/null +++ b/patterns/structural/bridge/docs/implementation.md @@ -0,0 +1,81 @@ +# Bridge — putting it into a system + +## The smell it fixes + +A class name with two axes baked into it — and a hierarchy that doubles every +time either axis grows: + +```python +class EmailAlert: ... + + +class SlackAlert: ... + + +class SmsAlert: ... + + +class EmailDigest: ... + + +class SlackDigest: ... + + +class SmsDigest: ... # 2 kinds x 3 transports = 6 classes, and counting +``` + +Adding WhatsApp means three new classes; adding a weekly-report kind means +four. The bridge cuts the product into a sum: kinds hold a transport, and +"Slack digest" becomes `DigestNotifier(SlackTransport(), "#ops")`. + +## Steps + +1. **Find the two axes.** Ask "what varies about what we *say*?" and "what + varies about how it's *delivered*?" If you can't fill both blanks, you + don't need a bridge. +2. **Type the implementor axis as a `Protocol`.** Keep it minimal — one or + two methods the abstraction actually calls (`deliver(recipient, text)`). +3. **Make abstractions hold, not inherit.** Each kind is a dataclass with a + `transport` field; behavior methods call through it. +4. **Inject at the edge.** Which transport a given notifier gets is wiring — + configuration, DI, or a registry — never a hard-coded constructor default. +5. **Test the axes separately, then one combination.** Transports get their + own tests; kinds are tested against a recording fake; a single M × N + sweep pins that any pair composes. + +```python +from patterns.structural.bridge import AlertNotifier, SlackTransport + +notifier = AlertNotifier(SlackTransport(), "#ops") # any kind x any transport +notifier.alert("critical", "db pool exhausted") +``` + +## Python idioms that keep it small + +- **`Protocol` on the implementor axis** — backends satisfy it structurally; + third parties can add transports without importing your base class. +- **Frozen dataclasses for abstractions** — the bridge reference is visible + in the signature and immutable after wiring. +- **A callable as the degenerate implementor.** When the interface is one + method, `Callable[[str, str], None]` may replace the Protocol entirely. + +## Pitfalls + +- **Bridging one axis.** If every "abstraction" is the same class with a + different name, you only had implementors — use plain injection and stop. +- **A fat implementor interface.** The Protocol should carry what all + backends share; per-backend extras belong on the backend, reached + explicitly, or the axes are lying. +- **Leaking backend types through the abstraction** (returning a Slack + response object from `alert`) re-couples what the bridge separated. +- **Hard-coding a default transport** in the abstraction — it silently turns + the bridge back into a single-axis class. + +## Worked example + +[`examples/notification_center/`](../examples/notification_center/) routes +alerts and digests for three teams over three transports — run it with: + +```bash +uv run python -m patterns.structural.bridge.examples.notification_center +``` diff --git a/patterns/structural/bridge/examples/__init__.py b/patterns/structural/bridge/examples/__init__.py new file mode 100644 index 0000000..e844b83 --- /dev/null +++ b/patterns/structural/bridge/examples/__init__.py @@ -0,0 +1 @@ +"""Mini-projects demonstrating the Bridge in practice.""" diff --git a/patterns/structural/bridge/examples/notification_center/__init__.py b/patterns/structural/bridge/examples/notification_center/__init__.py new file mode 100644 index 0000000..25d281a --- /dev/null +++ b/patterns/structural/bridge/examples/notification_center/__init__.py @@ -0,0 +1,11 @@ +"""Team notification routing built on the Bridge. + +Run it: ``uv run python -m patterns.structural.bridge.examples.notification_center`` +""" + +from patterns.structural.bridge.examples.notification_center.center import ( + NotificationCenter, + TeamChannel, +) + +__all__ = ["NotificationCenter", "TeamChannel"] diff --git a/patterns/structural/bridge/examples/notification_center/__main__.py b/patterns/structural/bridge/examples/notification_center/__main__.py new file mode 100644 index 0000000..a4b89f3 --- /dev/null +++ b/patterns/structural/bridge/examples/notification_center/__main__.py @@ -0,0 +1,28 @@ +"""Demo: one incident and one digest, three teams, three transports.""" + +from __future__ import annotations + +from patterns.structural.bridge.examples.notification_center.center import ( + NotificationCenter, + TeamChannel, +) +from patterns.structural.bridge.pattern import EmailTransport, SlackTransport, SmsTransport + + +def main() -> None: + slack, email, sms = SlackTransport(), EmailTransport(), SmsTransport() + + center = NotificationCenter() + center.register(TeamChannel("platform", slack, "#platform-ops")) + center.register(TeamChannel("payments", sms, "+1-555-0100")) + center.register(TeamChannel("support", email, "support@example.com")) + + center.alert(["platform", "payments"], "critical", "db connection pool exhausted") + center.broadcast_digest(["3 deploys", "1 rollback", "error budget at 92%"]) + + for line in (*slack.posts, *sms.messages, *email.outbox): + print(line) + + +if __name__ == "__main__": + main() diff --git a/patterns/structural/bridge/examples/notification_center/center.py b/patterns/structural/bridge/examples/notification_center/center.py new file mode 100644 index 0000000..f9b8df2 --- /dev/null +++ b/patterns/structural/bridge/examples/notification_center/center.py @@ -0,0 +1,47 @@ +"""Routing app over the bridge: teams choose transports, code stays put. + +Each team registers a channel — a preferred transport plus an address. The +center fans alerts and digests out to every team through whatever transport +each one picked. Adding a transport touches zero routing code; adding a +notifier kind touches zero transports. That independence *is* the bridge. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from patterns.structural.bridge.pattern import AlertNotifier, DigestNotifier, Transport + + +@dataclass(frozen=True) +class TeamChannel: + """One team's delivery preference.""" + + team: str + transport: Transport + address: str + + +class NotificationCenter: + """Holds the routing table; notifiers do the talking.""" + + def __init__(self) -> None: + self._channels: dict[str, TeamChannel] = {} + + def register(self, channel: TeamChannel) -> None: + self._channels[channel.team] = channel + + @property + def teams(self) -> list[str]: + return sorted(self._channels) + + def alert(self, teams: list[str], severity: str, message: str) -> None: + """Page specific teams through their chosen transports.""" + for team in teams: + channel = self._channels[team] + AlertNotifier(channel.transport, channel.address).alert(severity, message) + + def broadcast_digest(self, items: list[str]) -> None: + """Every team gets the digest, each on its own transport.""" + for channel in self._channels.values(): + DigestNotifier(channel.transport, channel.address).digest(items) diff --git a/patterns/structural/bridge/naive.py b/patterns/structural/bridge/naive.py deleted file mode 100644 index 6080d6a..0000000 --- a/patterns/structural/bridge/naive.py +++ /dev/null @@ -1,54 +0,0 @@ -"""The Gang of Four Bridge, translated literally. - -Abstraction hierarchy (Shape) holds a reference to the implementor -hierarchy (Renderer); each side can grow without touching the other. -""" - -from __future__ import annotations - -from abc import ABC, abstractmethod - - -class Renderer(ABC): - """The implementor interface.""" - - @abstractmethod - def render_circle(self, radius: float) -> str: ... - - -class VectorRenderer(Renderer): - def render_circle(self, radius: float) -> str: - return f"" - - -class RasterRenderer(Renderer): - def render_circle(self, radius: float) -> str: - return f"pixels for a circle of radius {radius}" - - -class Shape(ABC): - """The abstraction: holds the bridge reference.""" - - def __init__(self, renderer: Renderer) -> None: - self.renderer = renderer - - @abstractmethod - def draw(self) -> str: ... - - -class Circle(Shape): - def __init__(self, renderer: Renderer, radius: float) -> None: - super().__init__(renderer) - self.radius = radius - - def draw(self) -> str: - return self.renderer.render_circle(self.radius) - - -def main() -> None: - print(Circle(VectorRenderer(), 2.0).draw()) - print(Circle(RasterRenderer(), 2.0).draw()) - - -if __name__ == "__main__": - main() diff --git a/patterns/structural/bridge/pattern/__init__.py b/patterns/structural/bridge/pattern/__init__.py new file mode 100644 index 0000000..d06dd42 --- /dev/null +++ b/patterns/structural/bridge/pattern/__init__.py @@ -0,0 +1,19 @@ +"""The Bridge pattern, importable as library code.""" + +from patterns.structural.bridge.pattern.bridge import ( + AlertNotifier, + DigestNotifier, + EmailTransport, + SlackTransport, + SmsTransport, + Transport, +) + +__all__ = [ + "AlertNotifier", + "DigestNotifier", + "EmailTransport", + "SlackTransport", + "SmsTransport", + "Transport", +] diff --git a/patterns/structural/bridge/pythonic.py b/patterns/structural/bridge/pattern/bridge.py similarity index 67% rename from patterns/structural/bridge/pythonic.py rename to patterns/structural/bridge/pattern/bridge.py index 2d27762..7d0c45b 100644 --- a/patterns/structural/bridge/pythonic.py +++ b/patterns/structural/bridge/pattern/bridge.py @@ -1,8 +1,9 @@ -"""The Bridge without ceremony: composition plus an injected dependency. +"""The Bridge without ceremony: composition plus an injected implementor. -The two real axes: what to say (alert severities, digest summaries) and how -to deliver it (email, Slack, SMS). M notifiers + N transports cover M x N -combinations, and "send the outage alert to Slack" is a constructor call. +Two independent axes — what to say (alert, digest) and how to deliver it +(email, Slack, SMS). The transport is a ``Protocol`` injected into dataclass +notifiers: M notifiers + N transports cover M x N combinations, and "outage +alert to Slack" is a constructor call, not a class. """ from __future__ import annotations @@ -55,7 +56,7 @@ def alert(self, severity: str, message: str) -> None: @dataclass(frozen=True) class DigestNotifier: - """A second abstraction on the same bridge -- no transport changes needed.""" + """A second abstraction on the same bridge — no transport changes needed.""" transport: Transport recipient: str @@ -63,16 +64,3 @@ class DigestNotifier: def digest(self, items: list[str]) -> None: summary = f"{len(items)} updates: " + "; ".join(items) self.transport.deliver(self.recipient, summary) - - -def main() -> None: - slack = SlackTransport() - email = EmailTransport() - AlertNotifier(slack, "#ops").alert("critical", "db connection pool exhausted") - DigestNotifier(email, "team@example.com").digest(["3 deploys", "1 rollback"]) - print(slack.posts) - print(email.outbox) - - -if __name__ == "__main__": - main() diff --git a/patterns/structural/bridge/real_world.py b/patterns/structural/bridge/real_world.py deleted file mode 100644 index cb7b6c9..0000000 --- a/patterns/structural/bridge/real_world.py +++ /dev/null @@ -1,39 +0,0 @@ -"""``logging``: a Bridge in daily use. - -Logger is the abstraction callers hold; Handlers are the interchangeable -implementation hierarchy on the far side of the bridge. -""" - -from __future__ import annotations - -import logging - - -def logger_with_two_backends(name: str, sink_a: list[str], sink_b: list[str]) -> logging.Logger: - """One abstraction, two implementations receiving the same calls.""" - - def handler_for(sink: list[str]) -> logging.Handler: - class ListHandler(logging.Handler): - def emit(self, record: logging.LogRecord) -> None: - sink.append(record.getMessage()) - - return ListHandler() - - logger = logging.getLogger(name) - logger.handlers.clear() - logger.propagate = False - logger.setLevel(logging.INFO) - logger.addHandler(handler_for(sink_a)) - logger.addHandler(handler_for(sink_b)) - return logger - - -def main() -> None: - a: list[str] = [] - b: list[str] = [] - logger_with_two_backends("bridge-demo", a, b).info("one call") - print(f"backend a: {a}, backend b: {b}") - - -if __name__ == "__main__": - main() diff --git a/patterns/structural/bridge/tests/test_bridge.py b/patterns/structural/bridge/tests/test_bridge.py index 82c37df..a67a1ae 100644 --- a/patterns/structural/bridge/tests/test_bridge.py +++ b/patterns/structural/bridge/tests/test_bridge.py @@ -1,49 +1,48 @@ -"""Behavioral tests for all three bridge variants.""" +"""Behavioral tests for the bridge building block: axes compose freely.""" -from patterns.structural.bridge import naive, pythonic, real_world +from __future__ import annotations +from patterns.structural.bridge import ( + AlertNotifier, + DigestNotifier, + EmailTransport, + SlackTransport, + SmsTransport, +) -class TestNaive: - def test_same_abstraction_different_implementations(self) -> None: - assert naive.Circle(naive.VectorRenderer(), 2.0).draw() == "" - assert "pixels" in naive.Circle(naive.RasterRenderer(), 2.0).draw() +class TestAxesCompose: + def test_any_notifier_works_over_any_transport(self) -> None: + for make_transport in (EmailTransport, SlackTransport, SmsTransport): + transport = make_transport() + AlertNotifier(transport, "ops").alert("critical", "disk full") + DigestNotifier(transport, "ops").digest(["a", "b"]) + # No combination raised: 2 kinds x 3 transports from 5 classes. -class TestPythonic: - def test_one_abstraction_over_two_transports(self) -> None: - slack, email = pythonic.SlackTransport(), pythonic.EmailTransport() - pythonic.AlertNotifier(slack, "#ops").alert("critical", "disk full") - pythonic.AlertNotifier(email, "ops@x.com").alert("critical", "disk full") - assert slack.posts == ["slack #ops: [CRITICAL] disk full"] - assert email.outbox == ["email to ops@x.com: [CRITICAL] disk full"] + def test_alert_formats_severity_upfront(self) -> None: + slack = SlackTransport() + AlertNotifier(slack, "#ops").alert("critical", "db pool exhausted") + assert slack.posts == ["slack #ops: [CRITICAL] db pool exhausted"] - def test_two_abstractions_over_one_transport(self) -> None: - slack = pythonic.SlackTransport() - pythonic.AlertNotifier(slack, "#ops").alert("warn", "slow queries") - pythonic.DigestNotifier(slack, "#ops").digest(["a", "b"]) - assert len(slack.posts) == 2 # both sides vary independently + def test_digest_summarizes_item_count(self) -> None: + email = EmailTransport() + DigestNotifier(email, "team@example.com").digest(["3 deploys", "1 rollback"]) + assert email.outbox == ["email to team@example.com: 2 updates: 3 deploys; 1 rollback"] - def test_transport_specific_behavior_stays_in_the_transport(self) -> None: - sms = pythonic.SmsTransport() - pythonic.AlertNotifier(sms, "+1555").alert("info", "x" * 200) - assert len(sms.messages[0]) <= len("sms +1555: ") + sms.MAX_LEN + def test_sms_transport_truncates_long_texts(self) -> None: + sms = SmsTransport() + AlertNotifier(sms, "+15550100").alert("info", "x" * 200) + (message,) = sms.messages + assert len(message) <= len("sms +15550100: ") + SmsTransport.MAX_LEN - def test_any_duck_typed_transport_works(self) -> None: - class Collector: - def __init__(self) -> None: - self.seen: list[str] = [] - def deliver(self, recipient: str, text: str) -> None: - self.seen.append(text) - - collector = Collector() - pythonic.AlertNotifier(collector, "anyone").alert("info", "hello") - assert collector.seen == ["[INFO] hello"] +class TestBridgeIsOneReference: + def test_a_new_transport_needs_no_notifier_changes(self) -> None: + received: list[tuple[str, str]] = [] + class PagerTransport: + def deliver(self, recipient: str, text: str) -> None: + received.append((recipient, text)) -class TestRealWorld: - def test_one_logger_call_reaches_both_implementations(self) -> None: - a: list[str] = [] - b: list[str] = [] - real_world.logger_with_two_backends("bridge-test", a, b).info("msg") - assert a == ["msg"] and b == ["msg"] + AlertNotifier(PagerTransport(), "oncall").alert("critical", "it's down") + assert received == [("oncall", "[CRITICAL] it's down")] diff --git a/patterns/structural/bridge/tests/test_notification_center.py b/patterns/structural/bridge/tests/test_notification_center.py new file mode 100644 index 0000000..6a757b4 --- /dev/null +++ b/patterns/structural/bridge/tests/test_notification_center.py @@ -0,0 +1,61 @@ +"""Behavioral tests for the notification-center mini-project.""" + +from __future__ import annotations + +import pytest + +from patterns.structural.bridge.examples.notification_center import ( + NotificationCenter, + TeamChannel, +) +from patterns.structural.bridge.examples.notification_center.__main__ import main +from patterns.structural.bridge.pattern import EmailTransport, SlackTransport, SmsTransport + + +def build_center() -> tuple[NotificationCenter, SlackTransport, SmsTransport, EmailTransport]: + slack, sms, email = SlackTransport(), SmsTransport(), EmailTransport() + center = NotificationCenter() + center.register(TeamChannel("platform", slack, "#platform-ops")) + center.register(TeamChannel("payments", sms, "+1-555-0100")) + center.register(TeamChannel("support", email, "support@example.com")) + return center, slack, sms, email + + +class TestRouting: + def test_alerts_reach_only_the_paged_teams(self) -> None: + center, slack, sms, email = build_center() + center.alert(["platform"], "critical", "db pool exhausted") + assert len(slack.posts) == 1 + assert sms.messages == [] + assert email.outbox == [] + + def test_each_team_hears_through_its_own_transport(self) -> None: + center, slack, sms, _ = build_center() + center.alert(["platform", "payments"], "critical", "db pool exhausted") + assert "slack #platform-ops" in slack.posts[0] + assert "sms +1-555-0100" in sms.messages[0] + + def test_digest_broadcasts_to_every_registered_team(self) -> None: + center, slack, sms, email = build_center() + center.broadcast_digest(["3 deploys"]) + assert len(slack.posts) == len(sms.messages) == len(email.outbox) == 1 + + def test_reregistering_a_team_switches_its_transport(self) -> None: + center, slack, _, email = build_center() + center.register(TeamChannel("platform", email, "platform@example.com")) + center.alert(["platform"], "warn", "retrying") + assert slack.posts == [] + assert "platform@example.com" in email.outbox[0] + + def test_teams_lists_registrations(self) -> None: + center, *_ = build_center() + assert center.teams == ["payments", "platform", "support"] + + +class TestDemo: + def test_main_prints_all_three_transports(self, capsys: pytest.CaptureFixture[str]) -> None: + main() + out = capsys.readouterr().out + assert "slack #platform-ops" in out + assert "sms +1-555-0100" in out + assert "email to support@example.com" in out diff --git a/patterns/structural/composite/README.md b/patterns/structural/composite/README.md index f05657c..eff8a1a 100644 --- a/patterns/structural/composite/README.md +++ b/patterns/structural/composite/README.md @@ -14,33 +14,17 @@ stdlib_sightings: [pathlib.Path, xml.etree.ElementTree.Element] # Composite -## Problem - -File systems, GUI widget trees, org charts: structures where a container holds -items that may themselves be containers, and callers want one operation — -size, render, total — that works on any node without asking which kind it is. - -## Naive solution - -`naive.py` mirrors the book: an abstract `Graphic` component, a `Circle` leaf, -and a `Group` composite whose operation recurses over its children. Note the -book's contested move — putting `add`/`remove` on the *component* interface so -leaves must refuse them at runtime. - -## Pythonic solution - -Duck typing removes the need for the abstract base: a leaf and a container -that both offer `total()` are already substitutable. `pythonic.py` keeps a -`Protocol` for the type checker only, and leaves child management where it -honestly belongs — on the container. - -## In the wild - -`pathlib.Path` is the classic: files and directories share one interface, and -`iterdir()`/`rglob()` recurse the composite. `xml.etree.ElementTree.Element` -is a composite of elements all the way down. - -## Verdict - -**Pythonic.** Trees are everywhere and this is the right shape for them; just -keep the leaf's interface honest. +Part-whole trees where a leaf and a whole subtree answer the same operation — +and only containers manage children. **Verdict: pythonic** — the right shape +for trees; keep the leaf's interface honest. + +| Where | What | +|---|---| +| [`pattern/`](pattern/) | The importable code: `Composite`, `HasTotal` | +| [`docs/`](docs/) | [Fundamentals](docs/fundamentals.md) · [Implementation guide](docs/implementation.md) · [External examples](docs/examples.md) | +| [`examples/org_chart/`](examples/org_chart/) | Mini-project: headcount/cost rollups over a nested org chart | +| [`tests/`](tests/) | Behavioral tests for the pattern and the mini-project | + +```bash +uv run python -m patterns.structural.composite.examples.org_chart +``` diff --git a/patterns/structural/composite/__init__.py b/patterns/structural/composite/__init__.py index 5f8a108..4047ce9 100644 --- a/patterns/structural/composite/__init__.py +++ b/patterns/structural/composite/__init__.py @@ -1 +1,8 @@ -"""Composite: one interface for an object and a tree of objects.""" +"""Composite — public API. + +>>> from patterns.structural.composite import Composite +""" + +from patterns.structural.composite.pattern import Composite, HasTotal + +__all__ = ["Composite", "HasTotal"] diff --git a/patterns/structural/composite/docs/examples.md b/patterns/structural/composite/docs/examples.md new file mode 100644 index 0000000..089fb84 --- /dev/null +++ b/patterns/structural/composite/docs/examples.md @@ -0,0 +1,40 @@ +# Composite — where it lives outside this repo + +Cited, real implementations to study (or point an agent at) when designing or +reviewing composite-shaped code. + +## Python standard library + +- **`pathlib.Path`** — files and directories behind one interface; + `iterdir()` walks one level, `rglob()` recurses the whole composite. The + operations every node answers (`exists()`, `stat()`, `name`) coexist with + directory-only ones (`iterdir()`), an interface-honesty compromise worth + studying. [docs.python.org/3/library/pathlib.html](https://docs.python.org/3/library/pathlib.html) +- **`xml.etree.ElementTree.Element`** — elements holding child elements, one + API all the way down; `iter()` is the uniform deep traversal. + [docs.python.org/3/library/xml.etree.elementtree.html](https://docs.python.org/3/library/xml.etree.elementtree.html) +- **`ast`** — Python source as a uniform node tree; `ast.walk` and + `NodeVisitor` traverse without asking node kinds for structure. + [docs.python.org/3/library/ast.html](https://docs.python.org/3/library/ast.html) + +## Major ecosystems + +- **Qt object trees (PyQt/PySide).** Every `QObject` may parent children; + ownership, event propagation, and deletion all recurse the tree — a + composite carrying lifecycle semantics, not just totals. + [doc.qt.io/qt-6/objecttrees.html](https://doc.qt.io/qt-6/objecttrees.html) + +## Design discussion + +- **python-patterns.guide, Composite chapter** — the argument this unit's + caveat encodes: side with interface honesty (child management on + containers only) over the classic form's uniform-but-lying component. + [python-patterns.guide/gang-of-four/composite/](https://python-patterns.guide/gang-of-four/composite/) + +## What to notice across all of them + +None of the production composites make leaves carry child management: +`ElementTree` leaves are just elements with no children, `ast` leaves are +nodes whose fields hold no lists, and Qt children live on the parent. The +uniformity that matters to callers is the *operation* (walk, size, iterate), +not the mutation API — which is exactly the guide's honesty argument. diff --git a/patterns/structural/composite/docs/fundamentals.md b/patterns/structural/composite/docs/fundamentals.md new file mode 100644 index 0000000..b80d978 --- /dev/null +++ b/patterns/structural/composite/docs/fundamentals.md @@ -0,0 +1,73 @@ +# Composite — fundamentals + +## Intent + +Compose objects into part-whole trees, and let clients treat a single object +and a whole composition through one interface. A caller holding "something +with a size" should never need to ask whether it holds a file or a directory +of ten thousand of them. + +## Participants + +| Role | Classic (GoF) form | Python form | +|---|---|---| +| Component | Abstract base declaring the operation — and, contentiously, child management | A `Protocol` with just the operation (`HasTotal` in [`pattern/tree.py`](../pattern/tree.py)) — or nothing at all, duck typing suffices | +| Leaf | Subclass that must *refuse* `add()` at runtime | A plain frozen dataclass with the operation and no child API | +| Composite | Subclass holding children, recursing the operation | `Composite`: children + `add`/`remove` + the same operation | +| Client | Calls the component interface | Same — never asks a node which kind it is | + +## Mechanism + +1. Leaves and containers share one operation (`total()`). +2. A container's implementation combines its children's results; children may + themselves be containers, so the recursion walks the whole tree. +3. Child management (`add`/`remove`) exists only on containers. +4. The client holds "a node" and calls the operation — uniformly at every + depth. + +## The classic form, and what Python absorbs + +The textbook version declares the operation *and child management* on the +abstract component, so leaves must refuse children at runtime: + +```python +class Graphic(ABC): + @abstractmethod + def render(self, indent: int = 0) -> str: ... + + def add(self, child: Graphic) -> None: # on EVERY node... + raise TypeError("cannot hold children") # ...so leaves must refuse + + +class Circle(Graphic): # leaf: inherits the trap + def render(self, indent: int = 0) -> str: ... + + +class Group(Graphic): # composite: overrides add() + ... +``` + +Python absorbs the base class entirely: a leaf and a container that both +offer `total()` are already substitutable, so the shared ABC becomes at most +a `Protocol` for the type checker. That also dissolves the book's dilemma — +python-patterns.guide argues for **interface honesty over uniformity** +([guide chapter](https://python-patterns.guide/gang-of-four/composite/)): +with no forced base class, `add()` simply lives where it's true, on the +container, and a `TypeError`-at-runtime trap never exists. + +## When to use it + +- Genuine part-whole trees: file systems, org charts, GUI widget trees, + nested groupings — anywhere "a thing or a group of things" recurses. +- Callers need one aggregate operation over arbitrary nesting. + +## When not to use it + +- The structure is flat — a list and a `sum()` need no pattern. +- Nodes need many unrelated operations — consider keeping the tree as data + and writing traversals separately (see the Visitor unit's verdict). + +## Verdict: pythonic + +Trees are everywhere and this is the right shape for them. Keep the leaf's +interface honest, and share a base type only when it earns its keep. diff --git a/patterns/structural/composite/docs/implementation.md b/patterns/structural/composite/docs/implementation.md new file mode 100644 index 0000000..1c9ba63 --- /dev/null +++ b/patterns/structural/composite/docs/implementation.md @@ -0,0 +1,79 @@ +# Composite — putting it into a system + +## The smell it fixes + +Type-switching every time a structure nests: + +```python +def org_cost(node): + if isinstance(node, Employee): + return node.salary + if isinstance(node, Department): + total = 0 + for member in node.members: + total += org_cost(member) # and every new node kind edits this + return total +``` + +Every aggregate operation re-implements the traversal, and every new node +kind edits every operation. The composite moves the recursion into the +container once; operations become one method both node kinds answer. + +## Steps + +1. **Pick the rollup value type.** One number is fine; several measures that + should travel together become a small frozen dataclass with `__add__` + (the org example's `OrgMetrics` carries headcount *and* cost in one pass). +2. **Make leaves plain frozen dataclasses** with the operation and nothing + else — no child API, ever. +3. **Use `Composite` for containers** (or subclass it to add a name and + domain methods). Pass its `combine` explicitly — `sum` with a `start` + value is usually all you need. +4. **Keep child mutation on the container** and let `remove` raise on absent + children — silent no-ops hide reorg bugs. +5. **Test the rollups through nesting**, not just one level: build a small + tree in a fixture, assert totals at every depth, and assert leaves have + no `add` (interface honesty is a testable property — + `not hasattr(leaf, "add")`). + +```python +from patterns.structural.composite import Composite + +team = Composite(sum, [Task(3), Task(5)]) +project = Composite(sum, [team, Task(8)]) +assert project.total() == 16 +``` + +## Python idioms that keep it small + +- **`Protocol` instead of an ABC** — the type checker enforces the shared + operation; nodes stay free of inheritance. +- **Frozen dataclass leaves** — hashable, comparable, safe to share between + branches. +- **A metrics dataclass with `__add__`** rolls several measures up in one + traversal instead of one walk per measure. +- **Generators for traversal**: `iter(composite)` walks one level; recursive + generators (`yield from`) give you `rglob`-style deep iteration when you + need node access rather than totals. + +## Pitfalls + +- **Child management on the component interface** — the classic form's trap: + leaves inherit an `add()` they must refuse at runtime. Keep it on the + container only. +- **Parent pointers by default.** They turn a value tree into a mutable graph + with invalidation puzzles; add them only when navigation truly needs them. +- **Unbounded recursion trust.** Deep or user-built trees can hit recursion + limits and cycles; if inputs are hostile, traverse iteratively and track + visited nodes. +- **Mixing structure and presentation** (a `render()` that formats *and* + recurses *and* sorts) — keep the tree operation minimal and format outside. + +## Worked example + +[`examples/org_chart/`](../examples/org_chart/) rolls headcount and annual +cost up a nested org chart — run it with: + +```bash +uv run python -m patterns.structural.composite.examples.org_chart +``` diff --git a/patterns/structural/composite/examples/__init__.py b/patterns/structural/composite/examples/__init__.py new file mode 100644 index 0000000..abc9c1d --- /dev/null +++ b/patterns/structural/composite/examples/__init__.py @@ -0,0 +1 @@ +"""Mini-projects demonstrating the Composite in practice.""" diff --git a/patterns/structural/composite/examples/org_chart/__init__.py b/patterns/structural/composite/examples/org_chart/__init__.py new file mode 100644 index 0000000..c462b5d --- /dev/null +++ b/patterns/structural/composite/examples/org_chart/__init__.py @@ -0,0 +1,12 @@ +"""Org-chart rollups built on the Composite. + +Run it: ``uv run python -m patterns.structural.composite.examples.org_chart`` +""" + +from patterns.structural.composite.examples.org_chart.org import ( + Department, + Employee, + OrgMetrics, +) + +__all__ = ["Department", "Employee", "OrgMetrics"] diff --git a/patterns/structural/composite/examples/org_chart/__main__.py b/patterns/structural/composite/examples/org_chart/__main__.py new file mode 100644 index 0000000..835f273 --- /dev/null +++ b/patterns/structural/composite/examples/org_chart/__main__.py @@ -0,0 +1,25 @@ +"""Demo: headcount and cost rollups over a nested org chart.""" + +from __future__ import annotations + +from patterns.structural.composite.examples.org_chart.org import Department, Employee + + +def main() -> None: + platform = Department( + "platform", + [Employee("Ada", 190_000), Employee("Grace", 185_000)], + ) + payments = Department( + "payments", + [Employee("Alan", 175_000), platform], # a department inside a department + ) + company = Department("engineering", [payments, Employee("Barbara", 210_000)]) + + for unit in (platform, payments, company): + metrics = unit.total() + print(f"{unit.name}: {metrics.headcount} people, ${metrics.annual_cost:,}/yr") + + +if __name__ == "__main__": + main() diff --git a/patterns/structural/composite/examples/org_chart/org.py b/patterns/structural/composite/examples/org_chart/org.py new file mode 100644 index 0000000..49f3e34 --- /dev/null +++ b/patterns/structural/composite/examples/org_chart/org.py @@ -0,0 +1,51 @@ +"""Departments hold teams hold people; one ``total()`` serves every level. + +The interface-honesty rule in practice: ``Employee`` is a frozen leaf with no +child management — only ``Department`` (a ``Composite``) can ``add``/``remove``. +Both answer ``total()``, so headcount and cost roll up through any nesting +without ever asking a node what kind it is. +""" + +from __future__ import annotations + +from collections.abc import Iterable +from dataclasses import dataclass + +from patterns.structural.composite.pattern import Composite, HasTotal + + +@dataclass(frozen=True) +class OrgMetrics: + """The rollup value: both measures travel up the tree together.""" + + headcount: int + annual_cost: int + + def __add__(self, other: OrgMetrics) -> OrgMetrics: + return OrgMetrics(self.headcount + other.headcount, self.annual_cost + other.annual_cost) + + +ZERO = OrgMetrics(0, 0) + + +def combine(parts: Iterable[OrgMetrics]) -> OrgMetrics: + return sum(parts, start=ZERO) + + +@dataclass(frozen=True) +class Employee: + """A leaf. No ``add()`` — people honestly cannot hold reports here.""" + + name: str + salary: int + + def total(self) -> OrgMetrics: + return OrgMetrics(headcount=1, annual_cost=self.salary) + + +class Department(Composite[OrgMetrics]): + """A named container node; child management lives here, where it belongs.""" + + def __init__(self, name: str, members: Iterable[HasTotal[OrgMetrics]] = ()) -> None: + super().__init__(combine, members) + self.name = name diff --git a/patterns/structural/composite/naive.py b/patterns/structural/composite/naive.py deleted file mode 100644 index de1738d..0000000 --- a/patterns/structural/composite/naive.py +++ /dev/null @@ -1,60 +0,0 @@ -"""The Gang of Four Composite, translated literally. - -Abstract component, leaf, and composite -- including the book's contested -choice of declaring child management on the component so the leaf must -refuse it at runtime. -""" - -from __future__ import annotations - -from abc import ABC, abstractmethod - - -class Graphic(ABC): - """The component interface every node implements.""" - - @abstractmethod - def render(self, indent: int = 0) -> str: ... - - def add(self, child: Graphic) -> None: - raise TypeError(f"{type(self).__name__} cannot hold children") - - -class Circle(Graphic): - """A leaf: no children, and add() raises per the base default.""" - - def __init__(self, name: str) -> None: - self.name = name - - def render(self, indent: int = 0) -> str: - return " " * indent + f"circle({self.name})" - - -class Group(Graphic): - """A composite: renders by recursing over children.""" - - def __init__(self, name: str) -> None: - self.name = name - self._children: list[Graphic] = [] - - def add(self, child: Graphic) -> None: - self._children.append(child) - - def render(self, indent: int = 0) -> str: - lines = [" " * indent + f"group({self.name})"] - lines.extend(child.render(indent + 2) for child in self._children) - return "\n".join(lines) - - -def main() -> None: - scene = Group("scene") - scene.add(Circle("sun")) - inner = Group("cluster") - inner.add(Circle("a")) - inner.add(Circle("b")) - scene.add(inner) - print(scene.render()) - - -if __name__ == "__main__": - main() diff --git a/patterns/structural/composite/pattern/__init__.py b/patterns/structural/composite/pattern/__init__.py new file mode 100644 index 0000000..8d9e6e4 --- /dev/null +++ b/patterns/structural/composite/pattern/__init__.py @@ -0,0 +1,5 @@ +"""The Composite pattern, importable as library code.""" + +from patterns.structural.composite.pattern.tree import Composite, HasTotal + +__all__ = ["Composite", "HasTotal"] diff --git a/patterns/structural/composite/pattern/tree.py b/patterns/structural/composite/pattern/tree.py new file mode 100644 index 0000000..fc7ae0b --- /dev/null +++ b/patterns/structural/composite/pattern/tree.py @@ -0,0 +1,52 @@ +"""Composite as an importable, typed building block — with honest interfaces. + +A tree node is anything with ``total() -> V``; leaves are your own frozen +domain objects. ``Composite`` is the one container: it manages children +(that's where ``add``/``remove`` honestly belong — never on leaves) and rolls +totals up by combining its children's. Any value that can be summed works as +``V`` — an ``int``, or a metrics dataclass with ``__add__``. +""" + +from __future__ import annotations + +from collections.abc import Callable, Iterable, Iterator +from typing import Generic, Protocol, TypeVar + +V = TypeVar("V") +V_co = TypeVar("V_co", covariant=True) + + +class HasTotal(Protocol[V_co]): + """What every node — leaf or subtree — must offer: one rollup value.""" + + def total(self) -> V_co: ... + + +class Composite(Generic[V]): + """A container node: holds children, rolls their totals up.""" + + def __init__( + self, + combine: Callable[[Iterable[V]], V], + children: Iterable[HasTotal[V]] = (), + ) -> None: + self._combine = combine + self._children: list[HasTotal[V]] = list(children) + + def add(self, child: HasTotal[V]) -> None: + """Child management lives here, on the container — not on leaves.""" + self._children.append(child) + + def remove(self, child: HasTotal[V]) -> None: + """Remove a direct child; ``ValueError`` if it is not one.""" + self._children.remove(child) + + def total(self) -> V: + """Same interface as a leaf: callers never ask which kind they hold.""" + return self._combine(child.total() for child in self._children) + + def __iter__(self) -> Iterator[HasTotal[V]]: + return iter(self._children) + + def __len__(self) -> int: + return len(self._children) diff --git a/patterns/structural/composite/pythonic.py b/patterns/structural/composite/pythonic.py deleted file mode 100644 index 8434007..0000000 --- a/patterns/structural/composite/pythonic.py +++ /dev/null @@ -1,53 +0,0 @@ -"""Composite with duck typing: no abstract base, honest interfaces. - -The leaf and the container simply share a method. A ``Protocol`` gives the -type checker the same guarantee the ABC gave, without forcing leaves to -inherit -- or to carry child management they cannot honor. -""" - -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import Protocol - - -class Sized(Protocol): - def total_bytes(self) -> int: ... - - -@dataclass(frozen=True) -class File: - """A leaf. It has no add() -- files honestly cannot hold children.""" - - name: str - size: int - - def total_bytes(self) -> int: - return self.size - - -@dataclass -class Directory: - """A composite. Child management lives here, where it belongs.""" - - name: str - entries: list[Sized] = field(default_factory=list) - - def add(self, entry: Sized) -> None: - self.entries.append(entry) - - def total_bytes(self) -> int: - return sum(entry.total_bytes() for entry in self.entries) - - -def main() -> None: - root = Directory("root") - root.add(File("a.txt", 100)) - sub = Directory("sub") - sub.add(File("b.bin", 400)) - root.add(sub) - print(f"total: {root.total_bytes()} bytes") - - -if __name__ == "__main__": - main() diff --git a/patterns/structural/composite/real_world.py b/patterns/structural/composite/real_world.py deleted file mode 100644 index ec002cd..0000000 --- a/patterns/structural/composite/real_world.py +++ /dev/null @@ -1,34 +0,0 @@ -"""The stdlib's composite: ``xml.etree.ElementTree``. - -An ``Element`` holds child ``Element`` objects; ``iter()`` walks the whole -tree through one interface, never asking a node whether it is a leaf. -(``pathlib.Path`` is the same idea over the file system.) -""" - -from __future__ import annotations - -import xml.etree.ElementTree as ET - - -def build_scene() -> ET.Element: - scene = ET.Element("scene") - ET.SubElement(scene, "circle", name="sun") - cluster = ET.SubElement(scene, "group", name="cluster") - ET.SubElement(cluster, "circle", name="a") - ET.SubElement(cluster, "circle", name="b") - return scene - - -def count_circles(root: ET.Element) -> int: - """One recursive traversal, uniform over leaves and containers.""" - return sum(1 for _ in root.iter("circle")) - - -def main() -> None: - scene = build_scene() - print(ET.tostring(scene, encoding="unicode")) - print(f"circles in tree: {count_circles(scene)}") - - -if __name__ == "__main__": - main() diff --git a/patterns/structural/composite/tests/test_composite.py b/patterns/structural/composite/tests/test_composite.py index 4965088..685ca49 100644 --- a/patterns/structural/composite/tests/test_composite.py +++ b/patterns/structural/composite/tests/test_composite.py @@ -1,40 +1,63 @@ -"""Behavioral tests for all three composite variants.""" +"""Behavioral tests for the Composite building block.""" + +from __future__ import annotations + +from dataclasses import dataclass import pytest -from patterns.structural.composite import naive, pythonic, real_world +from patterns.structural.composite import Composite + + +@dataclass(frozen=True) +class Task: + hours: int + + def total(self) -> int: + return self.hours + + +class TestRollup: + def test_a_container_totals_its_leaves(self) -> None: + team = Composite[int](sum, [Task(3), Task(5)]) + assert team.total() == 8 + def test_nesting_rolls_up_through_every_level(self) -> None: + team = Composite[int](sum, [Task(3), Task(5)]) + project = Composite[int](sum, [team, Task(8)]) + portfolio = Composite[int](sum, [project]) + assert portfolio.total() == 16 -class TestNaive: - def test_nested_render_recurses(self) -> None: - scene = naive.Group("scene") - scene.add(naive.Circle("sun")) - inner = naive.Group("g") - inner.add(naive.Circle("a")) - scene.add(inner) - assert scene.render() == "group(scene)\n circle(sun)\n group(g)\n circle(a)" + def test_an_empty_container_totals_the_combine_identity(self) -> None: + assert Composite[int](sum).total() == 0 - def test_leaf_refuses_children(self) -> None: - with pytest.raises(TypeError): - naive.Circle("sun").add(naive.Circle("moon")) + def test_leaf_and_subtree_are_interchangeable_to_callers(self) -> None: + def describe(node: Task | Composite[int]) -> str: + return f"{node.total()}h" # never asks which kind it holds + assert describe(Task(4)) == "4h" + assert describe(Composite[int](sum, [Task(4)])) == "4h" -class TestPythonic: - def test_totals_recurse_through_nesting(self) -> None: - root = pythonic.Directory("root") - root.add(pythonic.File("a", 100)) - sub = pythonic.Directory("sub") - sub.add(pythonic.File("b", 400)) - root.add(sub) - assert root.total_bytes() == 500 - def test_leaf_has_no_child_management(self) -> None: - assert not hasattr(pythonic.File("a", 1), "add") +class TestHonestInterfaces: + def test_child_management_lives_only_on_the_container(self) -> None: + assert not hasattr(Task(1), "add") + assert not hasattr(Task(1), "remove") - def test_empty_directory_totals_zero(self) -> None: - assert pythonic.Directory("empty").total_bytes() == 0 + def test_add_and_remove_change_the_rollup(self) -> None: + team = Composite[int](sum, [Task(3)]) + extra = Task(5) + team.add(extra) + assert team.total() == 8 + team.remove(extra) + assert team.total() == 3 + def test_removing_a_stranger_raises(self) -> None: + with pytest.raises(ValueError): + Composite[int](sum).remove(Task(1)) -class TestRealWorld: - def test_uniform_traversal_counts_all_depths(self) -> None: - assert real_world.count_circles(real_world.build_scene()) == 3 + def test_iteration_walks_direct_children_in_order(self) -> None: + first, second = Task(1), Task(2) + team = Composite[int](sum, [first, second]) + assert list(team) == [first, second] + assert len(team) == 2 diff --git a/patterns/structural/composite/tests/test_org_chart.py b/patterns/structural/composite/tests/test_org_chart.py new file mode 100644 index 0000000..093f1a3 --- /dev/null +++ b/patterns/structural/composite/tests/test_org_chart.py @@ -0,0 +1,52 @@ +"""Behavioral tests for the org-chart mini-project.""" + +from __future__ import annotations + +import pytest + +from patterns.structural.composite.examples.org_chart import ( + Department, + Employee, + OrgMetrics, +) +from patterns.structural.composite.examples.org_chart.__main__ import main + + +def build_company() -> tuple[Department, Department]: + platform = Department("platform", [Employee("Ada", 190_000), Employee("Grace", 185_000)]) + company = Department("engineering", [platform, Employee("Barbara", 210_000)]) + return company, platform + + +class TestRollups: + def test_both_measures_travel_up_in_one_pass(self) -> None: + company, _ = build_company() + assert company.total() == OrgMetrics(headcount=3, annual_cost=585_000) + + def test_a_subtree_reports_only_its_own_people(self) -> None: + _, platform = build_company() + assert platform.total() == OrgMetrics(headcount=2, annual_cost=375_000) + + def test_an_empty_department_is_zero_not_an_error(self) -> None: + assert Department("new-team").total() == OrgMetrics(0, 0) + + def test_a_reorg_moves_cost_between_departments(self) -> None: + company, platform = build_company() + hire = Employee("Edsger", 200_000) + platform.add(hire) + assert company.total().headcount == 4 + platform.remove(hire) + assert company.total().headcount == 3 + + +class TestHonesty: + def test_employees_cannot_hold_reports(self) -> None: + assert not hasattr(Employee("Ada", 190_000), "add") + + +class TestDemo: + def test_main_prints_rollups_per_level(self, capsys: pytest.CaptureFixture[str]) -> None: + main() + out = capsys.readouterr().out + assert "platform: 2 people" in out + assert "engineering: 4 people" in out From 1dc62a0d014b18abfe04d251468de84890a8cf90 Mon Sep 17 00:00:00 2001 From: SuperElectron Date: Thu, 27 Aug 2026 11:55:22 -0700 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20v2=20migration=20=E2=80=94=20decora?= =?UTF-8?q?tor,=20facade,=20flyweight,=20proxy=20as=20modules?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Structural batch 2/2 of the patterns-as-modules refactor: - decorator: pattern/ ships logged/timed/retry/rate_limited (ParamSpec-typed, effects injected); examples/resilient_client stacks them on a flaky client and pins the ordering policy - facade: place_order checkout facade promoted to pattern/ (rollback intact, honest saga boundary); examples/order_checkout batch-processes orders through the one door - flyweight: pattern/ ships InternPool with an immutability guard; examples/glyph_styles holds ~30k glyphs on 2 shared styles - proxy: pattern/ ships stackable LazyProxy/ProtectionProxy/MeteringProxy; examples/db_gateway composes all three over one expensive connection - each unit: docs/{fundamentals,implementation,examples}.md with the classic-form contrast and cited external usages; legacy variant files and tests removed Co-Authored-By: Claude Fable 5 --- patterns/structural/decorator/README.md | 44 ++---- patterns/structural/decorator/__init__.py | 15 +- .../structural/decorator/docs/examples.md | 39 +++++ .../structural/decorator/docs/fundamentals.md | 77 ++++++++++ .../decorator/docs/implementation.md | 72 +++++++++ .../structural/decorator/examples/__init__.py | 1 + .../examples/resilient_client/__init__.py | 14 ++ .../examples/resilient_client/__main__.py | 19 +++ .../examples/resilient_client/client.py | 25 ++++ .../examples/resilient_client/service.py | 38 +++++ patterns/structural/decorator/naive.py | 42 ------ .../structural/decorator/pattern/__init__.py | 11 ++ .../decorator/pattern/decorators.py | 139 ++++++++++++++++++ patterns/structural/decorator/pythonic.py | 61 -------- patterns/structural/decorator/real_world.py | 25 ---- .../decorator/tests/test_decorator.py | 49 ------ .../decorator/tests/test_decorators.py | 124 ++++++++++++++++ .../decorator/tests/test_resilient_client.py | 54 +++++++ patterns/structural/facade/README.md | 42 ++---- patterns/structural/facade/__init__.py | 23 ++- patterns/structural/facade/docs/examples.md | 31 ++++ .../structural/facade/docs/fundamentals.md | 70 +++++++++ .../structural/facade/docs/implementation.md | 62 ++++++++ .../structural/facade/examples/__init__.py | 1 + .../examples/order_checkout/__init__.py | 11 ++ .../examples/order_checkout/__main__.py | 28 ++++ .../facade/examples/order_checkout/store.py | 67 +++++++++ patterns/structural/facade/naive.py | 53 ------- .../structural/facade/pattern/__init__.py | 19 +++ .../{pythonic.py => pattern/checkout.py} | 27 +--- patterns/structural/facade/real_world.py | 31 ---- .../structural/facade/tests/test_checkout.py | 86 +++++++++++ .../structural/facade/tests/test_facade.py | 96 ------------ .../facade/tests/test_order_checkout.py | 45 ++++++ patterns/structural/flyweight/README.md | 41 ++---- patterns/structural/flyweight/__init__.py | 9 +- .../structural/flyweight/docs/examples.md | 32 ++++ .../structural/flyweight/docs/fundamentals.md | 72 +++++++++ .../flyweight/docs/implementation.md | 68 +++++++++ .../structural/flyweight/examples/__init__.py | 1 + .../examples/glyph_styles/__init__.py | 12 ++ .../examples/glyph_styles/__main__.py | 23 +++ .../examples/glyph_styles/document.py | 61 ++++++++ patterns/structural/flyweight/naive.py | 46 ------ .../structural/flyweight/pattern/__init__.py | 5 + patterns/structural/flyweight/pattern/pool.py | 64 ++++++++ patterns/structural/flyweight/pythonic.py | 53 ------- patterns/structural/flyweight/real_world.py | 32 ---- .../flyweight/tests/test_flyweight.py | 35 ----- .../flyweight/tests/test_glyph_styles.py | 40 +++++ .../structural/flyweight/tests/test_pool.py | 55 +++++++ patterns/structural/proxy/README.md | 42 ++---- patterns/structural/proxy/__init__.py | 13 +- patterns/structural/proxy/docs/examples.md | 40 +++++ .../structural/proxy/docs/fundamentals.md | 76 ++++++++++ .../structural/proxy/docs/implementation.md | 67 +++++++++ .../structural/proxy/examples/__init__.py | 1 + .../proxy/examples/db_gateway/__init__.py | 11 ++ .../proxy/examples/db_gateway/__main__.py | 24 +++ .../proxy/examples/db_gateway/gateway.py | 49 ++++++ patterns/structural/proxy/naive.py | 49 ------ patterns/structural/proxy/pattern/__init__.py | 9 ++ patterns/structural/proxy/pattern/proxies.py | 61 ++++++++ patterns/structural/proxy/pythonic.py | 60 -------- patterns/structural/proxy/real_world.py | 40 ----- .../structural/proxy/tests/test_db_gateway.py | 45 ++++++ .../structural/proxy/tests/test_proxies.py | 76 ++++++++++ patterns/structural/proxy/tests/test_proxy.py | 48 ------ 68 files changed, 2040 insertions(+), 861 deletions(-) create mode 100644 patterns/structural/decorator/docs/examples.md create mode 100644 patterns/structural/decorator/docs/fundamentals.md create mode 100644 patterns/structural/decorator/docs/implementation.md create mode 100644 patterns/structural/decorator/examples/__init__.py create mode 100644 patterns/structural/decorator/examples/resilient_client/__init__.py create mode 100644 patterns/structural/decorator/examples/resilient_client/__main__.py create mode 100644 patterns/structural/decorator/examples/resilient_client/client.py create mode 100644 patterns/structural/decorator/examples/resilient_client/service.py delete mode 100644 patterns/structural/decorator/naive.py create mode 100644 patterns/structural/decorator/pattern/__init__.py create mode 100644 patterns/structural/decorator/pattern/decorators.py delete mode 100644 patterns/structural/decorator/pythonic.py delete mode 100644 patterns/structural/decorator/real_world.py delete mode 100644 patterns/structural/decorator/tests/test_decorator.py create mode 100644 patterns/structural/decorator/tests/test_decorators.py create mode 100644 patterns/structural/decorator/tests/test_resilient_client.py create mode 100644 patterns/structural/facade/docs/examples.md create mode 100644 patterns/structural/facade/docs/fundamentals.md create mode 100644 patterns/structural/facade/docs/implementation.md create mode 100644 patterns/structural/facade/examples/__init__.py create mode 100644 patterns/structural/facade/examples/order_checkout/__init__.py create mode 100644 patterns/structural/facade/examples/order_checkout/__main__.py create mode 100644 patterns/structural/facade/examples/order_checkout/store.py delete mode 100644 patterns/structural/facade/naive.py create mode 100644 patterns/structural/facade/pattern/__init__.py rename patterns/structural/facade/{pythonic.py => pattern/checkout.py} (81%) delete mode 100644 patterns/structural/facade/real_world.py create mode 100644 patterns/structural/facade/tests/test_checkout.py delete mode 100644 patterns/structural/facade/tests/test_facade.py create mode 100644 patterns/structural/facade/tests/test_order_checkout.py create mode 100644 patterns/structural/flyweight/docs/examples.md create mode 100644 patterns/structural/flyweight/docs/fundamentals.md create mode 100644 patterns/structural/flyweight/docs/implementation.md create mode 100644 patterns/structural/flyweight/examples/__init__.py create mode 100644 patterns/structural/flyweight/examples/glyph_styles/__init__.py create mode 100644 patterns/structural/flyweight/examples/glyph_styles/__main__.py create mode 100644 patterns/structural/flyweight/examples/glyph_styles/document.py delete mode 100644 patterns/structural/flyweight/naive.py create mode 100644 patterns/structural/flyweight/pattern/__init__.py create mode 100644 patterns/structural/flyweight/pattern/pool.py delete mode 100644 patterns/structural/flyweight/pythonic.py delete mode 100644 patterns/structural/flyweight/real_world.py delete mode 100644 patterns/structural/flyweight/tests/test_flyweight.py create mode 100644 patterns/structural/flyweight/tests/test_glyph_styles.py create mode 100644 patterns/structural/flyweight/tests/test_pool.py create mode 100644 patterns/structural/proxy/docs/examples.md create mode 100644 patterns/structural/proxy/docs/fundamentals.md create mode 100644 patterns/structural/proxy/docs/implementation.md create mode 100644 patterns/structural/proxy/examples/__init__.py create mode 100644 patterns/structural/proxy/examples/db_gateway/__init__.py create mode 100644 patterns/structural/proxy/examples/db_gateway/__main__.py create mode 100644 patterns/structural/proxy/examples/db_gateway/gateway.py delete mode 100644 patterns/structural/proxy/naive.py create mode 100644 patterns/structural/proxy/pattern/__init__.py create mode 100644 patterns/structural/proxy/pattern/proxies.py delete mode 100644 patterns/structural/proxy/pythonic.py delete mode 100644 patterns/structural/proxy/real_world.py create mode 100644 patterns/structural/proxy/tests/test_db_gateway.py create mode 100644 patterns/structural/proxy/tests/test_proxies.py delete mode 100644 patterns/structural/proxy/tests/test_proxy.py diff --git a/patterns/structural/decorator/README.md b/patterns/structural/decorator/README.md index 9ee0248..b85a755 100644 --- a/patterns/structural/decorator/README.md +++ b/patterns/structural/decorator/README.md @@ -15,33 +15,17 @@ stdlib_sightings: [functools.wraps, functools.lru_cache, contextlib.contextmanag # Decorator -## Problem - -You want cross-cutting behavior — logging, caching, retries, access control — -around existing behavior, without editing the original and without a subclass -per combination. - -## Naive solution - -`naive.py` is the GoF object wrapper: a class that holds the wrapped object, -adds its twist, and forwards everything else. Faithful, and it carries the -book's real cost — you must forward *every* method, and the wrapper still -fails `isinstance` checks against the original. - -## Pythonic solution - -For callables, the language absorbed the pattern into `@decorator` syntax. -`pythonic.py` builds a proper function decorator (with `functools.wraps`) and -a parameterized one — the three-layer form that trips everyone up once. - -## In the wild - -`functools.lru_cache` is a decorator adding caching; `functools.wraps` is a -decorator that fixes decorators; `contextlib.contextmanager` turns a generator -into a context manager. You use this pattern daily whether you notice or not. - -## Verdict - -**Pythonic** — for callables, idiomatically so. GoF-style object wrapping is -rarer; when you need it, `__getattr__` forwarding (shown in `naive.py`) keeps -it tolerable. +Add one cross-cutting concern at a time — logging, timing, retries, limits — +by wrapping, then stack the wrappers. **Verdict: pythonic** — for callables the +language absorbed the pattern into `@decorator` syntax. + +| Where | What | +|---|---| +| [`pattern/`](pattern/) | The importable code: `logged`, `timed`, `retry`, `rate_limited` | +| [`docs/`](docs/) | [Fundamentals](docs/fundamentals.md) · [Implementation guide](docs/implementation.md) · [External examples](docs/examples.md) | +| [`examples/resilient_client/`](examples/resilient_client/) | Mini-project: a flaky API client hardened by stacking `pattern/` decorators | +| [`tests/`](tests/) | Behavioral tests for the pattern and the mini-project | + +```bash +uv run python -m patterns.structural.decorator.examples.resilient_client +``` diff --git a/patterns/structural/decorator/__init__.py b/patterns/structural/decorator/__init__.py index 51ffe68..19b1ae6 100644 --- a/patterns/structural/decorator/__init__.py +++ b/patterns/structural/decorator/__init__.py @@ -1 +1,14 @@ -"""Decorator: add behavior around objects or callables without editing them.""" +"""Decorator — public API. + +>>> from patterns.structural.decorator import retry +""" + +from patterns.structural.decorator.pattern import ( + RateLimitExceededError, + logged, + rate_limited, + retry, + timed, +) + +__all__ = ["RateLimitExceededError", "logged", "rate_limited", "retry", "timed"] diff --git a/patterns/structural/decorator/docs/examples.md b/patterns/structural/decorator/docs/examples.md new file mode 100644 index 0000000..b5614d1 --- /dev/null +++ b/patterns/structural/decorator/docs/examples.md @@ -0,0 +1,39 @@ +# Decorator — where it lives outside this repo + +Cited, real implementations to study (or point an agent at) when designing or +reviewing decorator-shaped code. + +## Python standard library + +- **`functools.lru_cache` / `functools.cache`.** Memoization as a decorator — + wrap a function, gain a cache and `cache_info()` statistics. The pattern + shipping in the box. + [docs.python.org/3/library/functools.html#functools.lru_cache](https://docs.python.org/3/library/functools.html#functools.lru_cache) +- **`functools.wraps`.** A decorator whose only job is making other decorators + honest — it copies the wrapped function's identity onto the wrapper. + [docs.python.org/3/library/functools.html#functools.wraps](https://docs.python.org/3/library/functools.html#functools.wraps) +- **`contextlib.contextmanager`.** Wraps a generator into a context manager — + a decorator that changes the *kind* of the thing it wraps. + [docs.python.org/3/library/contextlib.html#contextlib.contextmanager](https://docs.python.org/3/library/contextlib.html#contextlib.contextmanager) + +## Major ecosystems + +- **Flask routing.** `@app.route("/path")` registers view functions into the + URL map at definition site — decorator as registration API. + [flask.palletsprojects.com/en/stable/quickstart/#routing](https://flask.palletsprojects.com/en/stable/quickstart/#routing) +- **Django's `@login_required`.** Access control layered onto views without + touching them. + [docs.djangoproject.com/en/stable/topics/auth/default/#the-login-required-decorator](https://docs.djangoproject.com/en/stable/topics/auth/default/#the-login-required-decorator) +- **`tenacity`.** Production retry policies (backoff, jitter, stop conditions) + stacked onto callables — this unit's `retry` grown up. + [tenacity.readthedocs.io](https://tenacity.readthedocs.io/) +- **`click`.** Whole CLIs built by stacking `@click.command` and + `@click.option` — decorators composing a program's surface. + [click.palletsprojects.com](https://click.palletsprojects.com/) + +## What to notice across all of them + +Every one preserves the wrapped callable's contract (arguments in, result +out) and adds exactly one concern beside it. And every serious one calls +`functools.wraps` — check for it first when reviewing any hand-rolled +decorator. diff --git a/patterns/structural/decorator/docs/fundamentals.md b/patterns/structural/decorator/docs/fundamentals.md new file mode 100644 index 0000000..3a80d97 --- /dev/null +++ b/patterns/structural/decorator/docs/fundamentals.md @@ -0,0 +1,77 @@ +# Decorator — fundamentals + +## Intent + +Attach responsibilities to an object or callable dynamically, without editing +the original and without a subclass per combination. Wrapping composes: +logging-around-retry-around-caching is three small pieces, not one class named +`LoggingRetryingCachingClient`. + +## Participants + +| Role | Classic (GoF) form | Python form | +|---|---|---| +| Component | Abstract interface both sides implement | Any callable (or any object, for the wrapping form) | +| Concrete component | The real object | The function being decorated | +| Decorator | Abstract wrapper holding a component | A factory returning a closure — see [`pattern/decorators.py`](../pattern/decorators.py) | +| Concrete decorators | One subclass per added concern | `logged`, `timed`, `retry`, `rate_limited` | + +## Mechanism + +1. A decorator takes the component, returns something with the same interface. +2. The wrapper adds its one concern before/after delegating inward. +3. Wrappers stack; order is meaningful and chosen at composition time. +4. `functools.wraps` copies identity (`__name__`, `__doc__`, signature) so the + stack stays introspectable. + +## The classic form, and what Python absorbs + +Two related shapes share the name. The GoF book wraps *objects* — a class +holding the wrapped instance, augmenting some methods, forwarding the rest: + +```python +class LoggingWriter: + """Wraps a file-like object; counts writes, forwards the rest.""" + + def __init__(self, wrapped: TextIO) -> None: + self._wrapped = wrapped + self.writes = 0 + + def write(self, text: str) -> int: # the augmented method + self.writes += 1 + return self._wrapped.write(text) + + def __getattr__(self, name: str) -> Any: # wholesale forwarding + return getattr(self._wrapped, name) +``` + +`__getattr__` already softens the book's forward-every-method tax — but the +wrapper still fails `isinstance` against the wrapped type (the guide's +caveat: wrapping doesn't make you the wrapped thing). + +For *callables*, Python absorbed the pattern into syntax: `@decorator` above a +`def` is the whole class diagram in one line. This module's +[`pattern/`](../pattern/) ships that form, because it is the one you compose +daily. See the guide chapter: +[python-patterns.guide/gang-of-four/decorator-pattern](https://python-patterns.guide/gang-of-four/decorator-pattern/). + +## When to use it + +- A cross-cutting concern (logging, retries, caching, limits, auth) recurs + around many call sites. +- You need concerns in different combinations per call site — stacking beats + a subclass lattice. + +## When not to use it + +- The behavior belongs to the function itself → just write it in the function. +- You need to intercept *every* attribute of a rich object → that's a Proxy + problem; see `structural/proxy`. +- One lazily computed value → `functools.cached_property`. + +## Verdict: pythonic + +For callables the pattern is idiomatic Python — the syntax exists for it. +Always apply `functools.wraps`; without it the stack destroys the wrapped +function's identity. Object wrapping is rarer: reach for it only when the +wrapped surface is wide and `__getattr__` forwarding keeps it honest. diff --git a/patterns/structural/decorator/docs/implementation.md b/patterns/structural/decorator/docs/implementation.md new file mode 100644 index 0000000..28c334d --- /dev/null +++ b/patterns/structural/decorator/docs/implementation.md @@ -0,0 +1,72 @@ +# Decorator — putting it into a system + +## The smell it fixes + +The same guard-and-report scaffolding pasted around every meaningful call: + +```python +def charge(card, amount): + log.info("charging...") + for attempt in range(3): + try: + result = api.charge(card, amount) + break + except ConnectionError: + if attempt == 2: + raise + log.info("charged") + return result +``` + +Business logic is one line; the other nine are concerns that belong to +everyone and therefore to no one. Each becomes a decorator written once. + +## Steps + +1. **Name each concern** hiding in the scaffolding: retry, log, time, limit. +2. **Write each as a decorator factory** `(config) -> (func) -> wrapper`, with + `functools.wraps` on every wrapper. Type with `ParamSpec` so the wrapped + signature survives type checking. +3. **Inject effects** (clock, sleep, log sink) as factory parameters with real + defaults — the decorators stay deterministic under test. +4. **Choose the stacking order deliberately**, and write it down where you + compose: retry innermost (each attempt hugs the call), observability + outside it (one line per *operation*), admission control outermost + (rejected calls cost nothing). A different policy is legitimate — but it + should be a decision, not an accident of paste order. +5. **Pin the order with a test.** Stacks are policy; swapping two layers must + fail a test, not a production incident. + +## Python idioms that keep it small + +- `@decorator` syntax at definition site when a function is always wrapped; + explicit `wrapped = deco(func)` at composition site when the policy varies + per use — [`examples/resilient_client/`](../examples/resilient_client/) + uses the second form. +- Parameterized decorators are three nested functions; that's the ceiling. + If you're four deep, refactor to a class with `__call__`. +- `functools.wraps` is non-negotiable — it is itself a decorator fixing + decorators, and every tool that inspects signatures depends on it. + +## Pitfalls + +- **Forgetting `functools.wraps`** — the wrapped function's name, docstring, + and signature vanish; stack traces and debuggers lie. +- **Order accidents.** `retry(logged(f))` logs once per attempt; + `logged(retry(f))` logs once per operation. Both are useful; only one is + what you meant. +- **Decorators that swallow exceptions** turn control flow invisible; add + behavior around the call, don't change its contract. +- **Hidden effects** (module-level clocks, global sleeps) make wrapped code + untestable; inject them. +- **State on the wrapper** (`wrapper.calls += 1`) needs a `type: ignore` under + strict typing — prefer a sink/callback the caller owns. + +## Worked example + +[`examples/resilient_client/`](../examples/resilient_client/) hardens a flaky +payments client with the full stack and pins the ordering policy in tests: + +```bash +uv run python -m patterns.structural.decorator.examples.resilient_client +``` diff --git a/patterns/structural/decorator/examples/__init__.py b/patterns/structural/decorator/examples/__init__.py new file mode 100644 index 0000000..bb5582b --- /dev/null +++ b/patterns/structural/decorator/examples/__init__.py @@ -0,0 +1 @@ +"""Mini-projects demonstrating the Decorator pattern in practice.""" diff --git a/patterns/structural/decorator/examples/resilient_client/__init__.py b/patterns/structural/decorator/examples/resilient_client/__init__.py new file mode 100644 index 0000000..f3b12c3 --- /dev/null +++ b/patterns/structural/decorator/examples/resilient_client/__init__.py @@ -0,0 +1,14 @@ +"""A flaky API client hardened by stacking decorators. + +Run it: ``uv run python -m patterns.structural.decorator.examples.resilient_client`` +""" + +from patterns.structural.decorator.examples.resilient_client.client import ( + FlakyPaymentAPI, + TransientNetworkError, +) +from patterns.structural.decorator.examples.resilient_client.service import ( + build_charge, +) + +__all__ = ["FlakyPaymentAPI", "TransientNetworkError", "build_charge"] diff --git a/patterns/structural/decorator/examples/resilient_client/__main__.py b/patterns/structural/decorator/examples/resilient_client/__main__.py new file mode 100644 index 0000000..b007c9f --- /dev/null +++ b/patterns/structural/decorator/examples/resilient_client/__main__.py @@ -0,0 +1,19 @@ +"""Demo: two charges against an API that fails twice before recovering.""" + +from __future__ import annotations + +from patterns.structural.decorator.examples.resilient_client.client import FlakyPaymentAPI +from patterns.structural.decorator.examples.resilient_client.service import build_charge + + +def main() -> None: + api = FlakyPaymentAPI(failures=2) + charge = build_charge(api, log=lambda line: print(f" log: {line}")) + print(f"charge('4242', 1200) = {charge('4242', 1200)}") + print(f"charge('4000', 800) = {charge('4000', 800)}") + print(f"network attempts: {api.attempts} (2 failures retried away)") + print(f"introspection survives: charge.__name__ = {charge.__name__!r}") + + +if __name__ == "__main__": + main() diff --git a/patterns/structural/decorator/examples/resilient_client/client.py b/patterns/structural/decorator/examples/resilient_client/client.py new file mode 100644 index 0000000..7af847b --- /dev/null +++ b/patterns/structural/decorator/examples/resilient_client/client.py @@ -0,0 +1,25 @@ +"""The unreliable thing being hardened: a fake payments API.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + + +class TransientNetworkError(ConnectionError): + """The kind of failure a retry can reasonably paper over.""" + + +@dataclass +class FlakyPaymentAPI: + """Fails the first ``failures`` calls, then succeeds forever after.""" + + failures: int + attempts: int = 0 + charges: list[tuple[str, int]] = field(default_factory=list) + + def charge(self, card: str, amount_cents: int) -> str: + self.attempts += 1 + if self.attempts <= self.failures: + raise TransientNetworkError(f"connection reset (attempt {self.attempts})") + self.charges.append((card, amount_cents)) + return f"txn-{len(self.charges)}" diff --git a/patterns/structural/decorator/examples/resilient_client/service.py b/patterns/structural/decorator/examples/resilient_client/service.py new file mode 100644 index 0000000..c40e445 --- /dev/null +++ b/patterns/structural/decorator/examples/resilient_client/service.py @@ -0,0 +1,38 @@ +"""Stacking the decorators into a hardened charge function. + +The stack reads bottom-up: retry hugs the flaky call so each attempt is +retried; logging sits outside so one *successful* operation logs once, not +once per attempt; the rate limit is outermost so rejected calls never touch +the network at all. That ordering is policy, and the tests pin it. +""" + +from __future__ import annotations + +from collections.abc import Callable + +from patterns.structural.decorator.examples.resilient_client.client import ( + FlakyPaymentAPI, + TransientNetworkError, +) +from patterns.structural.decorator.pattern import logged, rate_limited, retry + + +def build_charge( + api: FlakyPaymentAPI, + *, + log: Callable[[str], None], + max_attempts: int = 3, + max_calls: int = 5, + window: float = 1.0, + clock: Callable[[], float] | None = None, +) -> Callable[[str, int], str]: + """Wrap ``api.charge`` in retry -> logging -> rate limit, innermost first.""" + + def charge(card: str, amount_cents: int) -> str: + """Charge a card once.""" + return api.charge(card, amount_cents) + + hardened = retry(max_attempts, on=(TransientNetworkError,))(charge) + hardened = logged(log)(hardened) + limiter = rate_limited(max_calls, window, clock) if clock else rate_limited(max_calls, window) + return limiter(hardened) diff --git a/patterns/structural/decorator/naive.py b/patterns/structural/decorator/naive.py deleted file mode 100644 index 5777edd..0000000 --- a/patterns/structural/decorator/naive.py +++ /dev/null @@ -1,42 +0,0 @@ -"""The Gang of Four Decorator: wrap an *object*, forward the rest. - -A write-logging wrapper around a file-like object. ``__getattr__`` handles -wholesale forwarding so only the augmented method is written by hand -- the -Python mitigation of the book's forward-every-method tax. -""" - -from __future__ import annotations - -from typing import Any, TextIO - - -class LoggingWriter: - """Wraps a file-like object; counts and logs writes, forwards the rest.""" - - def __init__(self, wrapped: TextIO) -> None: - self._wrapped = wrapped - self.writes: int = 0 - - def write(self, text: str) -> int: - self.writes += 1 - return self._wrapped.write(text) - - def __getattr__(self, name: str) -> Any: - # Everything we don't augment is forwarded untouched. - return getattr(self._wrapped, name) - - -def main() -> None: - import io - - buffer = io.StringIO() - writer = LoggingWriter(buffer) - writer.write("hello ") - writer.write("world") - print(f"writes seen: {writer.writes}") - print(f"content: {buffer.getvalue()!r}") - print(f"isinstance survives wrapping: {isinstance(writer, io.StringIO)}") # False! - - -if __name__ == "__main__": - main() diff --git a/patterns/structural/decorator/pattern/__init__.py b/patterns/structural/decorator/pattern/__init__.py new file mode 100644 index 0000000..4348bba --- /dev/null +++ b/patterns/structural/decorator/pattern/__init__.py @@ -0,0 +1,11 @@ +"""The Decorator pattern, importable as library code.""" + +from patterns.structural.decorator.pattern.decorators import ( + RateLimitExceededError, + logged, + rate_limited, + retry, + timed, +) + +__all__ = ["RateLimitExceededError", "logged", "rate_limited", "retry", "timed"] diff --git a/patterns/structural/decorator/pattern/decorators.py b/patterns/structural/decorator/pattern/decorators.py new file mode 100644 index 0000000..f9f75a9 --- /dev/null +++ b/patterns/structural/decorator/pattern/decorators.py @@ -0,0 +1,139 @@ +"""Function decorators as importable, composable building blocks. + +Each factory returns a decorator that wraps a callable with one cross-cutting +concern -- logging, timing, retry, rate limiting -- and every wrapper applies +``functools.wraps`` so the wrapped function keeps its identity. Effects +(clocks, sleeping, log sinks) are injected, so the decorators stay +deterministic under test. +""" + +from __future__ import annotations + +import functools +import time +from collections.abc import Callable +from typing import ParamSpec, Protocol, TypeVar + +P = ParamSpec("P") +R = TypeVar("R") + + +class Decorator(Protocol): + """A signature-preserving wrapper: takes a callable, returns its like. + + The type variables live on ``__call__``, so one ``Decorator`` value can + wrap functions of any signature — they bind per decoration, not when the + factory runs. + """ + + def __call__(self, func: Callable[P, R], /) -> Callable[P, R]: ... + + +class RateLimitExceededError(RuntimeError): + """The wrapped callable was invoked more often than its window allows.""" + + +def logged(log: Callable[[str], None]) -> Decorator: + """Report every call and its outcome to ``log``.""" + + def decorator(func: Callable[P, R]) -> Callable[P, R]: + @functools.wraps(func) + def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: + log(f"-> {func.__name__}") + try: + result = func(*args, **kwargs) + except Exception as exc: + log(f"!! {func.__name__} raised {type(exc).__name__}") + raise + log(f"<- {func.__name__}") + return result + + return wrapper + + return decorator + + +def timed( + sink: Callable[[str, float], None], + clock: Callable[[], float] = time.perf_counter, +) -> Decorator: + """Report each call's duration (seconds) to ``sink`` as ``(name, elapsed)``.""" + + def decorator(func: Callable[P, R]) -> Callable[P, R]: + @functools.wraps(func) + def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: + started = clock() + try: + return func(*args, **kwargs) + finally: + sink(func.__name__, clock() - started) + + return wrapper + + return decorator + + +def retry( + attempts: int, + *, + on: tuple[type[Exception], ...] = (Exception,), + wait: float = 0.0, + sleep: Callable[[float], None] = time.sleep, +) -> Decorator: + """Retry up to ``attempts`` times on the listed exceptions. + + The wait doubles after each failure (``wait``, ``2*wait``, ...); the last + failure propagates. Inject ``sleep`` in tests to keep them instant. + """ + if attempts < 1: + raise ValueError("attempts must be >= 1") + + def decorator(func: Callable[P, R]) -> Callable[P, R]: + @functools.wraps(func) + def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: + pause = wait + for attempt in range(1, attempts + 1): + try: + return func(*args, **kwargs) + except on: + if attempt == attempts: + raise + if pause: + sleep(pause) + pause *= 2 + raise AssertionError("unreachable") # pragma: no cover + + return wrapper + + return decorator + + +def rate_limited( + max_calls: int, + window: float, + clock: Callable[[], float] = time.monotonic, +) -> Decorator: + """Allow ``max_calls`` per sliding ``window`` seconds; then raise. + + Raises :class:`RateLimitExceededError` instead of blocking -- the caller + decides whether to queue, drop, or surface the pressure. + """ + + def decorator(func: Callable[P, R]) -> Callable[P, R]: + calls: list[float] = [] + + @functools.wraps(func) + def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: + now = clock() + while calls and now - calls[0] >= window: + calls.pop(0) + if len(calls) >= max_calls: + raise RateLimitExceededError( + f"{func.__name__}: {max_calls} calls per {window}s exceeded" + ) + calls.append(now) + return func(*args, **kwargs) + + return wrapper + + return decorator diff --git a/patterns/structural/decorator/pythonic.py b/patterns/structural/decorator/pythonic.py deleted file mode 100644 index 327b49f..0000000 --- a/patterns/structural/decorator/pythonic.py +++ /dev/null @@ -1,61 +0,0 @@ -"""Python's native form: the function decorator. - -Two shapes you need: the plain decorator (two layers) and the parameterized -decorator (three layers). Both use ``functools.wraps`` so the wrapped -function keeps its identity under introspection. -""" - -from __future__ import annotations - -import functools -from collections.abc import Callable -from typing import TypeVar - -R = TypeVar("R") - - -def count_calls(func: Callable[..., R]) -> Callable[..., R]: - """Plain decorator: adds a call counter to any function.""" - - @functools.wraps(func) - def wrapper(*args: object, **kwargs: object) -> R: - wrapper.calls += 1 # type: ignore[attr-defined] - return func(*args, **kwargs) - - wrapper.calls = 0 # type: ignore[attr-defined] - return wrapper - - -def repeat(times: int) -> Callable[[Callable[..., R]], Callable[..., list[R]]]: - """Parameterized decorator: the outer layer takes the arguments.""" - - def decorator(func: Callable[..., R]) -> Callable[..., list[R]]: - @functools.wraps(func) - def wrapper(*args: object, **kwargs: object) -> list[R]: - return [func(*args, **kwargs) for _ in range(times)] - - return wrapper - - return decorator - - -@count_calls -def greet(name: str) -> str: - """Say hello.""" - return f"hello {name}" - - -@repeat(times=3) -def beep() -> str: - return "beep" - - -def main() -> None: - print(greet("ada"), greet("grace")) - print(f"calls: {greet.calls}") # type: ignore[attr-defined] - print(f"wraps preserved identity: {greet.__name__!r}, {greet.__doc__!r}") - print(beep()) - - -if __name__ == "__main__": - main() diff --git a/patterns/structural/decorator/real_world.py b/patterns/structural/decorator/real_world.py deleted file mode 100644 index b3bb206..0000000 --- a/patterns/structural/decorator/real_world.py +++ /dev/null @@ -1,25 +0,0 @@ -"""The stdlib decorating itself. - -``functools.lru_cache`` wraps a function with memoization -- the Decorator -pattern shipping in the standard library, cache statistics included. -""" - -from __future__ import annotations - -import functools - - -@functools.cache -def fib(n: int) -> int: - """Naively exponential -- linear once decorated.""" - return n if n < 2 else fib(n - 1) + fib(n - 2) - - -def main() -> None: - print(f"fib(60) = {fib(60)}") - info = fib.cache_info() - print(f"cache hits: {info.hits}, misses: {info.misses}") - - -if __name__ == "__main__": - main() diff --git a/patterns/structural/decorator/tests/test_decorator.py b/patterns/structural/decorator/tests/test_decorator.py deleted file mode 100644 index c3b5db9..0000000 --- a/patterns/structural/decorator/tests/test_decorator.py +++ /dev/null @@ -1,49 +0,0 @@ -"""Behavioral tests for all three decorator variants.""" - -import io - -from patterns.structural.decorator import naive, pythonic, real_world - - -class TestNaive: - def test_augments_write_and_forwards_content(self) -> None: - buffer = io.StringIO() - writer = naive.LoggingWriter(buffer) - writer.write("a") - writer.write("b") - assert writer.writes == 2 - assert buffer.getvalue() == "ab" - - def test_unaugmented_methods_are_forwarded(self) -> None: - writer = naive.LoggingWriter(io.StringIO()) - writer.write("xyz") - assert writer.getvalue() == "xyz" # forwarded via __getattr__ - - def test_wrapping_does_not_fool_isinstance(self) -> None: - assert not isinstance(naive.LoggingWriter(io.StringIO()), io.StringIO) - - -class TestPythonic: - def test_count_calls_counts(self) -> None: - @pythonic.count_calls - def f() -> int: - return 1 - - f(), f(), f() - assert f.calls == 3 # type: ignore[attr-defined] - - def test_wraps_preserves_metadata(self) -> None: - assert pythonic.greet.__name__ == "greet" - assert pythonic.greet.__doc__ == "Say hello." - - def test_parameterized_decorator(self) -> None: - assert pythonic.beep() == ["beep", "beep", "beep"] - - -class TestRealWorld: - def test_lru_cache_memoizes(self) -> None: - real_world.fib.cache_clear() - assert real_world.fib(30) == 832040 - hits_before = real_world.fib.cache_info().hits - real_world.fib(30) - assert real_world.fib.cache_info().hits == hits_before + 1 diff --git a/patterns/structural/decorator/tests/test_decorators.py b/patterns/structural/decorator/tests/test_decorators.py new file mode 100644 index 0000000..4be388e --- /dev/null +++ b/patterns/structural/decorator/tests/test_decorators.py @@ -0,0 +1,124 @@ +"""Behavioral tests for the decorator building blocks.""" + +from __future__ import annotations + +from collections.abc import Iterator + +import pytest + +from patterns.structural.decorator.pattern import ( + RateLimitExceededError, + logged, + rate_limited, + retry, + timed, +) + + +def test_logged_reports_call_and_return() -> None: + lines: list[str] = [] + + @logged(lines.append) + def add(a: int, b: int) -> int: + return a + b + + assert add(2, 3) == 5 + assert lines == ["-> add", "<- add"] + + +def test_logged_reports_raise_and_reraises() -> None: + lines: list[str] = [] + + @logged(lines.append) + def boom() -> None: + raise ValueError("no") + + with pytest.raises(ValueError): + boom() + assert lines == ["-> boom", "!! boom raised ValueError"] + + +def test_timed_feeds_sink_with_injected_clock() -> None: + ticks = iter([10.0, 10.25]) + seen: list[tuple[str, float]] = [] + + @timed(lambda name, secs: seen.append((name, secs)), clock=lambda: next(ticks)) + def work() -> str: + return "done" + + assert work() == "done" + assert seen == [("work", 0.25)] + + +def test_retry_retries_then_succeeds() -> None: + outcomes: Iterator[ConnectionError | str] = iter( + [ConnectionError("x"), ConnectionError("y"), "ok"] + ) + + @retry(3, on=(ConnectionError,)) + def flaky() -> str: + result = next(outcomes) + if isinstance(result, Exception): + raise result + return result + + assert flaky() == "ok" + + +def test_retry_exhaustion_raises_last_error_after_exact_attempts() -> None: + calls: list[int] = [] + + @retry(3, on=(ConnectionError,)) + def always_down() -> None: + calls.append(1) + raise ConnectionError("still down") + + with pytest.raises(ConnectionError): + always_down() + assert len(calls) == 3 + + +def test_retry_backoff_doubles_and_uses_injected_sleep() -> None: + pauses: list[float] = [] + + @retry(3, on=(ConnectionError,), wait=1.0, sleep=pauses.append) + def always_down() -> None: + raise ConnectionError("down") + + with pytest.raises(ConnectionError): + always_down() + assert pauses == [1.0, 2.0] + + +def test_retry_does_not_catch_unlisted_exceptions() -> None: + @retry(3, on=(ConnectionError,)) + def wrong_kind() -> None: + raise ValueError("not transient") + + with pytest.raises(ValueError): + wrong_kind() + + +def test_rate_limited_allows_within_window_then_raises() -> None: + now = [0.0] + + @rate_limited(2, window=10.0, clock=lambda: now[0]) + def ping() -> str: + return "pong" + + assert ping() == "pong" + assert ping() == "pong" + with pytest.raises(RateLimitExceededError): + ping() + now[0] = 11.0 # window slides; capacity returns + assert ping() == "pong" + + +def test_wraps_preserves_identity_through_a_stack() -> None: + @logged(lambda _: None) + @retry(2) + def documented() -> None: + """The docstring survives the stack.""" + + assert documented.__name__ == "documented" + assert documented.__doc__ == "The docstring survives the stack." diff --git a/patterns/structural/decorator/tests/test_resilient_client.py b/patterns/structural/decorator/tests/test_resilient_client.py new file mode 100644 index 0000000..47f62f5 --- /dev/null +++ b/patterns/structural/decorator/tests/test_resilient_client.py @@ -0,0 +1,54 @@ +"""Behavioral tests for the resilient_client mini-project.""" + +from __future__ import annotations + +import pytest + +from patterns.structural.decorator.examples.resilient_client import ( + FlakyPaymentAPI, + build_charge, +) +from patterns.structural.decorator.pattern import RateLimitExceededError + + +def test_transient_failures_are_retried_away() -> None: + api = FlakyPaymentAPI(failures=2) + charge = build_charge(api, log=lambda _: None) + assert charge("4242", 1200) == "txn-1" + assert api.attempts == 3 # two failures + the success + assert api.charges == [("4242", 1200)] + + +def test_stacking_order_logs_once_per_operation_not_per_attempt() -> None: + lines: list[str] = [] + api = FlakyPaymentAPI(failures=2) + charge = build_charge(api, log=lines.append) + charge("4242", 500) + # logged() sits OUTSIDE retry(): one arrow pair per operation, though the + # network was hit three times. Swapping the layers would fail this test. + assert lines == ["-> charge", "<- charge"] + assert api.attempts == 3 + + +def test_failures_beyond_the_retry_budget_surface() -> None: + api = FlakyPaymentAPI(failures=5) + charge = build_charge(api, log=lambda _: None, max_attempts=3) + with pytest.raises(ConnectionError): + charge("4242", 500) + assert api.charges == [] + + +def test_rate_limit_rejects_before_touching_the_network() -> None: + now = [0.0] + api = FlakyPaymentAPI(failures=0) + charge = build_charge(api, log=lambda _: None, max_calls=2, window=60.0, clock=lambda: now[0]) + charge("4242", 100) + charge("4242", 200) + with pytest.raises(RateLimitExceededError): + charge("4242", 300) + assert api.attempts == 2 # the rejected call never reached the API + + +def test_hardened_callable_keeps_identity() -> None: + charge = build_charge(FlakyPaymentAPI(failures=0), log=lambda _: None) + assert charge.__name__ == "charge" diff --git a/patterns/structural/facade/README.md b/patterns/structural/facade/README.md index a54d183..55deb05 100644 --- a/patterns/structural/facade/README.md +++ b/patterns/structural/facade/README.md @@ -14,31 +14,17 @@ stdlib_sightings: [subprocess.run, shutil.make_archive, urllib.request.urlopen] # Facade -## Problem - -Doing the common thing takes five coordinated calls into a subsystem, and -every caller performs the same dance. One misordered step, one leaked -resource, and the copy-paste bill comes due. - -## Naive solution - -`naive.py` is the class-shaped version: subsystem classes plus a -`HomeTheaterFacade` whose one method runs the sequence. - -## Pythonic solution - -Modules are namespaces and functions are entry points, so the natural Python -facade is a *function*: `pythonic.py` puts `place_order()` in front of an -order-fulfillment subsystem (inventory, payment, shipping, notification) — -including the payment-failure rollback every call site used to forget. The -subsystem stays public for callers needing the full controls. - -## In the wild - -`subprocess.run` is a facade over `Popen`'s wiring; `shutil.make_archive` -fronts `zipfile`/`tarfile`; `urllib.request.urlopen` hides openers and -handlers. Each leaves the machinery public underneath. - -## Verdict - -**Pythonic.** Ship the one-call common case; keep the subsystem's door open. +One entry point for the subsystem dance every caller used to copy-paste — +ordering, rollback and all. **Verdict: pythonic** — the natural Python facade +is a module-level function, and the subsystem stays public beside it. + +| Where | What | +|---|---| +| [`pattern/`](pattern/) | The importable code: `place_order` fronting `Warehouse`/`PaymentGateway`/`Shipping`/`Notifier` | +| [`docs/`](docs/) | [Fundamentals](docs/fundamentals.md) · [Implementation guide](docs/implementation.md) · [External examples](docs/examples.md) | +| [`examples/order_checkout/`](examples/order_checkout/) | Mini-project: a storefront batch-processing orders through the one door | +| [`tests/`](tests/) | Behavioral tests for the pattern and the mini-project | + +```bash +uv run python -m patterns.structural.facade.examples.order_checkout +``` diff --git a/patterns/structural/facade/__init__.py b/patterns/structural/facade/__init__.py index 6905b54..a08e73d 100644 --- a/patterns/structural/facade/__init__.py +++ b/patterns/structural/facade/__init__.py @@ -1 +1,22 @@ -"""Facade: one simple entry point in front of a subsystem.""" +"""Facade — public API. + +>>> from patterns.structural.facade import place_order +""" + +from patterns.structural.facade.pattern import ( + Notifier, + OrderResult, + PaymentGateway, + Shipping, + Warehouse, + place_order, +) + +__all__ = [ + "Notifier", + "OrderResult", + "PaymentGateway", + "Shipping", + "Warehouse", + "place_order", +] diff --git a/patterns/structural/facade/docs/examples.md b/patterns/structural/facade/docs/examples.md new file mode 100644 index 0000000..7068681 --- /dev/null +++ b/patterns/structural/facade/docs/examples.md @@ -0,0 +1,31 @@ +# Facade — where it lives outside this repo + +Cited, real implementations to study (or point an agent at) when designing or +reviewing facade-shaped code. + +## Python standard library + +- **`subprocess.run`.** One call fronting `Popen`'s pipes, waiting, timeout, + and return-code checking; `Popen` stays public for streaming callers. + [docs.python.org/3/library/subprocess.html#subprocess.run](https://docs.python.org/3/library/subprocess.html#subprocess.run) +- **`shutil.make_archive`.** Walks the tree, creates the archive, writes + entries, closes handles — the whole `zipfile`/`tarfile` dance in one call, + with both modules importable beside it. + [docs.python.org/3/library/shutil.html#shutil.make_archive](https://docs.python.org/3/library/shutil.html#shutil.make_archive) +- **`urllib.request.urlopen`.** Hides the opener/handler chain construction + every request needs; `build_opener` remains for callers who want the knobs. + [docs.python.org/3/library/urllib.request.html#urllib.request.urlopen](https://docs.python.org/3/library/urllib.request.html#urllib.request.urlopen) + +## Major ecosystems + +- **`requests`' functional API.** `requests.get(url)` fronts + Session/adapter/urllib3 machinery; the `Session` object is one import away + when you need pooling or retries. + [requests.readthedocs.io/en/latest/api/#main-interface](https://requests.readthedocs.io/en/latest/api/#main-interface) + +## What to notice across all of them + +Each facade owns a *policy*, not just a shortcut: `subprocess.run` decides +how waiting and non-zero exits work; `urlopen` decides the default handler +chain. And each leaves the machinery public — the measure of a good facade +is that power users never have to fight it. diff --git a/patterns/structural/facade/docs/fundamentals.md b/patterns/structural/facade/docs/fundamentals.md new file mode 100644 index 0000000..f67c385 --- /dev/null +++ b/patterns/structural/facade/docs/fundamentals.md @@ -0,0 +1,70 @@ +# Facade — fundamentals + +## Intent + +Give a complicated subsystem one simple entry point for the common case. The +facade performs the multi-step dance callers would otherwise copy-paste — +in the right order, with the right cleanup — while the subsystem stays +public for anyone needing the full controls. + +## Participants + +| Role | Classic (GoF) form | Python form | +|---|---|---| +| Facade | A class whose methods run subsystem sequences | Usually a module-level *function* — see [`pattern/checkout.py`](../pattern/checkout.py) | +| Subsystem classes | The machinery being fronted | Same, and deliberately still importable | +| Client | Calls the facade for the common case | Calls `place_order(...)`; reaches past it when needed | + +## Mechanism + +1. Identify the sequence every caller repeats against the subsystem. +2. Put that sequence — ordering, error handling, rollback — in one callable. +3. Callers use the one door for the common case. +4. The subsystem stays public: the facade simplifies, it must not imprison. + +## The classic form, and what Python absorbs + +The textbook facade is a class because 1994 had nothing else to hang a +function on: + +```python +class HomeTheaterFacade: + def __init__(self) -> None: + self.amp = Amplifier() + self.projector = Projector() + self.lights = Lights() + + def watch_movie(self) -> list[str]: # the one method + return [ + self.lights.dim(10), + self.projector.on(), + self.projector.wide_screen(), + self.amp.on(), + self.amp.set_volume(5), + ] +``` + +Python has modules for namespacing and functions as first-class entry points, +so a facade with one operation *is a function* — a class with a single method +is a function wearing a costume (this unit's standing caveat). The class form +earns its keep only when the facade holds real state across calls, as the +mini-project's `Store` does for a whole trading day. + +## When to use it + +- Callers repeat the same multi-call sequence against a subsystem, and one + misordered step or forgotten rollback is a real bug you have seen. +- You want a stable, small surface in front of churning machinery. + +## When not to use it + +- One underlying call → just call it; a pass-through layer is noise. +- Callers all need different sequences → there is no common case to front. +- You are tempted to *hide* the subsystem → that's a different (worse) + decision; keep the machinery importable. + +## Verdict: pythonic + +Ship the one-call common case as a function with good defaults; keep the +subsystem's door open. `subprocess.run` over `Popen` is the stdlib's model +citizen of this shape. diff --git a/patterns/structural/facade/docs/implementation.md b/patterns/structural/facade/docs/implementation.md new file mode 100644 index 0000000..8aa9742 --- /dev/null +++ b/patterns/structural/facade/docs/implementation.md @@ -0,0 +1,62 @@ +# Facade — putting it into a system + +## The smell it fixes + +The same subsystem choreography pasted at every call site: + +```python +# checkout_view.py # admin_reorder.py # support_tool.py +warehouse.reserve(sku, n) warehouse.reserve(sku, n) warehouse.reserve(sku, n) +txn = gateway.charge(...) txn = gateway.charge(...) txn = gateway.charge(...) +label = shipping.label(...) # forgot the rollback! label = shipping.label(...) +``` + +Three copies, one missing rollback, and the bug ships. The sequence is a +policy; policies live in one place. + +## Steps + +1. **Find the repeated dance.** Grep for the subsystem's entry calls; the + facade's body is whatever keeps appearing between them. +2. **Write it as a function** taking the subsystem objects as parameters + (dependency injection keeps it testable) plus keyword-only arguments for + the order itself. +3. **Own the failure policy inside.** Partial completion is the facade's + whole reason to exist: reserve-then-declined must release the stock. Be + honest about the boundary — [`pattern/checkout.py`](../pattern/checkout.py) + marks exactly where its rollback guarantee ends. +4. **Leave the subsystem public.** Export the classes beside the facade; + write at least one caller that legitimately bypasses it (the + mini-project's `Store.restock`) to prove the door stays open. +5. **Route existing call sites through the facade** and delete their local + copies of the dance. The diff is the payoff: minus signs everywhere. + +## Python idioms that keep it small + +- **Module-level function, keyword-only config.** The natural Python facade + is `def place_order(...)` in a module, not a `Manager` class. +- **Take collaborators as parameters** rather than constructing them inside — + the facade coordinates, it doesn't own; tests swap in primed fakes. +- **Grow a class only when state accumulates.** `Store` in the mini-project + holds the subsystem for a whole batch; that's state, so a class is honest. + +## Pitfalls + +- **The one-method class.** `CheckoutManager.place_order()` with no other + members is a function in costume; write the function. +- **Imprisoning the subsystem** (private modules, mangled names) turns a + convenience into a bottleneck; every future need funnels through you. +- **Silent partial completion.** A facade that charges the card and then + crashes without compensating has *created* a bug factory. Decide: roll + back, or document the boundary loudly. +- **Facade sprawl.** When `place_order` sprouts eleven flag parameters, the + callers have distinct needs — give them the subsystem, not more flags. + +## Worked example + +[`examples/order_checkout/`](../examples/order_checkout/) processes a batch of +orders — one declined card among them — through the single checkout door: + +```bash +uv run python -m patterns.structural.facade.examples.order_checkout +``` diff --git a/patterns/structural/facade/examples/__init__.py b/patterns/structural/facade/examples/__init__.py new file mode 100644 index 0000000..83e55db --- /dev/null +++ b/patterns/structural/facade/examples/__init__.py @@ -0,0 +1 @@ +"""Mini-projects demonstrating the Facade pattern in practice.""" diff --git a/patterns/structural/facade/examples/order_checkout/__init__.py b/patterns/structural/facade/examples/order_checkout/__init__.py new file mode 100644 index 0000000..0aaab79 --- /dev/null +++ b/patterns/structural/facade/examples/order_checkout/__init__.py @@ -0,0 +1,11 @@ +"""A storefront processing a day's orders through the checkout facade. + +Run it: ``uv run python -m patterns.structural.facade.examples.order_checkout`` +""" + +from patterns.structural.facade.examples.order_checkout.store import ( + Order, + Store, +) + +__all__ = ["Order", "Store"] diff --git a/patterns/structural/facade/examples/order_checkout/__main__.py b/patterns/structural/facade/examples/order_checkout/__main__.py new file mode 100644 index 0000000..33fb511 --- /dev/null +++ b/patterns/structural/facade/examples/order_checkout/__main__.py @@ -0,0 +1,28 @@ +"""Demo: a morning's orders, one declined card among them.""" + +from __future__ import annotations + +from patterns.structural.facade.examples.order_checkout.store import Order, Store +from patterns.structural.facade.pattern import PaymentGateway, Warehouse + + +def main() -> None: + store = Store( + warehouse=Warehouse(stock={"mug": 10, "tee": 3}), + gateway=PaymentGateway(declined_cards={"4000-declined"}), + ) + orders = [ + Order("mug", 2, 1200, "4242", "12 Grace Ave"), + Order("tee", 1, 2500, "4000-declined", "9 Hopper St"), + Order("mug", 1, 1200, "4111", "3 Lovelace Rd"), + ] + fulfilled, failed = store.process(orders) + for result in fulfilled: + print(f"fulfilled: {result.transaction_id} -> {result.shipping_label}") + for order, reason in failed: + print(f"failed: {order.sku} x{order.quantity} ({reason})") + print(f"stock after (tee restored by rollback): {store.warehouse.stock}") + + +if __name__ == "__main__": + main() diff --git a/patterns/structural/facade/examples/order_checkout/store.py b/patterns/structural/facade/examples/order_checkout/store.py new file mode 100644 index 0000000..0bd5fc3 --- /dev/null +++ b/patterns/structural/facade/examples/order_checkout/store.py @@ -0,0 +1,67 @@ +"""The mini-project: a storefront whose only checkout path is the facade. + +Every order goes through ``place_order`` -- no call site re-implements the +reserve/charge/ship/notify dance, so the payment-declined rollback exists in +exactly one place. Callers needing the full controls still reach the +subsystem directly (see ``Store.restock``, which talks to the warehouse). +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from patterns.structural.facade.pattern import ( + Notifier, + OrderResult, + PaymentGateway, + Shipping, + Warehouse, + place_order, +) + + +@dataclass(frozen=True) +class Order: + sku: str + quantity: int + price_cents: int + card: str + address: str + + +@dataclass +class Store: + """Owns the subsystem; exposes one door for the common case.""" + + warehouse: Warehouse = field(default_factory=Warehouse) + gateway: PaymentGateway = field(default_factory=PaymentGateway) + shipping: Shipping = field(default_factory=Shipping) + notifier: Notifier = field(default_factory=Notifier) + + def restock(self, sku: str, quantity: int) -> None: + # Full-controls path: the subsystem is public, not imprisoned. + self.warehouse.release(sku, quantity) + + def checkout(self, order: Order) -> OrderResult: + return place_order( + self.warehouse, + self.gateway, + self.shipping, + self.notifier, + sku=order.sku, + quantity=order.quantity, + price_cents=order.price_cents, + card=order.card, + address=order.address, + ) + + def process(self, orders: list[Order]) -> tuple[list[OrderResult], list[tuple[Order, str]]]: + """A day's batch: fulfilled results plus (order, reason) failures.""" + fulfilled: list[OrderResult] = [] + failed: list[tuple[Order, str]] = [] + for order in orders: + try: + fulfilled.append(self.checkout(order)) + except (LookupError, PermissionError) as exc: + failed.append((order, str(exc))) + return fulfilled, failed diff --git a/patterns/structural/facade/naive.py b/patterns/structural/facade/naive.py deleted file mode 100644 index 8ee26bc..0000000 --- a/patterns/structural/facade/naive.py +++ /dev/null @@ -1,53 +0,0 @@ -"""The class-shaped Facade. - -Three subsystem classes, one facade whose single method performs the -sequence every caller would otherwise copy-paste. -""" - -from __future__ import annotations - - -class Amplifier: - def on(self) -> str: - return "amp on" - - def set_volume(self, level: int) -> str: - return f"volume {level}" - - -class Projector: - def on(self) -> str: - return "projector on" - - def wide_screen(self) -> str: - return "16:9" - - -class Lights: - def dim(self, percent: int) -> str: - return f"lights {percent}%" - - -class HomeTheaterFacade: - def __init__(self) -> None: - self.amp = Amplifier() - self.projector = Projector() - self.lights = Lights() - - def watch_movie(self) -> list[str]: - return [ - self.lights.dim(10), - self.projector.on(), - self.projector.wide_screen(), - self.amp.on(), - self.amp.set_volume(5), - ] - - -def main() -> None: - for step in HomeTheaterFacade().watch_movie(): - print(step) - - -if __name__ == "__main__": - main() diff --git a/patterns/structural/facade/pattern/__init__.py b/patterns/structural/facade/pattern/__init__.py new file mode 100644 index 0000000..4e2095d --- /dev/null +++ b/patterns/structural/facade/pattern/__init__.py @@ -0,0 +1,19 @@ +"""The Facade pattern, importable as library code.""" + +from patterns.structural.facade.pattern.checkout import ( + Notifier, + OrderResult, + PaymentGateway, + Shipping, + Warehouse, + place_order, +) + +__all__ = [ + "Notifier", + "OrderResult", + "PaymentGateway", + "Shipping", + "Warehouse", + "place_order", +] diff --git a/patterns/structural/facade/pythonic.py b/patterns/structural/facade/pattern/checkout.py similarity index 81% rename from patterns/structural/facade/pythonic.py rename to patterns/structural/facade/pattern/checkout.py index 465ab01..8f3543c 100644 --- a/patterns/structural/facade/pythonic.py +++ b/patterns/structural/facade/pattern/checkout.py @@ -1,10 +1,10 @@ -"""The pythonic facade: a module-level function with good defaults. +"""A facade in its natural Python form: one function with good defaults. The subsystem is a small order-fulfillment flow -- inventory, payment, shipping, notification -- four calls every checkout caller used to copy-paste, in the right order, with the right rollback. ``place_order`` -is the one-call common case; the subsystem stays public for callers who -need the full controls (partial shipments, invoice-only, etc.). +is the one-call common case; the subsystem classes stay public for callers +who need the full controls (partial shipments, invoice-only, etc.). """ from __future__ import annotations @@ -87,24 +87,3 @@ def place_order( label = shipping.create_label(sku, address) notifier.confirm(address, txn, label) return OrderResult(transaction_id=txn, shipping_label=label) - - -def main() -> None: - warehouse = Warehouse(stock={"mug": 10}) - result = place_order( - warehouse, - PaymentGateway(), - Shipping(), - Notifier(), - sku="mug", - quantity=2, - price_cents=1200, - card="4242", - address="12 Grace Ave", - ) - print(result) - print(f"stock after: {warehouse.stock}") - - -if __name__ == "__main__": - main() diff --git a/patterns/structural/facade/real_world.py b/patterns/structural/facade/real_world.py deleted file mode 100644 index 1d04a09..0000000 --- a/patterns/structural/facade/real_world.py +++ /dev/null @@ -1,31 +0,0 @@ -"""``shutil.make_archive``: one call fronting the zipfile machinery. - -Behind the facade: walking the tree, creating the archive, writing entries, -closing handles. The full ``zipfile`` API stays available beside it. -""" - -from __future__ import annotations - -import shutil -import tempfile -import zipfile -from pathlib import Path - - -def archive_directory(source: Path, out_dir: Path) -> Path: - """The facade in action: an entire directory zipped in one call.""" - return Path(shutil.make_archive(str(out_dir / "backup"), "zip", root_dir=source)) - - -def main() -> None: - with tempfile.TemporaryDirectory() as tmp: - source = Path(tmp) / "src" - source.mkdir() - (source / "a.txt").write_text("hello") - archive = archive_directory(source, Path(tmp)) - with zipfile.ZipFile(archive) as zf: # the subsystem, still public - print(f"{archive.name} contains {zf.namelist()}") - - -if __name__ == "__main__": - main() diff --git a/patterns/structural/facade/tests/test_checkout.py b/patterns/structural/facade/tests/test_checkout.py new file mode 100644 index 0000000..28ff344 --- /dev/null +++ b/patterns/structural/facade/tests/test_checkout.py @@ -0,0 +1,86 @@ +"""Behavioral tests for the checkout facade.""" + +from __future__ import annotations + +import pytest + +from patterns.structural.facade.pattern import ( + Notifier, + PaymentGateway, + Shipping, + Warehouse, + place_order, +) + + +def build_subsystem( + stock: int = 10, declined: set[str] | None = None +) -> tuple[Warehouse, PaymentGateway, Shipping, Notifier]: + return ( + Warehouse(stock={"mug": stock}), + PaymentGateway(declined_cards=declined or set()), + Shipping(), + Notifier(), + ) + + +def test_happy_path_runs_the_whole_dance_in_order() -> None: + warehouse, gateway, shipping, notifier = build_subsystem() + result = place_order( + warehouse, + gateway, + shipping, + notifier, + sku="mug", + quantity=2, + price_cents=1200, + card="4242", + address="12 Grace Ave", + ) + assert warehouse.stock["mug"] == 8 + assert gateway.charges == [("4242", 2400)] + assert result.shipping_label in shipping.labels + assert notifier.sent and result.transaction_id in notifier.sent[0] + + +def test_declined_payment_rolls_back_the_reservation() -> None: + warehouse, gateway, shipping, notifier = build_subsystem(declined={"4000"}) + with pytest.raises(PermissionError): + place_order( + warehouse, + gateway, + shipping, + notifier, + sku="mug", + quantity=3, + price_cents=1000, + card="4000", + address="9 Hopper St", + ) + assert warehouse.stock["mug"] == 10 # released, not leaked + assert shipping.labels == [] + assert notifier.sent == [] + + +def test_insufficient_stock_stops_before_any_charge() -> None: + warehouse, gateway, shipping, notifier = build_subsystem(stock=1) + with pytest.raises(LookupError): + place_order( + warehouse, + gateway, + shipping, + notifier, + sku="mug", + quantity=5, + price_cents=1000, + card="4242", + address="3 Lovelace Rd", + ) + assert gateway.charges == [] + + +def test_subsystem_stays_usable_without_the_facade() -> None: + warehouse, gateway, _, _ = build_subsystem() + warehouse.reserve("mug", 1) # full-controls path: no facade required + assert gateway.charge("4242", 100) == "txn-1" + assert warehouse.stock["mug"] == 9 diff --git a/patterns/structural/facade/tests/test_facade.py b/patterns/structural/facade/tests/test_facade.py deleted file mode 100644 index c57dfc3..0000000 --- a/patterns/structural/facade/tests/test_facade.py +++ /dev/null @@ -1,96 +0,0 @@ -"""Behavioral tests for all three facade variants.""" - -import tempfile -import zipfile -from pathlib import Path - -import pytest - -from patterns.structural.facade import naive, pythonic, real_world - - -class TestNaive: - def test_one_call_runs_the_whole_sequence(self) -> None: - steps = naive.HomeTheaterFacade().watch_movie() - assert steps == ["lights 10%", "projector on", "16:9", "amp on", "volume 5"] - - -class TestPythonic: - def _subsystem( - self, - ) -> tuple[pythonic.Warehouse, pythonic.PaymentGateway, pythonic.Shipping, pythonic.Notifier]: - return ( - pythonic.Warehouse(stock={"mug": 10}), - pythonic.PaymentGateway(), - pythonic.Shipping(), - pythonic.Notifier(), - ) - - def test_facade_runs_every_step_in_order(self) -> None: - warehouse, gateway, shipping, notifier = self._subsystem() - result = pythonic.place_order( - warehouse, - gateway, - shipping, - notifier, - sku="mug", - quantity=2, - price_cents=1200, - card="4242", - address="12 Grace Ave", - ) - assert warehouse.stock["mug"] == 8 - assert gateway.charges == [("4242", 2400)] - assert result.shipping_label in shipping.labels - assert notifier.sent and result.transaction_id in notifier.sent[0] - - def test_declined_payment_rolls_back_the_reservation(self) -> None: - warehouse, gateway, shipping, notifier = self._subsystem() - gateway.declined_cards.add("0000") - with pytest.raises(PermissionError): - pythonic.place_order( - warehouse, - gateway, - shipping, - notifier, - sku="mug", - quantity=3, - price_cents=1200, - card="0000", - address="x", - ) - assert warehouse.stock["mug"] == 10 # released, not leaked - assert shipping.labels == [] and notifier.sent == [] - - def test_insufficient_stock_charges_nothing(self) -> None: - warehouse, gateway, shipping, notifier = self._subsystem() - with pytest.raises(LookupError): - pythonic.place_order( - warehouse, - gateway, - shipping, - notifier, - sku="mug", - quantity=99, - price_cents=1200, - card="4242", - address="x", - ) - assert gateway.charges == [] - - def test_subsystem_stays_public_for_full_control(self) -> None: - # Invoice-only flow: callers can still drive the parts directly. - gateway = pythonic.PaymentGateway() - assert gateway.charge("4242", 500) == "txn-1" - - -class TestRealWorld: - def test_make_archive_facade(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - source = Path(tmp) / "src" - source.mkdir() - (source / "a.txt").write_text("hello") - archive = real_world.archive_directory(source, Path(tmp)) - assert archive.exists() - with zipfile.ZipFile(archive) as zf: - assert zf.namelist() == ["a.txt"] diff --git a/patterns/structural/facade/tests/test_order_checkout.py b/patterns/structural/facade/tests/test_order_checkout.py new file mode 100644 index 0000000..92db8a5 --- /dev/null +++ b/patterns/structural/facade/tests/test_order_checkout.py @@ -0,0 +1,45 @@ +"""Behavioral tests for the order_checkout mini-project.""" + +from __future__ import annotations + +from patterns.structural.facade.examples.order_checkout import Order, Store +from patterns.structural.facade.pattern import PaymentGateway, Warehouse + + +def build_store() -> Store: + return Store( + warehouse=Warehouse(stock={"mug": 10, "tee": 3}), + gateway=PaymentGateway(declined_cards={"4000-declined"}), + ) + + +def test_batch_separates_fulfilled_from_failed() -> None: + store = build_store() + fulfilled, failed = store.process( + [ + Order("mug", 2, 1200, "4242", "12 Grace Ave"), + Order("tee", 1, 2500, "4000-declined", "9 Hopper St"), + Order("mug", 1, 1200, "4111", "3 Lovelace Rd"), + ] + ) + assert [r.transaction_id for r in fulfilled] == ["txn-1", "txn-2"] + assert [(o.sku, "declined" in reason) for o, reason in failed] == [("tee", True)] + + +def test_declined_order_leaves_stock_untouched_for_the_rest_of_the_batch() -> None: + store = build_store() + store.process( + [ + Order("tee", 2, 2500, "4000-declined", "9 Hopper St"), + Order("tee", 3, 2500, "4242", "12 Grace Ave"), + ] + ) + # The rollback restored the 2 tees, so the order for all 3 could succeed. + assert store.warehouse.stock["tee"] == 0 + assert len(store.gateway.charges) == 1 + + +def test_full_controls_path_bypasses_the_facade() -> None: + store = build_store() + store.restock("mug", 5) + assert store.warehouse.stock["mug"] == 15 diff --git a/patterns/structural/flyweight/README.md b/patterns/structural/flyweight/README.md index 965d4b0..54e9830 100644 --- a/patterns/structural/flyweight/README.md +++ b/patterns/structural/flyweight/README.md @@ -15,30 +15,17 @@ stdlib_sightings: [sys.intern, functools.lru_cache, int] # Flyweight -## Problem - -A text editor holds a million character objects; a card game deals thousands -of hands from 52 distinct cards. Building a fresh object per occurrence wastes -memory on identical state. Share one immutable instance per distinct value. - -## Naive solution - -`naive.py` uses the book's shape — a factory that checks a pool before -constructing — for playing cards: ask for `9♥` twice, get the same object. - -## Pythonic solution - -Two idiomatic forms in `pythonic.py`: a `functools.lru_cache`-decorated -factory (the pool is the cache), and the guide's `__new__` variant where the -class itself makes `Card(9, "♥") is Card(9, "♥")` true. - -## In the wild - -CPython interns small integers (`-5..256`) and identifier-like strings on its -own, and `sys.intern` lets you intern strings explicitly to speed up -comparisons — the interpreter running Flyweight underneath you. - -## Verdict - -**Use with care.** Great when profiling shows real duplication of immutable -values; pointless ceremony otherwise. Keep flyweights frozen. +Share one immutable instance per distinct value instead of building millions +of duplicates. **Verdict: use with care** — measure first, keep flyweights +frozen, prefer an explicit factory over `__new__` tricks. + +| Where | What | +|---|---| +| [`pattern/`](pattern/) | The importable code: `InternPool` (keyed sharing with an immutability guard) | +| [`docs/`](docs/) | [Fundamentals](docs/fundamentals.md) · [Implementation guide](docs/implementation.md) · [External examples](docs/examples.md) | +| [`examples/glyph_styles/`](examples/glyph_styles/) | Mini-project: a text buffer holding thousands of glyphs on a handful of shared styles | +| [`tests/`](tests/) | Behavioral tests for the pattern and the mini-project | + +```bash +uv run python -m patterns.structural.flyweight.examples.glyph_styles +``` diff --git a/patterns/structural/flyweight/__init__.py b/patterns/structural/flyweight/__init__.py index eb8fb36..212925c 100644 --- a/patterns/structural/flyweight/__init__.py +++ b/patterns/structural/flyweight/__init__.py @@ -1 +1,8 @@ -"""Flyweight: share immutable instances rather than duplicating them.""" +"""Flyweight — public API. + +>>> from patterns.structural.flyweight import InternPool +""" + +from patterns.structural.flyweight.pattern import InternPool + +__all__ = ["InternPool"] diff --git a/patterns/structural/flyweight/docs/examples.md b/patterns/structural/flyweight/docs/examples.md new file mode 100644 index 0000000..29d31b9 --- /dev/null +++ b/patterns/structural/flyweight/docs/examples.md @@ -0,0 +1,32 @@ +# Flyweight — where it lives outside this repo + +Cited, real implementations to study (or point an agent at) when designing or +reviewing sharing/interning code. + +## Python standard library + +- **`sys.intern`.** Explicit string interning: one shared copy, pointer-fast + equality. The docs call out dictionary keys as the winning case. + [docs.python.org/3/library/sys.html#sys.intern](https://docs.python.org/3/library/sys.html#sys.intern) +- **CPython small-int interning.** Integers −5..256 are pre-built singletons; + the interpreter runs the pattern under you, which is why careless `is` checks + on small ints "work" and then betray you at 257. + [docs.python.org/3/c-api/long.html](https://docs.python.org/3/c-api/long.html) +- **`functools.lru_cache`.** A memoizing decorator that, applied to a + factory, *is* the flyweight pool — the guide chapter's own recommendation. + [docs.python.org/3/library/functools.html#functools.lru_cache](https://docs.python.org/3/library/functools.html#functools.lru_cache) + +## Major ecosystems + +- **spaCy `StringStore`.** Interns every vocabulary string to a 64-bit hash + so tokens across a corpus share one copy — flyweight at NLP scale. + [spacy.io/api/stringstore](https://spacy.io/api/stringstore) +- **Apache Arrow dictionary arrays** (pandas `Categorical`). Column-scale + value sharing: each distinct value stored once, rows hold small indices. + [arrow.apache.org/docs/python/data.html#dictionary-arrays](https://arrow.apache.org/docs/python/data.html#dictionary-arrays) + +## What to notice across all of them + +Every production flyweight shares only **immutable** values, and none of +them expose the pooled object for mutation. And each one earned its place +with a measurement — interning pays at corpus/column scale, not at 52 cards. diff --git a/patterns/structural/flyweight/docs/fundamentals.md b/patterns/structural/flyweight/docs/fundamentals.md new file mode 100644 index 0000000..cd3ad39 --- /dev/null +++ b/patterns/structural/flyweight/docs/fundamentals.md @@ -0,0 +1,72 @@ +# Flyweight — fundamentals + +## Intent + +Support huge numbers of fine-grained objects by sharing one immutable +instance per distinct value instead of duplicating it. Split state into +**intrinsic** (shared, in the flyweight) and **extrinsic** (per occurrence, +carried by the holder). + +## Participants + +| Role | Classic (GoF) form | Python form | +|---|---|---| +| Flyweight | Interface for objects carrying intrinsic state | Any immutable value — a frozen dataclass, a tuple | +| Flyweight factory | Checks a pool before constructing | [`InternPool`](../pattern/pool.py), or `functools.lru_cache` on a factory | +| Client | Supplies extrinsic state on each use | The holder keeps `(char, style)`, not a fat per-char object | + +## Mechanism + +1. Identify the duplicated immutable core of your many objects. +2. Front construction with a pool keyed by that core: first request builds, + later requests share. +3. Keep everything per-occurrence *outside* the shared object. +4. Never mutate a flyweight — a shared instance mutated once is corrupted + everywhere. + +## The classic form, and what Python absorbs + +The book's mechanism is a factory checking a pool — recognizable verbatim in +Python: + +```python +class CardFactory: + def __init__(self) -> None: + self._pool: dict[tuple[str, str], Card] = {} + + def get(self, rank: str, suit: str) -> Card: + key = (rank, suit) + if key not in self._pool: # check the pool... + self._pool[key] = Card(rank, suit) + return self._pool[key] # ...share the instance +``` + +Python absorbs this twice over. `functools.lru_cache` on a plain factory +function *is* the pool. And the guide's `__new__` variant moves the pool +inside the class so `Card('9','♥') is Card('9','♥')` holds with plain +construction syntax — clever, but the sharing becomes invisible at the call +site, which is why the guide (and this unit) prefer the explicit factory: +[python-patterns.guide/gang-of-four/flyweight](https://python-patterns.guide/gang-of-four/flyweight/). + +Most humbling: CPython already interns small integers and identifier-like +strings. Your duplicates may not exist — measure first. + +## When to use it + +- Profiling shows real memory pressure from many identical immutable values + (glyph styles, map tiles, token metadata). +- Identity comparison (`is`) as a fast path is worth engineering for. + +## When not to use it + +- The objects are mutable — sharing mutable state is a bug generator, not an + optimization. +- The population is small; a pool managing 52 cards saves nothing worth the + indirection unless the *lesson* is the point. +- You haven't measured; interning by reflex is ceremony. + +## Verdict: use with care + +Great when profiling shows genuine duplication of immutable values; +pointless ceremony otherwise. Keep flyweights frozen — the pool's +`strict=True` guard exists because that rule gets broken quietly. diff --git a/patterns/structural/flyweight/docs/implementation.md b/patterns/structural/flyweight/docs/implementation.md new file mode 100644 index 0000000..5d7a860 --- /dev/null +++ b/patterns/structural/flyweight/docs/implementation.md @@ -0,0 +1,68 @@ +# Flyweight — putting it into a system + +## The smell it fixes + +A million tiny objects that are mostly the same object: + +```python +@dataclass +class Char: + char: str + font: str # "Georgia" a million times + size: int # 11 a million times + weight: str # "regular" a million times +``` + +Per-occurrence data (the character) is fused to duplicated data (the style), +and memory pays for the duplication a million-fold. + +## Steps + +1. **Measure first.** `sys.getsizeof`, `tracemalloc`, a heap profiler — + confirm the duplicates exist and matter. CPython already interns small + ints and many strings; your problem may be imaginary. +2. **Split intrinsic from extrinsic.** Intrinsic = identical across + occurrences and immutable (the style); extrinsic = per occurrence (the + char, the position). The split is the design work; the pool is plumbing. +3. **Freeze the intrinsic part** (`@dataclass(frozen=True)`) so sharing is + safe by construction. +4. **Front construction with a pool** — `InternPool(build)` from + [`pattern/pool.py`](../pattern/pool.py), or `functools.lru_cache` on a + factory function when you don't need to inspect the pool. +5. **Route all construction through the factory.** A single call site that + builds directly reintroduces duplicates silently; make the factory the + only public door. +6. **Assert the sharing in a test** — `get(k) is get(k)` and a distinct-count + ceiling — so a refactor that breaks interning fails loudly. + +## Python idioms that keep it small + +- **`functools.lru_cache` as the pool** when the key is the factory's + argument tuple and you never need eviction control or introspection. +- **Frozen dataclasses** give immutability, `__hash__`, and `__eq__` in one + decorator line. +- **Tuples as keys**: `(font, size, weight)` needs no key class. +- **`sys.intern`** when the flyweights are strings compared often. + +## Pitfalls + +- **Mutable flyweights** — one mutation corrupts every holder. The pool's + `strict=True` refuses values it can't verify as frozen. +- **Unbounded pools from user-supplied keys** are a memory leak wearing the + memory-optimization costume; bound them (`lru_cache(maxsize=...)`) or key + from a closed domain. +- **Equality vs identity confusion.** Sharing makes `is` work; code that + *relies* on `is` for correctness now silently depends on the pool being + the only constructor. +- **Interning by reflex** — without a measurement, the pattern is pure + ceremony (this unit's verdict in one line). + +## Worked example + +[`examples/glyph_styles/`](../examples/glyph_styles/) holds a ~30,000-glyph +document at three live `Style` objects and pins both the identity sharing +and the ceiling in tests: + +```bash +uv run python -m patterns.structural.flyweight.examples.glyph_styles +``` diff --git a/patterns/structural/flyweight/examples/__init__.py b/patterns/structural/flyweight/examples/__init__.py new file mode 100644 index 0000000..9dfaec4 --- /dev/null +++ b/patterns/structural/flyweight/examples/__init__.py @@ -0,0 +1 @@ +"""Mini-projects demonstrating the Flyweight pattern in practice.""" diff --git a/patterns/structural/flyweight/examples/glyph_styles/__init__.py b/patterns/structural/flyweight/examples/glyph_styles/__init__.py new file mode 100644 index 0000000..0bdf7f3 --- /dev/null +++ b/patterns/structural/flyweight/examples/glyph_styles/__init__.py @@ -0,0 +1,12 @@ +"""A text buffer sharing character styles through an intern pool. + +Run it: ``uv run python -m patterns.structural.flyweight.examples.glyph_styles`` +""" + +from patterns.structural.flyweight.examples.glyph_styles.document import ( + Document, + Style, + StyleBook, +) + +__all__ = ["Document", "Style", "StyleBook"] diff --git a/patterns/structural/flyweight/examples/glyph_styles/__main__.py b/patterns/structural/flyweight/examples/glyph_styles/__main__.py new file mode 100644 index 0000000..544b668 --- /dev/null +++ b/patterns/structural/flyweight/examples/glyph_styles/__main__.py @@ -0,0 +1,23 @@ +"""Demo: a large document, a tiny number of live Style objects.""" + +from __future__ import annotations + +from patterns.structural.flyweight.examples.glyph_styles.document import Document + + +def main() -> None: + doc = Document() + doc.write("Chapter One", font="Georgia", size=18, weight="bold") + for _ in range(1000): + doc.write("All happy families are alike. ", font="Georgia", size=11) + doc.write("THE END", font="Georgia", size=18, weight="bold") + + a = doc.glyphs[0].style + b = doc.glyphs[-1].style + print(f"glyphs in document: {len(doc):,}") + print(f"distinct styles: {doc.styles.distinct_styles}") + print(f"headers share one object: {a is b}") + + +if __name__ == "__main__": + main() diff --git a/patterns/structural/flyweight/examples/glyph_styles/document.py b/patterns/structural/flyweight/examples/glyph_styles/document.py new file mode 100644 index 0000000..3d2bde9 --- /dev/null +++ b/patterns/structural/flyweight/examples/glyph_styles/document.py @@ -0,0 +1,61 @@ +"""The mini-project: a document where every character carries a style. + +The GoF book's own motivating example, made measurable. Intrinsic state +(font, size, weight) is interned in a ``StyleBook``; extrinsic state (the +character, its position) stays with each occurrence. A million-character +document holds a handful of ``Style`` objects. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from patterns.structural.flyweight.pattern import InternPool + +StyleKey = tuple[str, int, str] # (font, size, weight) + + +@dataclass(frozen=True) +class Style: + """Intrinsic, shared, immutable — the flyweight.""" + + font: str + size: int + weight: str + + +@dataclass(frozen=True) +class Glyph: + """One occurrence: extrinsic state plus a reference to the shared style.""" + + char: str + style: Style + + +class StyleBook: + """The intern pool with a domain face: ask for a style, share the object.""" + + def __init__(self) -> None: + self._pool: InternPool[StyleKey, Style] = InternPool(lambda key: Style(*key), strict=True) + + def get(self, font: str, size: int, weight: str = "regular") -> Style: + return self._pool.get((font, size, weight)) + + @property + def distinct_styles(self) -> int: + return len(self._pool) + + +class Document: + """A text buffer whose glyphs share their styles.""" + + def __init__(self, styles: StyleBook | None = None) -> None: + self.styles = styles if styles is not None else StyleBook() + self.glyphs: list[Glyph] = [] + + def write(self, text: str, *, font: str, size: int, weight: str = "regular") -> None: + style = self.styles.get(font, size, weight) # one lookup per run of text + self.glyphs.extend(Glyph(char, style) for char in text) + + def __len__(self) -> int: + return len(self.glyphs) diff --git a/patterns/structural/flyweight/naive.py b/patterns/structural/flyweight/naive.py deleted file mode 100644 index 2304261..0000000 --- a/patterns/structural/flyweight/naive.py +++ /dev/null @@ -1,46 +0,0 @@ -"""The Gang of Four Flyweight: a factory in front of an instance pool. - -Cards are immutable; the factory returns the pooled instance when the same -card is requested again. -""" - -from __future__ import annotations - -from dataclasses import dataclass - - -@dataclass(frozen=True) -class Card: - """The flyweight: intrinsic state only, and frozen.""" - - rank: str - suit: str - - -class CardFactory: - """Checks the pool before constructing -- the book's central mechanism.""" - - def __init__(self) -> None: - self._pool: dict[tuple[str, str], Card] = {} - - def get(self, rank: str, suit: str) -> Card: - key = (rank, suit) - if key not in self._pool: - self._pool[key] = Card(rank, suit) - return self._pool[key] - - @property - def distinct_cards(self) -> int: - return len(self._pool) - - -def main() -> None: - factory = CardFactory() - hand = [factory.get("9", "♥"), factory.get("A", "♠"), factory.get("9", "♥")] - print(f"hand: {hand}") - print(f"shared: {hand[0] is hand[2]}") - print(f"distinct objects created: {factory.distinct_cards}") - - -if __name__ == "__main__": - main() diff --git a/patterns/structural/flyweight/pattern/__init__.py b/patterns/structural/flyweight/pattern/__init__.py new file mode 100644 index 0000000..5bb049f --- /dev/null +++ b/patterns/structural/flyweight/pattern/__init__.py @@ -0,0 +1,5 @@ +"""The Flyweight pattern, importable as library code.""" + +from patterns.structural.flyweight.pattern.pool import InternPool + +__all__ = ["InternPool"] diff --git a/patterns/structural/flyweight/pattern/pool.py b/patterns/structural/flyweight/pattern/pool.py new file mode 100644 index 0000000..6b2da03 --- /dev/null +++ b/patterns/structural/flyweight/pattern/pool.py @@ -0,0 +1,64 @@ +"""Flyweight as an importable building block: a keyed intern pool. + +``InternPool`` fronts construction with a pool: identical keys yield the +*identical* object. It is the explicit, inspectable form of what +``functools.lru_cache`` on a factory does implicitly — and the pool only +stays safe if the pooled values are immutable, which ``get`` can enforce. +""" + +from __future__ import annotations + +from collections.abc import Callable, Hashable +from dataclasses import fields, is_dataclass +from typing import Generic, TypeVar + +K = TypeVar("K", bound=Hashable) +V = TypeVar("V") + + +def _is_frozen(value: object) -> bool: + # Best-effort immutability check for the guard rail: frozen dataclasses + # and common immutable builtins pass; everything else is the caller's + # own risk and rejected under strict=True. + if is_dataclass(value) and not isinstance(value, type): + params = getattr(type(value), "__dataclass_params__", None) + return bool(params and params.frozen) and all( + _is_frozen(getattr(value, f.name)) for f in fields(value) + ) + return isinstance(value, (str, bytes, int, float, bool, frozenset, tuple, type(None))) + + +class InternPool(Generic[K, V]): + """Share one instance per distinct key. + + ``build`` constructs a value the first time a key appears; every later + request for that key returns the same object. With ``strict=True`` the + pool refuses values it cannot verify as immutable — a mutated shared + instance corrupts every holder at once. + """ + + def __init__(self, build: Callable[[K], V], *, strict: bool = False) -> None: + self._build = build + self._strict = strict + self._pool: dict[K, V] = {} + + def get(self, key: K) -> V: + """Return the shared instance for ``key``, building it on first use.""" + try: + return self._pool[key] + except KeyError: + value = self._build(key) + if self._strict and not _is_frozen(value): + raise TypeError( + f"InternPool(strict=True) refuses mutable value {value!r}; " + "flyweights must be immutable" + ) from None + self._pool[key] = value + return value + + def __len__(self) -> int: + """How many distinct instances exist — the number sharing saves you to.""" + return len(self._pool) + + def __contains__(self, key: object) -> bool: + return key in self._pool diff --git a/patterns/structural/flyweight/pythonic.py b/patterns/structural/flyweight/pythonic.py deleted file mode 100644 index d97caf0..0000000 --- a/patterns/structural/flyweight/pythonic.py +++ /dev/null @@ -1,53 +0,0 @@ -"""Two pythonic flyweights. - -1. ``functools.lru_cache`` on a factory function: the cache *is* the pool. -2. The guide's ``__new__`` variant: the class hides the pool, so plain - construction syntax returns shared instances. -""" - -from __future__ import annotations - -import functools -from typing import ClassVar - - -@functools.cache -def get_card(rank: str, suit: str) -> tuple[str, str]: - """The factory form: identical arguments yield the identical object.""" - return (rank, suit) - - -class Card: - """The __new__ form: ``Card('9', '♥') is Card('9', '♥')``. - - The pool is unbounded and unsynchronized: fine for a fixed domain like - 52 cards, wrong for unbounded user-supplied keys or racing threads. - """ - - _pool: ClassVar[dict[tuple[str, str], Card]] = {} - - rank: str - suit: str - - def __new__(cls, rank: str, suit: str) -> Card: - key = (rank, suit) - card = cls._pool.get(key) - if card is None: - card = super().__new__(cls) - card.rank = rank - card.suit = suit - cls._pool[key] = card - return card - - def __repr__(self) -> str: - return f"" - - -def main() -> None: - print(f"factory form shares: {get_card('9', '♥') is get_card('9', '♥')}") - print(f"__new__ form shares: {Card('9', '♥') is Card('9', '♥')}") - print(f"distinct stays distinct: {Card('9', '♥') is not Card('A', '♠')}") - - -if __name__ == "__main__": - main() diff --git a/patterns/structural/flyweight/real_world.py b/patterns/structural/flyweight/real_world.py deleted file mode 100644 index 96dd0ca..0000000 --- a/patterns/structural/flyweight/real_world.py +++ /dev/null @@ -1,32 +0,0 @@ -"""The interpreter's own flyweights. - -CPython interns small integers and many strings; ``sys.intern`` requests -interning explicitly, turning string equality into pointer equality. -""" - -from __future__ import annotations - -import sys - - -def small_ints_are_interned() -> bool: - """Integers in -5..256 are pre-built and shared.""" - a = 254 + 2 - b = 250 + 6 - return a is b - - -def interned_strings_share_identity() -> bool: - # Build strings at runtime so the compiler can't fold them together. - a = sys.intern("flyweight " + "pattern") - b = sys.intern("flyweight" + " pattern") - return a is b - - -def main() -> None: - print(f"small ints interned: {small_ints_are_interned()}") - print(f"sys.intern shares: {interned_strings_share_identity()}") - - -if __name__ == "__main__": - main() diff --git a/patterns/structural/flyweight/tests/test_flyweight.py b/patterns/structural/flyweight/tests/test_flyweight.py deleted file mode 100644 index 3cc24c2..0000000 --- a/patterns/structural/flyweight/tests/test_flyweight.py +++ /dev/null @@ -1,35 +0,0 @@ -"""Behavioral tests for all three flyweight variants.""" - -from patterns.structural.flyweight import naive, pythonic, real_world - - -class TestNaive: - def test_same_request_returns_shared_instance(self) -> None: - factory = naive.CardFactory() - assert factory.get("9", "♥") is factory.get("9", "♥") - - def test_pool_counts_distinct_only(self) -> None: - factory = naive.CardFactory() - for _ in range(10): - factory.get("9", "♥") - factory.get("A", "♠") - assert factory.distinct_cards == 2 - - -class TestPythonic: - def test_lru_cache_factory_shares(self) -> None: - assert pythonic.get_card("2", "♦") is pythonic.get_card("2", "♦") - - def test_dunder_new_shares_on_plain_construction(self) -> None: - assert pythonic.Card("9", "♥") is pythonic.Card("9", "♥") - - def test_distinct_values_stay_distinct(self) -> None: - assert pythonic.Card("9", "♥") is not pythonic.Card("A", "♠") - - -class TestRealWorld: - def test_small_int_interning(self) -> None: - assert real_world.small_ints_are_interned() - - def test_sys_intern(self) -> None: - assert real_world.interned_strings_share_identity() diff --git a/patterns/structural/flyweight/tests/test_glyph_styles.py b/patterns/structural/flyweight/tests/test_glyph_styles.py new file mode 100644 index 0000000..4bb2e06 --- /dev/null +++ b/patterns/structural/flyweight/tests/test_glyph_styles.py @@ -0,0 +1,40 @@ +"""Behavioral tests for the glyph_styles mini-project.""" + +from __future__ import annotations + +import dataclasses + +import pytest + +from patterns.structural.flyweight.examples.glyph_styles import Document + + +def test_many_glyphs_share_a_handful_of_styles() -> None: + doc = Document() + for _ in range(500): + doc.write("All happy families are alike. ", font="Georgia", size=11) + doc.write("THE END", font="Georgia", size=18, weight="bold") + assert len(doc) > 10_000 + assert doc.styles.distinct_styles == 2 + + +def test_identical_runs_share_the_identical_style_object() -> None: + doc = Document() + doc.write("one", font="Georgia", size=11) + doc.write("two", font="Georgia", size=11) + assert doc.glyphs[0].style is doc.glyphs[-1].style + + +def test_styles_are_frozen() -> None: + doc = Document() + doc.write("x", font="Georgia", size=11) + with pytest.raises(dataclasses.FrozenInstanceError): + doc.glyphs[0].style.size = 99 # type: ignore[misc] + + +def test_extrinsic_state_stays_per_glyph() -> None: + doc = Document() + doc.write("ab", font="Georgia", size=11) + first, second = doc.glyphs + assert (first.char, second.char) == ("a", "b") + assert first.style is second.style # shared core, distinct occurrences diff --git a/patterns/structural/flyweight/tests/test_pool.py b/patterns/structural/flyweight/tests/test_pool.py new file mode 100644 index 0000000..5f63a9f --- /dev/null +++ b/patterns/structural/flyweight/tests/test_pool.py @@ -0,0 +1,55 @@ +"""Behavioral tests for the InternPool building block.""" + +from __future__ import annotations + +from dataclasses import dataclass + +import pytest + +from patterns.structural.flyweight.pattern import InternPool + + +@dataclass(frozen=True) +class Color: + name: str + + +def test_same_key_yields_the_identical_object() -> None: + pool: InternPool[str, Color] = InternPool(Color) + assert pool.get("red") is pool.get("red") + + +def test_distinct_keys_stay_distinct() -> None: + pool: InternPool[str, Color] = InternPool(Color) + assert pool.get("red") is not pool.get("blue") + assert len(pool) == 2 + + +def test_build_runs_once_per_key() -> None: + built: list[str] = [] + + def build(name: str) -> Color: + built.append(name) + return Color(name) + + pool = InternPool(build) + pool.get("red"), pool.get("red"), pool.get("red") + assert built == ["red"] + + +def test_contains_reflects_what_was_interned() -> None: + pool: InternPool[str, Color] = InternPool(Color) + pool.get("red") + assert "red" in pool + assert "blue" not in pool + + +def test_strict_pool_accepts_frozen_values() -> None: + pool: InternPool[str, Color] = InternPool(Color, strict=True) + assert pool.get("red") is pool.get("red") + + +def test_strict_pool_refuses_mutable_values() -> None: + pool: InternPool[str, list[str]] = InternPool(lambda k: [k], strict=True) + with pytest.raises(TypeError, match="must be immutable"): + pool.get("red") diff --git a/patterns/structural/proxy/README.md b/patterns/structural/proxy/README.md index 8e5d1e6..0378966 100644 --- a/patterns/structural/proxy/README.md +++ b/patterns/structural/proxy/README.md @@ -14,31 +14,17 @@ stdlib_sightings: [weakref.proxy, functools.cached_property, unittest.mock.Mock] # Proxy -## Problem - -You want the *interface* of an object but not (yet, or not directly) the -object: constructing it is expensive, touching it needs a permission check, -or you want to observe every access. - -## Naive solution - -`naive.py` is the GoF virtual proxy: same interface as the real subject, -constructing it only on first use. - -## Pythonic solution - -`__getattr__` builds a generic lazy proxy in a dozen lines — no shared -interface needed, any attribute access triggers construction and then -forwards. And when the real goal is one lazily-computed attribute, -`functools.cached_property` replaces the whole apparatus. - -## In the wild - -`weakref.proxy` returns an object that forwards everything to its referent -without keeping it alive — and raises once the referent is gone. -`unittest.mock.Mock` is a proxy you interrogate afterwards. - -## Verdict - -**Use with care.** Powerful where laziness or mediation is real; remember the -disguise is skin-deep (identity, isinstance, dunders). +Stand between callers and an object to mediate access — lazily building it, +guarding it, observing it. **Verdict: use with care** — the mediation is real +power, the disguise is skin-deep. + +| Where | What | +|---|---| +| [`pattern/`](pattern/) | The importable code: `LazyProxy`, `ProtectionProxy`, `MeteringProxy` — stackable | +| [`docs/`](docs/) | [Fundamentals](docs/fundamentals.md) · [Implementation guide](docs/implementation.md) · [External examples](docs/examples.md) | +| [`examples/db_gateway/`](examples/db_gateway/) | Mini-project: an expensive warehouse connection behind all three proxies | +| [`tests/`](tests/) | Behavioral tests for the pattern and the mini-project | + +```bash +uv run python -m patterns.structural.proxy.examples.db_gateway +``` diff --git a/patterns/structural/proxy/__init__.py b/patterns/structural/proxy/__init__.py index f3f802a..a1476b3 100644 --- a/patterns/structural/proxy/__init__.py +++ b/patterns/structural/proxy/__init__.py @@ -1 +1,12 @@ -"""Proxy: a stand-in that controls access to the real object.""" +"""Proxy — public API. + +>>> from patterns.structural.proxy import LazyProxy +""" + +from patterns.structural.proxy.pattern import ( + LazyProxy, + MeteringProxy, + ProtectionProxy, +) + +__all__ = ["LazyProxy", "MeteringProxy", "ProtectionProxy"] diff --git a/patterns/structural/proxy/docs/examples.md b/patterns/structural/proxy/docs/examples.md new file mode 100644 index 0000000..72669f9 --- /dev/null +++ b/patterns/structural/proxy/docs/examples.md @@ -0,0 +1,40 @@ +# Proxy — where it lives outside this repo + +Cited, real implementations to study (or point an agent at) when designing or +reviewing proxy-shaped code. + +## Python standard library + +- **`weakref.proxy`.** Forwards everything to its referent without keeping + it alive; raises `ReferenceError` once the referent is collected — a + lifetime-mediating proxy in the box. + [docs.python.org/3/library/weakref.html#weakref.proxy](https://docs.python.org/3/library/weakref.html#weakref.proxy) +- **`functools.cached_property`.** The virtual proxy shrunk to its minimal + honest size: one attribute, computed on first access, cached after. + [docs.python.org/3/library/functools.html#functools.cached_property](https://docs.python.org/3/library/functools.html#functools.cached_property) +- **`unittest.mock.Mock`.** A stand-in you interrogate afterwards — the + smart-reference flavor: every access recorded, assertions available. + [docs.python.org/3/library/unittest.mock.html](https://docs.python.org/3/library/unittest.mock.html) + +## Major ecosystems + +- **Werkzeug `LocalProxy`** (Flask's `request` and `g`). Module-level names + that forward to context-local objects per request — remote-ish proxies to + "wherever the current context keeps it". + [werkzeug.palletsprojects.com/en/stable/local/](https://werkzeug.palletsprojects.com/en/stable/local/) +- **Django `SimpleLazyObject`** and lazy `QuerySet` evaluation. Virtual + proxies in a mainstream ORM: `request.user` is built only if touched; + querysets hit the database only when iterated. + [docs.djangoproject.com/en/stable/ref/models/querysets/#when-querysets-are-evaluated](https://docs.djangoproject.com/en/stable/ref/models/querysets/#when-querysets-are-evaluated) +- **`wrapt` / `lazy-object-proxy`.** Production-grade generic proxies whose + documentation is largely about the dunder problem — evidence for how hard + the full disguise really is. + [wrapt.readthedocs.io](https://wrapt.readthedocs.io/) + +## What to notice across all of them + +Each one mediates exactly one concern (lifetime, laziness, context, +recording), none pretend the disguise is complete — `weakref.proxy` +documents which operations see through it, Werkzeug documents `isinstance` +behavior — and the ones that must survive dunders (`wrapt`) pay a whole +library's worth of effort for it. diff --git a/patterns/structural/proxy/docs/fundamentals.md b/patterns/structural/proxy/docs/fundamentals.md new file mode 100644 index 0000000..a5d8d0b --- /dev/null +++ b/patterns/structural/proxy/docs/fundamentals.md @@ -0,0 +1,76 @@ +# Proxy — fundamentals + +## Intent + +Provide a surrogate for another object to control access to it. The proxy +offers the subject's interface but mediates: deferring construction +(virtual), guarding operations (protection), observing traffic (smart +reference), or standing in for something remote. + +## Participants + +| Role | Classic (GoF) form | Python form | +|---|---|---| +| Subject | Abstract interface proxy and real object share | No interface needed — `__getattr__` forwards anything | +| Real subject | The expensive/guarded/remote object | Same | +| Proxy | Implements the interface, holds the real subject | A dozen-line forwarding class — see [`pattern/proxies.py`](../pattern/proxies.py) | + +## Mechanism + +1. The proxy holds (or knows how to build) the subject. +2. Attribute access hits the proxy first; it applies its one mediation. +3. Then it forwards to the subject with plain `getattr`. +4. Proxies are objects too, so mediations stack — metering over protection + over laziness is three small classes composed, not one class with flags. + +## The classic form, and what Python absorbs + +The book's virtual proxy shares an abstract interface with its subject and +re-implements every method as a forwarding stub: + +```python +class Report(ABC): + @abstractmethod + def summary(self) -> str: ... + + +class ReportProxy(Report): # same interface, by inheritance + def __init__(self) -> None: + self._real: ExpensiveReport | None = None + + def summary(self) -> str: # one stub per subject method + if self._real is None: + self._real = ExpensiveReport() + return self._real.summary() +``` + +Python absorbs the ceremony twice. `__getattr__` — called only when normal +lookup fails — forwards the *entire* surface in one method, no shared +interface required. And when the real goal is "compute this one attribute +lazily", `functools.cached_property` is the whole pattern at the right size. +What Python does **not** absorb is the disguise: `isinstance` checks, +identity comparisons, and dunder lookups (which bypass `__getattr__` +entirely) all see through the proxy. That caveat leads this unit. + +## When to use it + +- Construction is genuinely expensive and often unnecessary (virtual). +- Operations need per-caller mediation — permissions, quotas, audit + (protection / smart reference). +- Several mediations must compose over one subject — the case a single + `cached_property` can't cover. + +## When not to use it + +- One lazily computed attribute → `functools.cached_property`. +- The mediation is per-*call* on known functions → that's a decorator; see + `structural/decorator`. +- Code downstream relies on `isinstance`/identity of the subject — the + disguise will leak, and dunder-dependent protocols (`len`, iteration, + context managers) won't forward. + +## Verdict: use with care + +Powerful where laziness or mediation is real; the skin-deep disguise is the +tax. Production-grade generic proxies (`wrapt`) exist precisely because the +dunder problem is hard — reach for them before hand-rolling cleverness. diff --git a/patterns/structural/proxy/docs/implementation.md b/patterns/structural/proxy/docs/implementation.md new file mode 100644 index 0000000..9f07804 --- /dev/null +++ b/patterns/structural/proxy/docs/implementation.md @@ -0,0 +1,67 @@ +# Proxy — putting it into a system + +## The smell it fixes + +Mediation logic fused into either the subject or every caller: + +```python +class WarehouseConnection: + def query(self, sql, *, role, audit_log): # the subject now knows + if role != "admin" and is_write(sql): # about roles... + raise PermissionError + audit_log.append(sql) # ...and about auditing + ... +``` + +The connection's job is querying. Permissions and audit are *access* +concerns — they belong between the caller and the subject, in a layer each +side can ignore. + +## Steps + +1. **Name the mediation**: deferral, guarding, observation, remoteness. One + proxy per concern — resist the mega-proxy with flags. +2. **Write each as a `__getattr__` forwarder** holding the subject (or its + factory). Keep the proxy's own attributes few; `__getattr__` only fires + for names not found on the proxy itself. +3. **Stack in the order the policy demands.** Outermost runs first: + metering outside protection counts denied attempts; protection outside + laziness means denied callers never pay construction. + [`examples/db_gateway/`](../examples/db_gateway/) pins exactly that order. +4. **Keep a proxy-free path** for code that legitimately owns the subject — + construction stays public, like any good facade or wrapper discipline. +5. **Test the mediation, not the forwarding**: assert the subject is *not* + built before first use, denied roles *never* reach it, counts match + traffic. Plain forwarding needs no tests of its own. + +## Python idioms that keep it small + +- **`__getattr__` (not `__getattribute__`)** — it fires only on lookup + misses, so the proxy's own state stays reachable and recursion stays away. +- **`functools.cached_property`** when the mediation is "one expensive + attribute, once" — the apparatus disappears into the stdlib. +- **`weakref.proxy`** when the mediation is lifetime, not access. +- **Factories, not eager subjects**, for virtual proxies: pass + `lambda: Connection(dsn)`, never a pre-built connection. + +## Pitfalls + +- **The disguise is skin-deep** — `isinstance`, `is`, and every dunder + bypass `__getattr__`. A proxied object that must support `len()`, + iteration, or `with` needs those dunders written explicitly. +- **`__getattr__` recursion**: initialize the proxy's own attributes before + any forwarding can happen, or route them through `object.__setattr__`. +- **Name shadowing**: an attribute the proxy defines (`access_counts`) wins + over the subject's attribute of the same name — keep proxy surfaces tiny. +- **Leaking the subject**: a mediated method that returns `self._subject` + hands callers an unguarded reference; return proxied results if the + guarantee matters. + +## Worked example + +[`examples/db_gateway/`](../examples/db_gateway/) stacks metering over +role-protection over a lazy warehouse connection: + +```bash +uv run python -m patterns.structural.proxy.examples.db_gateway +``` diff --git a/patterns/structural/proxy/examples/__init__.py b/patterns/structural/proxy/examples/__init__.py new file mode 100644 index 0000000..aefb262 --- /dev/null +++ b/patterns/structural/proxy/examples/__init__.py @@ -0,0 +1 @@ +"""Mini-projects demonstrating the Proxy pattern in practice.""" diff --git a/patterns/structural/proxy/examples/db_gateway/__init__.py b/patterns/structural/proxy/examples/db_gateway/__init__.py new file mode 100644 index 0000000..deefb61 --- /dev/null +++ b/patterns/structural/proxy/examples/db_gateway/__init__.py @@ -0,0 +1,11 @@ +"""An expensive warehouse connection behind stacked proxies. + +Run it: ``uv run python -m patterns.structural.proxy.examples.db_gateway`` +""" + +from patterns.structural.proxy.examples.db_gateway.gateway import ( + WarehouseConnection, + build_gateway, +) + +__all__ = ["WarehouseConnection", "build_gateway"] diff --git a/patterns/structural/proxy/examples/db_gateway/__main__.py b/patterns/structural/proxy/examples/db_gateway/__main__.py new file mode 100644 index 0000000..9c8f88a --- /dev/null +++ b/patterns/structural/proxy/examples/db_gateway/__main__.py @@ -0,0 +1,24 @@ +"""Demo: laziness, denial, and metering over one connection.""" + +from __future__ import annotations + +from patterns.structural.proxy.examples.db_gateway.gateway import ( + WarehouseConnection, + build_gateway, +) + + +def main() -> None: + analyst = build_gateway("warehouse://prod", role="analyst") + print(f"connections before any query: {WarehouseConnection.instances_connected}") + rows = analyst.query("SELECT sku, qty FROM stock") + print(f"first query connected lazily: {WarehouseConnection.instances_connected} -> {rows}") + try: + analyst.drop_table("stock") + except PermissionError as exc: + print(f"analyst denied: {exc}") + print(f"metered access counts: {dict(analyst.access_counts)}") + + +if __name__ == "__main__": + main() diff --git a/patterns/structural/proxy/examples/db_gateway/gateway.py b/patterns/structural/proxy/examples/db_gateway/gateway.py new file mode 100644 index 0000000..8aeb750 --- /dev/null +++ b/patterns/structural/proxy/examples/db_gateway/gateway.py @@ -0,0 +1,49 @@ +"""The mini-project: one expensive connection, three kinds of mediation. + +The stack, outside-in: metering observes everything (including denials), +protection guards by role, laziness defers the expensive connect until a +query actually runs. This composition over one subject is what a single +``cached_property`` cannot express -- and the reason the pattern survives. +""" + +from __future__ import annotations + +from patterns.structural.proxy.pattern import LazyProxy, MeteringProxy, ProtectionProxy + +READ_ONLY_ATTRS = frozenset({"query", "connected"}) + + +class WarehouseConnection: + """The real subject; pretend ``__init__`` dials a distant warehouse.""" + + instances_connected = 0 + + def __init__(self, dsn: str) -> None: + type(self).instances_connected += 1 + self.dsn = dsn + self.connected = True + self.queries_run: list[str] = [] + + def query(self, sql: str) -> list[str]: + self.queries_run.append(sql) + return [f"row for {sql!r}"] + + def drop_table(self, name: str) -> str: + return f"dropped {name}" + + +def allow_for_role(role: str) -> frozenset[str]: + """Analyst sees the read-only surface; admin sees everything.""" + return ( + frozenset({"query", "connected", "drop_table", "dsn", "queries_run"}) + if role == "admin" + else READ_ONLY_ATTRS + ) + + +def build_gateway(dsn: str, *, role: str) -> MeteringProxy: + """Stack the three proxies over one lazily-built connection.""" + allowed = allow_for_role(role) + lazy = LazyProxy(lambda: WarehouseConnection(dsn)) + guarded = ProtectionProxy(lazy, lambda name: name in allowed or name == "is_built") + return MeteringProxy(guarded) diff --git a/patterns/structural/proxy/naive.py b/patterns/structural/proxy/naive.py deleted file mode 100644 index a886ffc..0000000 --- a/patterns/structural/proxy/naive.py +++ /dev/null @@ -1,49 +0,0 @@ -"""The Gang of Four virtual proxy, translated literally. - -The proxy shares the subject's interface and defers the expensive -construction until the first real call. -""" - -from __future__ import annotations - -from abc import ABC, abstractmethod - - -class Report(ABC): - @abstractmethod - def summary(self) -> str: ... - - -class ExpensiveReport(Report): - """The real subject; pretend __init__ crunches a warehouse of data.""" - - instances_built = 0 - - def __init__(self) -> None: - type(self).instances_built += 1 - - def summary(self) -> str: - return "42 pages of insight" - - -class ReportProxy(Report): - """Same interface; builds the real subject only when first needed.""" - - def __init__(self) -> None: - self._real: ExpensiveReport | None = None - - def summary(self) -> str: - if self._real is None: - self._real = ExpensiveReport() - return self._real.summary() - - -def main() -> None: - proxy = ReportProxy() - print(f"built after construction: {ExpensiveReport.instances_built}") - print(proxy.summary()) - print(f"built after first use: {ExpensiveReport.instances_built}") - - -if __name__ == "__main__": - main() diff --git a/patterns/structural/proxy/pattern/__init__.py b/patterns/structural/proxy/pattern/__init__.py new file mode 100644 index 0000000..62c26aa --- /dev/null +++ b/patterns/structural/proxy/pattern/__init__.py @@ -0,0 +1,9 @@ +"""The Proxy pattern, importable as library code.""" + +from patterns.structural.proxy.pattern.proxies import ( + LazyProxy, + MeteringProxy, + ProtectionProxy, +) + +__all__ = ["LazyProxy", "MeteringProxy", "ProtectionProxy"] diff --git a/patterns/structural/proxy/pattern/proxies.py b/patterns/structural/proxy/pattern/proxies.py new file mode 100644 index 0000000..58b79bd --- /dev/null +++ b/patterns/structural/proxy/pattern/proxies.py @@ -0,0 +1,61 @@ +"""Three composable proxies: lazy, protection, metering. + +Each forwards attribute access to a subject via ``__getattr__`` -- no shared +interface required -- and each adds exactly one kind of mediation. Because +every proxy is also a plain object, they stack: +``MeteringProxy(ProtectionProxy(LazyProxy(build), allow))``. + +The disguise is skin-deep (this unit's standing caveat): ``isinstance``, +identity, and dunder lookups all see the proxy, not the subject. +""" + +from __future__ import annotations + +from collections import Counter +from collections.abc import Callable +from typing import Any + + +class LazyProxy: + """Defer construction: the subject is built on first attribute access.""" + + def __init__(self, factory: Callable[[], object]) -> None: + # object.__setattr__-free here: plain attributes are fine because + # __getattr__ only fires for names *not* found on the proxy itself. + self._factory = factory + self._subject: object | None = None + + @property + def is_built(self) -> bool: + """Whether the expensive subject exists yet.""" + return self._subject is not None + + def __getattr__(self, name: str) -> Any: + if self._subject is None: + self._subject = self._factory() + return getattr(self._subject, name) + + +class ProtectionProxy: + """Guard access: every attribute name passes ``allow`` or raises.""" + + def __init__(self, subject: object, allow: Callable[[str], bool]) -> None: + self._subject = subject + self._allow = allow + + def __getattr__(self, name: str) -> Any: + if not self._allow(name): + raise PermissionError(f"access to {name!r} denied") + return getattr(self._subject, name) + + +class MeteringProxy: + """Observe access: count every attribute lookup by name, then forward.""" + + def __init__(self, subject: object) -> None: + self._subject = subject + self.access_counts: Counter[str] = Counter() + + def __getattr__(self, name: str) -> Any: + self.access_counts[name] += 1 + return getattr(self._subject, name) diff --git a/patterns/structural/proxy/pythonic.py b/patterns/structural/proxy/pythonic.py deleted file mode 100644 index 2adb36b..0000000 --- a/patterns/structural/proxy/pythonic.py +++ /dev/null @@ -1,60 +0,0 @@ -"""Lazy access, two pythonic sizes. - -A generic ``__getattr__`` proxy defers construction of *any* object; and -when the goal is one expensive attribute, ``functools.cached_property`` -is the whole pattern. -""" - -from __future__ import annotations - -from collections.abc import Callable -from functools import cached_property -from typing import Any - - -class LazyProxy: - """Builds the real object on first attribute access, then forwards.""" - - def __init__(self, factory: Callable[[], object]) -> None: - # Avoid __setattr__/__getattr__ recursion via object.__setattr__. - object.__setattr__(self, "_factory", factory) - object.__setattr__(self, "_real", None) - - def __getattr__(self, name: str) -> Any: - real = object.__getattribute__(self, "_real") - if real is None: - real = object.__getattribute__(self, "_factory")() - object.__setattr__(self, "_real", real) - return getattr(real, name) - - -class Dataset: - """cached_property: the one-attribute proxy, built into functools.""" - - def __init__(self, raw: list[int]) -> None: - self.raw = raw - self.computations = 0 - - @cached_property - def stats(self) -> tuple[int, int]: - self.computations += 1 - return (min(self.raw), max(self.raw)) - - -def main() -> None: - built: list[str] = [] - - def factory() -> object: - built.append("now") - return "the real string" - - proxy = LazyProxy(factory) - print(f"built before use: {built}") - print(f"forwarded upper(): {proxy.upper()}, built: {built}") - - data = Dataset([3, 1, 4]) - print(f"stats {data.stats} computed {data.computations} time(s) over 2 reads: {data.stats}") - - -if __name__ == "__main__": - main() diff --git a/patterns/structural/proxy/real_world.py b/patterns/structural/proxy/real_world.py deleted file mode 100644 index c98fd22..0000000 --- a/patterns/structural/proxy/real_world.py +++ /dev/null @@ -1,40 +0,0 @@ -"""``weakref.proxy``: a stdlib proxy with teeth. - -It forwards attribute access to the referent without keeping it alive; -once the referent is collected, the proxy raises ReferenceError. -""" - -from __future__ import annotations - -import weakref - - -class Service: - def ping(self) -> str: - return "pong" - - -def live_proxy_forwards() -> str: - service = Service() - proxy = weakref.proxy(service) - return str(proxy.ping()) - - -def dead_proxy_raises() -> bool: - service = Service() - proxy = weakref.proxy(service) - del service # CPython refcounting collects immediately - try: - proxy.ping() - except ReferenceError: - return True - return False - - -def main() -> None: - print(f"live proxy: {live_proxy_forwards()}") - print(f"dead proxy raises ReferenceError: {dead_proxy_raises()}") - - -if __name__ == "__main__": - main() diff --git a/patterns/structural/proxy/tests/test_db_gateway.py b/patterns/structural/proxy/tests/test_db_gateway.py new file mode 100644 index 0000000..5b90298 --- /dev/null +++ b/patterns/structural/proxy/tests/test_db_gateway.py @@ -0,0 +1,45 @@ +"""Behavioral tests for the db_gateway mini-project.""" + +from __future__ import annotations + +import pytest + +from patterns.structural.proxy.examples.db_gateway import ( + WarehouseConnection, + build_gateway, +) + + +@pytest.fixture(autouse=True) +def reset_connection_counter() -> None: + WarehouseConnection.instances_connected = 0 + + +def test_no_connection_until_the_first_query() -> None: + gateway = build_gateway("warehouse://prod", role="analyst") + assert WarehouseConnection.instances_connected == 0 + rows = gateway.query("SELECT 1") + assert WarehouseConnection.instances_connected == 1 + assert rows == ["row for 'SELECT 1'"] + + +def test_denied_role_never_touches_the_subject() -> None: + gateway = build_gateway("warehouse://prod", role="analyst") + with pytest.raises(PermissionError): + gateway.drop_table("stock") + # The denial fired before the lazy layer: nothing ever connected. + assert WarehouseConnection.instances_connected == 0 + + +def test_admin_role_reaches_the_full_surface() -> None: + gateway = build_gateway("warehouse://prod", role="admin") + assert gateway.drop_table("stock") == "dropped stock" + + +def test_metering_counts_all_traffic_including_denials() -> None: + gateway = build_gateway("warehouse://prod", role="analyst") + gateway.query("SELECT 1") + gateway.query("SELECT 2") + with pytest.raises(PermissionError): + gateway.drop_table("stock") + assert gateway.access_counts == {"query": 2, "drop_table": 1} diff --git a/patterns/structural/proxy/tests/test_proxies.py b/patterns/structural/proxy/tests/test_proxies.py new file mode 100644 index 0000000..76bb707 --- /dev/null +++ b/patterns/structural/proxy/tests/test_proxies.py @@ -0,0 +1,76 @@ +"""Behavioral tests for the proxy building blocks.""" + +from __future__ import annotations + +import pytest + +from patterns.structural.proxy.pattern import LazyProxy, MeteringProxy, ProtectionProxy + + +class Subject: + def __init__(self) -> None: + self.color = "green" + + def greet(self) -> str: + return "hello" + + +def test_lazy_proxy_defers_construction_until_first_access() -> None: + built: list[str] = [] + + def factory() -> Subject: + built.append("now") + return Subject() + + proxy = LazyProxy(factory) + assert not proxy.is_built + assert built == [] + assert proxy.greet() == "hello" + assert proxy.is_built + assert built == ["now"] + + +def test_lazy_proxy_builds_exactly_once() -> None: + built: list[str] = [] + + def factory() -> Subject: + built.append("now") + return Subject() + + proxy = LazyProxy(factory) + proxy.greet(), proxy.greet(), proxy.color + assert built == ["now"] + + +def test_protection_proxy_forwards_allowed_and_denies_the_rest() -> None: + proxy = ProtectionProxy(Subject(), allow=lambda name: name == "greet") + assert proxy.greet() == "hello" + with pytest.raises(PermissionError, match="color"): + proxy.color # noqa: B018 — the access itself is the assertion + + +def test_metering_proxy_counts_each_attribute_access() -> None: + proxy = MeteringProxy(Subject()) + proxy.greet(), proxy.greet(), proxy.color + assert proxy.access_counts == {"greet": 2, "color": 1} + + +def test_stacked_proxies_compose_their_mediations() -> None: + built: list[str] = [] + + def factory() -> Subject: + built.append("now") + return Subject() + + stack = MeteringProxy(ProtectionProxy(LazyProxy(factory), lambda n: n == "greet")) + with pytest.raises(PermissionError): + stack.color # noqa: B018 — denied by the protection layer + assert built == [] # denial happened before the lazy layer built anything + assert stack.greet() == "hello" + assert built == ["now"] + assert stack.access_counts == {"color": 1, "greet": 1} # denials metered too + + +def test_the_disguise_is_skin_deep() -> None: + proxy = LazyProxy(Subject) + assert not isinstance(proxy, Subject) # the caveat, pinned diff --git a/patterns/structural/proxy/tests/test_proxy.py b/patterns/structural/proxy/tests/test_proxy.py deleted file mode 100644 index 9875110..0000000 --- a/patterns/structural/proxy/tests/test_proxy.py +++ /dev/null @@ -1,48 +0,0 @@ -"""Behavioral tests for all three proxy variants.""" - -from patterns.structural.proxy import naive, pythonic, real_world - - -class TestNaive: - def test_construction_is_deferred_until_first_use(self) -> None: - before = naive.ExpensiveReport.instances_built - proxy = naive.ReportProxy() - assert naive.ExpensiveReport.instances_built == before - assert proxy.summary() == "42 pages of insight" - assert naive.ExpensiveReport.instances_built == before + 1 - - def test_repeat_calls_reuse_the_subject(self) -> None: - before = naive.ExpensiveReport.instances_built - proxy = naive.ReportProxy() - proxy.summary() - proxy.summary() - assert naive.ExpensiveReport.instances_built == before + 1 - - -class TestPythonic: - def test_lazy_proxy_defers_then_forwards(self) -> None: - built: list[str] = [] - - def factory() -> object: - built.append("x") - return "abc" - - proxy = pythonic.LazyProxy(factory) - assert built == [] - assert proxy.upper() == "ABC" - assert proxy.startswith("a") - assert built == ["x"] # built exactly once - - def test_cached_property_computes_once(self) -> None: - data = pythonic.Dataset([3, 1, 4]) - assert data.stats == (1, 4) - assert data.stats == (1, 4) - assert data.computations == 1 - - -class TestRealWorld: - def test_live_weakref_proxy_forwards(self) -> None: - assert real_world.live_proxy_forwards() == "pong" - - def test_dead_weakref_proxy_raises(self) -> None: - assert real_world.dead_proxy_raises() From b6bdd3e6b9661004ec8bd9a16800bb45e66930ad Mon Sep 17 00:00:00 2001 From: SuperElectron Date: Thu, 27 Aug 2026 12:13:46 -0700 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20v2=20modules=20=E2=80=94=20structur?= =?UTF-8?q?al=20group=20(adapter,=20bridge,=20composite,=20decorator,=20fa?= =?UTF-8?q?cade,=20flyweight,=20proxy)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assembles feat/v2-structural-1 + -2. Also: fleet example-imports-pattern check upgraded to an AST import walk; legacy sandbox test moved onto the synthetic legacy fixture. Co-Authored-By: Claude Fable 5 --- tests/test_catalog.py | 25 +++++++++++++++++++++---- tests/test_sandbox.py | 8 +++++--- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/tests/test_catalog.py b/tests/test_catalog.py index 3b8e11f..67f1949 100644 --- a/tests/test_catalog.py +++ b/tests/test_catalog.py @@ -30,17 +30,34 @@ def test_catalog_contains_both_shapes_during_migration(self) -> None: def test_every_module_example_builds_on_its_own_pattern_package(self) -> None: # The mini-projects exist to show the pattern in practice: each one # must import its unit's pattern/ package, not reimplement the idea. - import re + # AST walk, not text search — a docstring mentioning the path is not + # an import. + import ast for pattern in load_catalog().patterns: if pattern.shape != "module": continue group, slug = pattern.id.split("/") absolute = f"patterns.{group}.{slug}.pattern" - relative = re.compile(r"from\s+\.+pattern\b|import\s+\.+pattern\b") for name, path in pattern.examples().items(): - sources = "\n".join(f.read_text() for f in sorted(path.rglob("*.py"))) - assert absolute in sources or relative.search(sources), ( + imports_pattern = False + for source_file in sorted(path.rglob("*.py")): + tree = ast.parse(source_file.read_text(), filename=str(source_file)) + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom): + module = node.module or "" + if module == absolute or module.startswith(f"{absolute}."): + imports_pattern = True + # Relative: from ..pattern import X / from ...pattern.chain import X + if node.level > 0 and ( + module == "pattern" or module.startswith("pattern.") + ): + imports_pattern = True + elif isinstance(node, ast.Import): + for alias in node.names: + if alias.name == absolute or alias.name.startswith(f"{absolute}."): + imports_pattern = True + assert imports_pattern, ( f"{pattern.id} example {name!r} never imports its own pattern package" ) diff --git a/tests/test_sandbox.py b/tests/test_sandbox.py index a5b20c4..6d70672 100644 --- a/tests/test_sandbox.py +++ b/tests/test_sandbox.py @@ -9,10 +9,12 @@ class TestSandbox: - def test_runs_a_real_example(self) -> None: - result = run_example(CATALOG, "structural/flyweight", "pythonic") + def test_runs_a_legacy_variant(self, legacy_catalog: Catalog) -> None: + # Legacy variants are a synthetic-fixture concern: every real unit + # migrates to the module shape. + result = run_example(legacy_catalog, "creational/oldthing", "pythonic") assert result.exit_code == 0 - assert "shares" in result.stdout + assert "pythonic oldthing runs" in result.stdout assert not result.timed_out def test_unknown_pattern_id_is_refused(self) -> None: From 4e4f21f79207fe12aa1f2a22d68674fb3e044eea Mon Sep 17 00:00:00 2001 From: SuperElectron Date: Thu, 27 Aug 2026 12:24:52 -0700 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20structural=20review=20findings=20?= =?UTF-8?q?=E2=80=94=20LazyProxy=20sentinel,=20recursive=20InternPool=20gu?= =?UTF-8?q?ard,=20fleet=20examples=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _MISSING sentinel caches falsy subjects; strict interning recurses into containers; bridge 2x3 asserted + registry house rule; all legacy sandbox tests on the synthetic fixture; parametrized run of every module unit's examples; facade rollback releases on any charge failure. Co-Authored-By: Claude Fable 5 --- .../adapter/tests/test_payment_gateways.py | 37 ++++++++++++---- .../examples/notification_center/center.py | 12 +++++- .../structural/bridge/tests/test_bridge.py | 15 +++++-- .../bridge/tests/test_notification_center.py | 11 ++++- .../composite/docs/implementation.md | 11 +++++ patterns/structural/composite/pattern/tree.py | 6 ++- .../decorator/docs/implementation.md | 12 ++++-- .../examples/resilient_client/service.py | 6 +-- .../decorator/tests/test_decorators.py | 5 +++ .../structural/facade/docs/implementation.md | 5 ++- .../structural/facade/pattern/checkout.py | 6 ++- .../structural/facade/tests/test_checkout.py | 23 ++++++++++ .../facade/tests/test_order_checkout.py | 13 ++++++ .../flyweight/docs/implementation.md | 2 +- patterns/structural/flyweight/pattern/pool.py | 7 +++- .../structural/flyweight/tests/test_pool.py | 25 +++++++++++ patterns/structural/proxy/pattern/proxies.py | 10 +++-- .../structural/proxy/tests/test_db_gateway.py | 22 ++++++++++ .../structural/proxy/tests/test_proxies.py | 29 ++++++++++++- tests/test_sandbox.py | 42 ++++++++++++++----- 20 files changed, 256 insertions(+), 43 deletions(-) diff --git a/patterns/structural/adapter/tests/test_payment_gateways.py b/patterns/structural/adapter/tests/test_payment_gateways.py index 179723f..1ba542e 100644 --- a/patterns/structural/adapter/tests/test_payment_gateways.py +++ b/patterns/structural/adapter/tests/test_payment_gateways.py @@ -6,6 +6,8 @@ from __future__ import annotations +from collections.abc import Callable + import pytest from patterns.structural.adapter.examples.payment_gateways import ( @@ -27,22 +29,30 @@ def paypal() -> PaymentProcessor: return PayPalAdapter(PayPalLikeGateway()) -@pytest.mark.parametrize("processor", [stripe(), paypal()], ids=["stripe-like", "paypal-like"]) +@pytest.mark.parametrize("make_processor", [stripe, paypal], ids=["stripe-like", "paypal-like"]) class TestAnyVendor: - """One suite, every adapter: the client contract is vendor-independent.""" + """One suite, every adapter: the client contract is vendor-independent. + + Adapters are built inside each test — construction at collection time + would share instances across the class and break if a vendor gains state. + """ - def test_a_normal_charge_pays_the_order(self, processor: PaymentProcessor) -> None: - receipt = checkout("A-1", 2_499, processor) + def test_a_normal_charge_pays_the_order( + self, make_processor: Callable[[], PaymentProcessor] + ) -> None: + receipt = checkout("A-1", 2_499, make_processor()) assert receipt.paid assert receipt.reference != "" - def test_a_huge_charge_is_declined_not_raised(self, processor: PaymentProcessor) -> None: - receipt = checkout("A-2", 999_999, processor) + def test_a_huge_charge_is_declined_not_raised( + self, make_processor: Callable[[], PaymentProcessor] + ) -> None: + receipt = checkout("A-2", 999_999, make_processor()) assert not receipt.paid assert receipt.note != "" - def test_a_zero_charge_is_refused(self, processor: PaymentProcessor) -> None: - assert not checkout("A-3", 0, processor).paid + def test_a_zero_charge_is_refused(self, make_processor: Callable[[], PaymentProcessor]) -> None: + assert not checkout("A-3", 0, make_processor()).paid class TestTranslationDetails: @@ -56,6 +66,17 @@ def test_paypal_exceptions_become_results(self) -> None: assert not result.ok assert "DECLINED" in result.reason + def test_stripe_currency_is_normalized_to_lowercase(self) -> None: + seen: list[str] = [] + + class RecordingStripe(StripeLikeClient): + def create_charge(self, amount_cents: int, currency: str) -> dict[str, str]: + seen.append(currency) + return super().create_charge(amount_cents, currency) + + StripeAdapter(RecordingStripe()).charge(2_499, "USD") + assert seen == ["usd"] + def test_stripe_extras_stay_reachable_through_forwarding(self) -> None: adapter = StripeAdapter(StripeLikeClient()) assert "all systems normal" in adapter.diagnostics() diff --git a/patterns/structural/bridge/examples/notification_center/center.py b/patterns/structural/bridge/examples/notification_center/center.py index f9b8df2..456d979 100644 --- a/patterns/structural/bridge/examples/notification_center/center.py +++ b/patterns/structural/bridge/examples/notification_center/center.py @@ -28,7 +28,15 @@ class NotificationCenter: def __init__(self) -> None: self._channels: dict[str, TeamChannel] = {} - def register(self, channel: TeamChannel) -> None: + def register(self, channel: TeamChannel, *, replace: bool = False) -> None: + """Add a team's channel; refuses to silently drop an existing one. + + Pass ``replace=True`` to intentionally swap a team's transport. + """ + if channel.team in self._channels and not replace: + raise ValueError( + f"team {channel.team!r} already has a channel; pass replace=True to swap it" + ) self._channels[channel.team] = channel @property @@ -38,6 +46,8 @@ def teams(self) -> list[str]: def alert(self, teams: list[str], severity: str, message: str) -> None: """Page specific teams through their chosen transports.""" for team in teams: + if team not in self._channels: + raise KeyError(f"unknown team {team!r}; registered teams: {self.teams}") channel = self._channels[team] AlertNotifier(channel.transport, channel.address).alert(severity, message) diff --git a/patterns/structural/bridge/tests/test_bridge.py b/patterns/structural/bridge/tests/test_bridge.py index a67a1ae..0b6ab10 100644 --- a/patterns/structural/bridge/tests/test_bridge.py +++ b/patterns/structural/bridge/tests/test_bridge.py @@ -8,16 +8,25 @@ EmailTransport, SlackTransport, SmsTransport, + Transport, ) class TestAxesCompose: def test_any_notifier_works_over_any_transport(self) -> None: - for make_transport in (EmailTransport, SlackTransport, SmsTransport): - transport = make_transport() + # 2 notifiers x 3 transports: every combination must actually deliver. + email, slack, sms = EmailTransport(), SlackTransport(), SmsTransport() + channels: list[tuple[Transport, list[str]]] = [ + (email, email.outbox), + (slack, slack.posts), + (sms, sms.messages), + ] + for transport, delivered in channels: AlertNotifier(transport, "ops").alert("critical", "disk full") DigestNotifier(transport, "ops").digest(["a", "b"]) - # No combination raised: 2 kinds x 3 transports from 5 classes. + assert len(delivered) == 2 + assert "[CRITICAL] disk full" in delivered[0] + assert "2 updates" in delivered[1] def test_alert_formats_severity_upfront(self) -> None: slack = SlackTransport() diff --git a/patterns/structural/bridge/tests/test_notification_center.py b/patterns/structural/bridge/tests/test_notification_center.py index 6a757b4..e738154 100644 --- a/patterns/structural/bridge/tests/test_notification_center.py +++ b/patterns/structural/bridge/tests/test_notification_center.py @@ -40,13 +40,20 @@ def test_digest_broadcasts_to_every_registered_team(self) -> None: center.broadcast_digest(["3 deploys"]) assert len(slack.posts) == len(sms.messages) == len(email.outbox) == 1 - def test_reregistering_a_team_switches_its_transport(self) -> None: + def test_reregistering_a_team_requires_explicit_replace(self) -> None: center, slack, _, email = build_center() - center.register(TeamChannel("platform", email, "platform@example.com")) + with pytest.raises(ValueError, match="platform"): + center.register(TeamChannel("platform", email, "platform@example.com")) + center.register(TeamChannel("platform", email, "platform@example.com"), replace=True) center.alert(["platform"], "warn", "retrying") assert slack.posts == [] assert "platform@example.com" in email.outbox[0] + def test_alerting_an_unregistered_team_names_the_known_ones(self) -> None: + center, *_ = build_center() + with pytest.raises(KeyError, match="payments"): + center.alert(["nope"], "critical", "who hears this?") + def test_teams_lists_registrations(self) -> None: center, *_ = build_center() assert center.teams == ["payments", "platform", "support"] diff --git a/patterns/structural/composite/docs/implementation.md b/patterns/structural/composite/docs/implementation.md index 1c9ba63..cf3edb4 100644 --- a/patterns/structural/composite/docs/implementation.md +++ b/patterns/structural/composite/docs/implementation.md @@ -37,8 +37,19 @@ container once; operations become one method both node kinds answer. `not hasattr(leaf, "add")`). ```python +from dataclasses import dataclass + from patterns.structural.composite import Composite + +@dataclass(frozen=True) +class Task: # a leaf: totals itself, has no child API + hours: int + + def total(self) -> int: + return self.hours + + team = Composite(sum, [Task(3), Task(5)]) project = Composite(sum, [team, Task(8)]) assert project.total() == 16 diff --git a/patterns/structural/composite/pattern/tree.py b/patterns/structural/composite/pattern/tree.py index fc7ae0b..9e8189c 100644 --- a/patterns/structural/composite/pattern/tree.py +++ b/patterns/structural/composite/pattern/tree.py @@ -38,7 +38,11 @@ def add(self, child: HasTotal[V]) -> None: self._children.append(child) def remove(self, child: HasTotal[V]) -> None: - """Remove a direct child; ``ValueError`` if it is not one.""" + """Remove the first ``==``-equal direct child; ``ValueError`` if none. + + With value-equal leaves (frozen dataclasses), "first equal" may not + be the identical object you hold a reference to. + """ self._children.remove(child) def total(self) -> V: diff --git a/patterns/structural/decorator/docs/implementation.md b/patterns/structural/decorator/docs/implementation.md index 28c334d..1fe7160 100644 --- a/patterns/structural/decorator/docs/implementation.md +++ b/patterns/structural/decorator/docs/implementation.md @@ -52,9 +52,10 @@ everyone and therefore to no one. Each becomes a decorator written once. - **Forgetting `functools.wraps`** — the wrapped function's name, docstring, and signature vanish; stack traces and debuggers lie. -- **Order accidents.** `retry(logged(f))` logs once per attempt; - `logged(retry(f))` logs once per operation. Both are useful; only one is - what you meant. +- **Order accidents.** These are decorator *factories* — call them first. + `retry(3)(logged(log)(f))` logs once per attempt; + `logged(log)(retry(3)(f))` logs once per operation. Both are useful; only + one is what you meant. - **Decorators that swallow exceptions** turn control flow invisible; add behavior around the call, don't change its contract. - **Hidden effects** (module-level clocks, global sleeps) make wrapped code @@ -65,7 +66,10 @@ everyone and therefore to no one. Each becomes a decorator written once. ## Worked example [`examples/resilient_client/`](../examples/resilient_client/) hardens a flaky -payments client with the full stack and pins the ordering policy in tests: +payments client with the retry/logging/rate-limit stack and pins the ordering +policy in tests. `timed` is deliberately left out of that stack: latency is +measured around the whole hardened call at the edge, not baked between the +layers — slot it outermost when you want it: ```bash uv run python -m patterns.structural.decorator.examples.resilient_client diff --git a/patterns/structural/decorator/examples/resilient_client/service.py b/patterns/structural/decorator/examples/resilient_client/service.py index c40e445..a832f9a 100644 --- a/patterns/structural/decorator/examples/resilient_client/service.py +++ b/patterns/structural/decorator/examples/resilient_client/service.py @@ -8,6 +8,7 @@ from __future__ import annotations +import time from collections.abc import Callable from patterns.structural.decorator.examples.resilient_client.client import ( @@ -24,7 +25,7 @@ def build_charge( max_attempts: int = 3, max_calls: int = 5, window: float = 1.0, - clock: Callable[[], float] | None = None, + clock: Callable[[], float] = time.monotonic, ) -> Callable[[str, int], str]: """Wrap ``api.charge`` in retry -> logging -> rate limit, innermost first.""" @@ -34,5 +35,4 @@ def charge(card: str, amount_cents: int) -> str: hardened = retry(max_attempts, on=(TransientNetworkError,))(charge) hardened = logged(log)(hardened) - limiter = rate_limited(max_calls, window, clock) if clock else rate_limited(max_calls, window) - return limiter(hardened) + return rate_limited(max_calls, window, clock)(hardened) diff --git a/patterns/structural/decorator/tests/test_decorators.py b/patterns/structural/decorator/tests/test_decorators.py index 4be388e..bbe14b4 100644 --- a/patterns/structural/decorator/tests/test_decorators.py +++ b/patterns/structural/decorator/tests/test_decorators.py @@ -122,3 +122,8 @@ def documented() -> None: assert documented.__name__ == "documented" assert documented.__doc__ == "The docstring survives the stack." + + +def test_retry_refuses_a_nonsensical_attempt_count() -> None: + with pytest.raises(ValueError, match="attempts"): + retry(0) diff --git a/patterns/structural/facade/docs/implementation.md b/patterns/structural/facade/docs/implementation.md index 8aa9742..1606c74 100644 --- a/patterns/structural/facade/docs/implementation.md +++ b/patterns/structural/facade/docs/implementation.md @@ -55,7 +55,10 @@ policy; policies live in one place. ## Worked example [`examples/order_checkout/`](../examples/order_checkout/) processes a batch of -orders — one declined card among them — through the single checkout door: +orders — one declined card among them — through the single checkout door. +Unusually for this catalog, the pattern package carries the whole domain: +`place_order` *is* the facade, so the mini-project adds only the batch +processing and the full-controls bypass around it: ```bash uv run python -m patterns.structural.facade.examples.order_checkout diff --git a/patterns/structural/facade/pattern/checkout.py b/patterns/structural/facade/pattern/checkout.py index 8f3543c..90eba94 100644 --- a/patterns/structural/facade/pattern/checkout.py +++ b/patterns/structural/facade/pattern/checkout.py @@ -78,8 +78,10 @@ def place_order( warehouse.reserve(sku, quantity) try: txn = gateway.charge(card, price_cents * quantity) - except PermissionError: - warehouse.release(sku, quantity) # the step copy-paste always forgets + except Exception: + # Any charge failure — declined card or gateway blowup — must hand + # the reservation back; this is the step copy-paste always forgets. + warehouse.release(sku, quantity) raise # Honest boundary: a crash below this line leaves the charge captured. # Real systems make charge/label/notify a saga (compensate on failure) diff --git a/patterns/structural/facade/tests/test_checkout.py b/patterns/structural/facade/tests/test_checkout.py index 28ff344..5d9a87f 100644 --- a/patterns/structural/facade/tests/test_checkout.py +++ b/patterns/structural/facade/tests/test_checkout.py @@ -62,6 +62,29 @@ def test_declined_payment_rolls_back_the_reservation() -> None: assert notifier.sent == [] +def test_gateway_blowup_also_releases_the_reservation() -> None: + # Rollback must cover ANY charge failure, not just the declined path. + class ExplodingGateway(PaymentGateway): + def charge(self, card: str, amount_cents: int) -> str: + raise ConnectionError("gateway unreachable") + + warehouse, _, shipping, notifier = build_subsystem() + with pytest.raises(ConnectionError): + place_order( + warehouse, + ExplodingGateway(), + shipping, + notifier, + sku="mug", + quantity=3, + price_cents=1000, + card="4242", + address="9 Hopper St", + ) + assert warehouse.stock["mug"] == 10 # released, not leaked + assert shipping.labels == [] + + def test_insufficient_stock_stops_before_any_charge() -> None: warehouse, gateway, shipping, notifier = build_subsystem(stock=1) with pytest.raises(LookupError): diff --git a/patterns/structural/facade/tests/test_order_checkout.py b/patterns/structural/facade/tests/test_order_checkout.py index 92db8a5..49cc014 100644 --- a/patterns/structural/facade/tests/test_order_checkout.py +++ b/patterns/structural/facade/tests/test_order_checkout.py @@ -39,6 +39,19 @@ def test_declined_order_leaves_stock_untouched_for_the_rest_of_the_batch() -> No assert len(store.gateway.charges) == 1 +def test_insufficient_stock_lands_in_the_failed_bucket() -> None: + store = build_store() + fulfilled, failed = store.process( + [ + Order("tee", 99, 2500, "4242", "9 Hopper St"), # only 3 in stock + Order("mug", 1, 1200, "4242", "12 Grace Ave"), + ] + ) + assert len(fulfilled) == 1 + assert [(o.sku, "tee" in reason) for o, reason in failed] == [("tee", True)] + assert store.gateway.charges == [("4242", 1200)] # the doomed order never charged + + def test_full_controls_path_bypasses_the_facade() -> None: store = build_store() store.restock("mug", 5) diff --git a/patterns/structural/flyweight/docs/implementation.md b/patterns/structural/flyweight/docs/implementation.md index 5d7a860..f7fb1d3 100644 --- a/patterns/structural/flyweight/docs/implementation.md +++ b/patterns/structural/flyweight/docs/implementation.md @@ -60,7 +60,7 @@ and memory pays for the duplication a million-fold. ## Worked example [`examples/glyph_styles/`](../examples/glyph_styles/) holds a ~30,000-glyph -document at three live `Style` objects and pins both the identity sharing +document at two live `Style` objects and pins both the identity sharing and the ceiling in tests: ```bash diff --git a/patterns/structural/flyweight/pattern/pool.py b/patterns/structural/flyweight/pattern/pool.py index 6b2da03..33381f3 100644 --- a/patterns/structural/flyweight/pattern/pool.py +++ b/patterns/structural/flyweight/pattern/pool.py @@ -19,13 +19,16 @@ def _is_frozen(value: object) -> bool: # Best-effort immutability check for the guard rail: frozen dataclasses # and common immutable builtins pass; everything else is the caller's - # own risk and rejected under strict=True. + # own risk and rejected under strict=True. Containers are only as frozen + # as their elements — a tuple holding a list is mutable where it counts. if is_dataclass(value) and not isinstance(value, type): params = getattr(type(value), "__dataclass_params__", None) return bool(params and params.frozen) and all( _is_frozen(getattr(value, f.name)) for f in fields(value) ) - return isinstance(value, (str, bytes, int, float, bool, frozenset, tuple, type(None))) + if isinstance(value, (tuple, frozenset)): + return all(_is_frozen(item) for item in value) + return isinstance(value, (str, bytes, int, float, bool, type(None))) class InternPool(Generic[K, V]): diff --git a/patterns/structural/flyweight/tests/test_pool.py b/patterns/structural/flyweight/tests/test_pool.py index 5f63a9f..4e6783b 100644 --- a/patterns/structural/flyweight/tests/test_pool.py +++ b/patterns/structural/flyweight/tests/test_pool.py @@ -53,3 +53,28 @@ def test_strict_pool_refuses_mutable_values() -> None: pool: InternPool[str, list[str]] = InternPool(lambda k: [k], strict=True) with pytest.raises(TypeError, match="must be immutable"): pool.get("red") + + +def test_strict_pool_refuses_a_tuple_holding_a_mutable() -> None: + # A tuple is only as frozen as its elements: mutating the inner list + # would corrupt every holder of the shared value. + pool: InternPool[str, tuple[list[str]]] = InternPool(lambda k: ([k],), strict=True) + with pytest.raises(TypeError, match="must be immutable"): + pool.get("red") + + +def test_strict_pool_accepts_deeply_frozen_nesting() -> None: + pool: InternPool[str, tuple[object, ...]] = InternPool( + lambda k: (k, frozenset({(k, 1)}), Color(k)), strict=True + ) + assert pool.get("red") is pool.get("red") + + +def test_strict_pool_refuses_a_frozen_dataclass_with_a_mutable_field() -> None: + @dataclass(frozen=True) + class Palette: + names: list[str] + + pool: InternPool[str, Palette] = InternPool(lambda k: Palette([k]), strict=True) + with pytest.raises(TypeError, match="must be immutable"): + pool.get("red") diff --git a/patterns/structural/proxy/pattern/proxies.py b/patterns/structural/proxy/pattern/proxies.py index 58b79bd..d9bba73 100644 --- a/patterns/structural/proxy/pattern/proxies.py +++ b/patterns/structural/proxy/pattern/proxies.py @@ -15,6 +15,10 @@ from collections.abc import Callable from typing import Any +# Not-yet-built marker: ``None`` won't do, because a factory may legitimately +# return ``None`` and that result must still be cached exactly once. +_MISSING = object() + class LazyProxy: """Defer construction: the subject is built on first attribute access.""" @@ -23,15 +27,15 @@ def __init__(self, factory: Callable[[], object]) -> None: # object.__setattr__-free here: plain attributes are fine because # __getattr__ only fires for names *not* found on the proxy itself. self._factory = factory - self._subject: object | None = None + self._subject: object = _MISSING @property def is_built(self) -> bool: """Whether the expensive subject exists yet.""" - return self._subject is not None + return self._subject is not _MISSING def __getattr__(self, name: str) -> Any: - if self._subject is None: + if self._subject is _MISSING: self._subject = self._factory() return getattr(self._subject, name) diff --git a/patterns/structural/proxy/tests/test_db_gateway.py b/patterns/structural/proxy/tests/test_db_gateway.py index 5b90298..22e7686 100644 --- a/patterns/structural/proxy/tests/test_db_gateway.py +++ b/patterns/structural/proxy/tests/test_db_gateway.py @@ -43,3 +43,25 @@ def test_metering_counts_all_traffic_including_denials() -> None: with pytest.raises(PermissionError): gateway.drop_table("stock") assert gateway.access_counts == {"query": 2, "drop_table": 1} + + +def test_analyst_can_read_connected_but_not_dsn() -> None: + gateway = build_gateway("warehouse://prod", role="analyst") + gateway.query("SELECT 1") # force the connection into existence + assert gateway.connected is True + with pytest.raises(PermissionError): + gateway.dsn # noqa: B018 — the access itself is the assertion + + +def test_admin_reads_dsn_and_query_log() -> None: + gateway = build_gateway("warehouse://prod", role="admin") + gateway.query("SELECT 1") + assert gateway.dsn == "warehouse://prod" + assert gateway.queries_run == ["SELECT 1"] + + +def test_is_built_passes_through_the_stack() -> None: + gateway = build_gateway("warehouse://prod", role="analyst") + assert gateway.is_built is False + gateway.query("SELECT 1") + assert gateway.is_built is True diff --git a/patterns/structural/proxy/tests/test_proxies.py b/patterns/structural/proxy/tests/test_proxies.py index 76bb707..6410434 100644 --- a/patterns/structural/proxy/tests/test_proxies.py +++ b/patterns/structural/proxy/tests/test_proxies.py @@ -42,6 +42,24 @@ def factory() -> Subject: assert built == ["now"] +def test_lazy_proxy_caches_a_none_subject_once() -> None: + # A factory may legitimately produce None (e.g. a failed connect that the + # caller inspects); that result is still "built" and must not retrigger. + built: list[str] = [] + + def factory() -> None: + built.append("now") + return None + + proxy = LazyProxy(factory) + with pytest.raises(AttributeError): + proxy.anything # noqa: B018 — the access itself is the trigger + with pytest.raises(AttributeError): + proxy.other # noqa: B018 + assert built == ["now"] + assert proxy.is_built + + def test_protection_proxy_forwards_allowed_and_denies_the_rest() -> None: proxy = ProtectionProxy(Subject(), allow=lambda name: name == "greet") assert proxy.greet() == "hello" @@ -72,5 +90,12 @@ def factory() -> Subject: def test_the_disguise_is_skin_deep() -> None: - proxy = LazyProxy(Subject) - assert not isinstance(proxy, Subject) # the caveat, pinned + # Dunder lookups bypass __getattr__: a subject that supports len() does + # not make the proxy support it. That is the caveat, behaviorally. + class Sized: + def __len__(self) -> int: + return 3 + + proxy = LazyProxy(Sized) + with pytest.raises(TypeError): + len(proxy) diff --git a/tests/test_sandbox.py b/tests/test_sandbox.py index 6d70672..5ca3246 100644 --- a/tests/test_sandbox.py +++ b/tests/test_sandbox.py @@ -17,21 +17,23 @@ def test_runs_a_legacy_variant(self, legacy_catalog: Catalog) -> None: assert "pythonic oldthing runs" in result.stdout assert not result.timed_out - def test_unknown_pattern_id_is_refused(self) -> None: + def test_unknown_pattern_id_is_refused(self, legacy_catalog: Catalog) -> None: with pytest.raises(KeyError): - run_example(CATALOG, "../../etc/passwd", "naive") + run_example(legacy_catalog, "../../etc/passwd", "naive") - def test_unknown_variant_is_refused(self) -> None: + def test_unknown_variant_is_refused(self, legacy_catalog: Catalog) -> None: + # Against a unit that HAS variants, so the refusal is a real selection + # miss, not the empty-variants degenerate case. with pytest.raises(KeyError, match="no variant"): - run_example(CATALOG, "structural/flyweight", "__init__") + run_example(legacy_catalog, "creational/oldthing", "__init__") - def test_traversal_shaped_variant_is_refused(self) -> None: + def test_traversal_shaped_variant_is_refused(self, legacy_catalog: Catalog) -> None: with pytest.raises(KeyError): - run_example(CATALOG, "structural/flyweight", "../../../tmp/evil") + run_example(legacy_catalog, "creational/oldthing", "../../../tmp/evil") - def test_failing_example_reports_not_raises(self) -> None: + def test_failing_example_reports_not_raises(self, legacy_catalog: Catalog) -> None: # every current example exits 0; simulate by checking the API shape - result = run_example(CATALOG, "behavioral/command", "real_world") + result = run_example(legacy_catalog, "creational/oldthing", "real_world") assert isinstance(result.exit_code, int) assert isinstance(result.stderr, str) @@ -55,9 +57,9 @@ def test_unknown_pattern_id_is_refused(self, module_catalog: Catalog) -> None: with pytest.raises(KeyError): run_example_package(module_catalog, "../../etc/passwd", "demo") - def test_legacy_unit_has_no_packages(self) -> None: + def test_legacy_unit_has_no_packages(self, legacy_catalog: Catalog) -> None: with pytest.raises(KeyError, match="no example"): - run_example_package(CATALOG, "structural/flyweight", "pythonic") + run_example_package(legacy_catalog, "creational/oldthing", "pythonic") def test_runs_the_real_pilot_unit(self) -> None: # The migrated unit itself, through the python -I -m path CI must cover. @@ -69,6 +71,26 @@ def test_runs_the_real_pilot_unit(self) -> None: assert not result.timed_out +def _every_module_example() -> list[tuple[str, str]]: + return [ + (pattern.id, example) + for pattern in CATALOG.patterns + if pattern.shape == "module" + for example in sorted(pattern.examples()) + ] + + +class TestEveryExampleRuns: + """Demo rot check: every module unit's every example runs in the sandbox.""" + + @pytest.mark.parametrize(("pattern_id", "example"), _every_module_example()) + def test_example_exits_cleanly(self, pattern_id: str, example: str) -> None: + result = run_example_package(CATALOG, pattern_id, example) + assert result.exit_code == 0, f"{pattern_id}/{example}: {result.stderr}" + assert not result.timed_out + assert result.stdout.strip(), f"{pattern_id}/{example} printed nothing" + + class TestSearchIndex: def test_symptom_search_hits_the_right_unit(self) -> None: from design_patterns.mcp.search import SearchIndex