From da991b8706453b66a8ca8c742663414b6b00d5ce Mon Sep 17 00:00:00 2001 From: SuperElectron Date: Wed, 26 Aug 2026 17:31:57 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20five=20modern-Python=20patterns=20?= =?UTF-8?q?=E2=80=94=20catalog=20complete=20at=2032=20units?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New group patterns/modern/: - dependency_injection (pythonic) — Protocol seams, kwarg defaults; sorted(key=), json.dumps(cls=) - repository (use-with-care) — Protocol + in-memory fake + sqlite impl - context_manager (pythonic) — __enter__/__exit__, @contextmanager, ExitStack - registry (pythonic) — decorator-filled dispatch dict; codecs - async_producer_consumer (use-with-care) — asyncio.Queue + TaskGroup with backpressure and tested shutdown; thread version for contrast Adds pytest-asyncio (auto mode). 211 tests; ruff/mypy --strict clean; all 96 demos run as modules. Co-Authored-By: Claude Fable 5 --- patterns/modern/__init__.py | 1 + .../modern/async_producer_consumer/README.md | 44 +++++++++++++++ .../async_producer_consumer/__init__.py | 1 + .../modern/async_producer_consumer/naive.py | 39 +++++++++++++ .../async_producer_consumer/pythonic.py | 41 ++++++++++++++ .../async_producer_consumer/real_world.py | 49 +++++++++++++++++ .../async_producer_consumer/tests/__init__.py | 0 .../tests/test_async_producer_consumer.py | 36 ++++++++++++ patterns/modern/context_manager/README.md | 44 +++++++++++++++ patterns/modern/context_manager/__init__.py | 1 + patterns/modern/context_manager/naive.py | 55 +++++++++++++++++++ patterns/modern/context_manager/pythonic.py | 52 ++++++++++++++++++ patterns/modern/context_manager/real_world.py | 32 +++++++++++ .../modern/context_manager/tests/__init__.py | 0 .../tests/test_context_manager.py | 55 +++++++++++++++++++ .../modern/dependency_injection/README.md | 43 +++++++++++++++ .../modern/dependency_injection/__init__.py | 1 + patterns/modern/dependency_injection/naive.py | 31 +++++++++++ .../modern/dependency_injection/pythonic.py | 45 +++++++++++++++ .../modern/dependency_injection/real_world.py | 32 +++++++++++ .../dependency_injection/tests/__init__.py | 0 .../tests/test_dependency_injection.py | 35 ++++++++++++ patterns/modern/registry/README.md | 41 ++++++++++++++ patterns/modern/registry/__init__.py | 1 + patterns/modern/registry/naive.py | 26 +++++++++ patterns/modern/registry/pythonic.py | 55 +++++++++++++++++++ patterns/modern/registry/real_world.py | 28 ++++++++++ patterns/modern/registry/tests/__init__.py | 0 .../modern/registry/tests/test_registry.py | 42 ++++++++++++++ patterns/modern/repository/README.md | 43 +++++++++++++++ patterns/modern/repository/__init__.py | 1 + patterns/modern/repository/naive.py | 23 ++++++++ patterns/modern/repository/pythonic.py | 54 ++++++++++++++++++ patterns/modern/repository/real_world.py | 36 ++++++++++++ patterns/modern/repository/tests/__init__.py | 0 .../repository/tests/test_repository.py | 41 ++++++++++++++ pyproject.toml | 2 + tests/test_catalog.py | 4 +- 38 files changed, 1032 insertions(+), 2 deletions(-) create mode 100644 patterns/modern/__init__.py create mode 100644 patterns/modern/async_producer_consumer/README.md create mode 100644 patterns/modern/async_producer_consumer/__init__.py create mode 100644 patterns/modern/async_producer_consumer/naive.py create mode 100644 patterns/modern/async_producer_consumer/pythonic.py create mode 100644 patterns/modern/async_producer_consumer/real_world.py create mode 100644 patterns/modern/async_producer_consumer/tests/__init__.py create mode 100644 patterns/modern/async_producer_consumer/tests/test_async_producer_consumer.py create mode 100644 patterns/modern/context_manager/README.md create mode 100644 patterns/modern/context_manager/__init__.py create mode 100644 patterns/modern/context_manager/naive.py create mode 100644 patterns/modern/context_manager/pythonic.py create mode 100644 patterns/modern/context_manager/real_world.py create mode 100644 patterns/modern/context_manager/tests/__init__.py create mode 100644 patterns/modern/context_manager/tests/test_context_manager.py create mode 100644 patterns/modern/dependency_injection/README.md create mode 100644 patterns/modern/dependency_injection/__init__.py create mode 100644 patterns/modern/dependency_injection/naive.py create mode 100644 patterns/modern/dependency_injection/pythonic.py create mode 100644 patterns/modern/dependency_injection/real_world.py create mode 100644 patterns/modern/dependency_injection/tests/__init__.py create mode 100644 patterns/modern/dependency_injection/tests/test_dependency_injection.py create mode 100644 patterns/modern/registry/README.md create mode 100644 patterns/modern/registry/__init__.py create mode 100644 patterns/modern/registry/naive.py create mode 100644 patterns/modern/registry/pythonic.py create mode 100644 patterns/modern/registry/real_world.py create mode 100644 patterns/modern/registry/tests/__init__.py create mode 100644 patterns/modern/registry/tests/test_registry.py create mode 100644 patterns/modern/repository/README.md create mode 100644 patterns/modern/repository/__init__.py create mode 100644 patterns/modern/repository/naive.py create mode 100644 patterns/modern/repository/pythonic.py create mode 100644 patterns/modern/repository/real_world.py create mode 100644 patterns/modern/repository/tests/__init__.py create mode 100644 patterns/modern/repository/tests/test_repository.py diff --git a/patterns/modern/__init__.py b/patterns/modern/__init__.py new file mode 100644 index 0000000..e52f98d --- /dev/null +++ b/patterns/modern/__init__.py @@ -0,0 +1 @@ +"""Modern Python patterns beyond the Gang of Four.""" diff --git a/patterns/modern/async_producer_consumer/README.md b/patterns/modern/async_producer_consumer/README.md new file mode 100644 index 0000000..365aa39 --- /dev/null +++ b/patterns/modern/async_producer_consumer/README.md @@ -0,0 +1,44 @@ +--- +id: modern/async_producer_consumer +name: Async Producer/Consumer +aliases: [asyncio-queue, worker-pool, pipeline] +guide_url: null +problem: "Decouple work generation from work processing under asyncio, with bounded memory and clean shutdown." +symptoms: ["fan out downloads to workers", "bounded queue backpressure", "asyncio pipeline", "graceful worker shutdown"] +verdict: use-with-care +caveats: + - "Choose one shutdown discipline and test it: sentinels per worker, or queue.join() plus task cancellation." + - "An unbounded queue turns a slow consumer into a memory leak — set maxsize and let backpressure work." +stdlib_sightings: [asyncio.Queue, asyncio.TaskGroup, queue.Queue] +--- + +# Async Producer/Consumer + +## Problem + +Producers generate work faster (or slower) than consumers process it. You +want N workers pulling from a shared source, bounded memory in between, and +a shutdown that neither drops items nor hangs. + +## Naive solution + +`naive.py` is the thread version: `threading.Thread` workers around a +`queue.Queue` with sentinels — fine, but each worker burns an OS thread and +coordination is manual. + +## Pythonic solution + +`asyncio.Queue` with `TaskGroup`-managed workers: `maxsize` gives +backpressure, `queue.join()` waits for completion, cancellation ends the +idle workers. All the coordination is in the queue. + +## In the wild + +This *is* the stdlib idiom — the asyncio docs' own queue example is this +pattern; `real_world.py` shapes it as a rate-limited fetch pipeline with +per-item results collected in completion order. + +## Verdict + +**Use with care.** The right tool for I/O-bound fan-out; get the shutdown +discipline right (and tested) or debug it forever. diff --git a/patterns/modern/async_producer_consumer/__init__.py b/patterns/modern/async_producer_consumer/__init__.py new file mode 100644 index 0000000..85e6399 --- /dev/null +++ b/patterns/modern/async_producer_consumer/__init__.py @@ -0,0 +1 @@ +"""Async Producer/Consumer: bounded queues between async workers.""" diff --git a/patterns/modern/async_producer_consumer/naive.py b/patterns/modern/async_producer_consumer/naive.py new file mode 100644 index 0000000..0b7b138 --- /dev/null +++ b/patterns/modern/async_producer_consumer/naive.py @@ -0,0 +1,39 @@ +"""The thread version: queue.Queue, sentinel-per-worker shutdown. + +Works, but every worker is an OS thread and the coordination is manual. +""" + +from __future__ import annotations + +import queue +import threading + + +def process_all(items: list[str], worker_count: int = 2) -> list[str]: + channel: queue.Queue[str | None] = queue.Queue() + results: list[str] = [] + lock = threading.Lock() + + def worker() -> None: + while (item := channel.get()) is not None: + with lock: + results.append(item.upper()) + + workers = [threading.Thread(target=worker) for _ in range(worker_count)] + for w in workers: + w.start() + for item in items: + channel.put(item) + for _ in workers: + channel.put(None) # one sentinel per worker + for w in workers: + w.join() + return sorted(results) + + +def main() -> None: + print(process_all(["a", "b", "c", "d"])) + + +if __name__ == "__main__": + main() diff --git a/patterns/modern/async_producer_consumer/pythonic.py b/patterns/modern/async_producer_consumer/pythonic.py new file mode 100644 index 0000000..fe5e6cb --- /dev/null +++ b/patterns/modern/async_producer_consumer/pythonic.py @@ -0,0 +1,41 @@ +"""asyncio.Queue + TaskGroup workers. + +maxsize bounds memory (backpressure), join() waits for all items to be +processed, cancellation ends the idle workers. +""" + +from __future__ import annotations + +import asyncio + + +async def process_all(items: list[str], worker_count: int = 3) -> list[str]: + channel: asyncio.Queue[str] = asyncio.Queue(maxsize=2) # backpressure + results: list[str] = [] + + async def worker() -> None: + while True: + item = await channel.get() + try: + await asyncio.sleep(0) # stand-in for real async I/O + results.append(item.upper()) + finally: + channel.task_done() + + async with asyncio.TaskGroup() as group: + workers = [group.create_task(worker()) for _ in range(worker_count)] + for item in items: + await channel.put(item) # blocks when the queue is full + await channel.join() # all items fetched AND task_done() + for w in workers: + w.cancel() # idle workers end; TaskGroup absorbs the cancellation + + return sorted(results) + + +def main() -> None: + print(asyncio.run(process_all(["a", "b", "c", "d", "e"]))) + + +if __name__ == "__main__": + main() diff --git a/patterns/modern/async_producer_consumer/real_world.py b/patterns/modern/async_producer_consumer/real_world.py new file mode 100644 index 0000000..d52c3cf --- /dev/null +++ b/patterns/modern/async_producer_consumer/real_world.py @@ -0,0 +1,49 @@ +"""The idiom shaped as a pipeline: N workers, bounded queue, ordered results. + +A fake fetcher stands in for HTTP so the demo and tests run offline; swap it +for a real client and nothing else changes. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable + +Fetcher = Callable[[str], Awaitable[str]] + + +async def fake_fetch(url: str) -> str: + await asyncio.sleep(0) + return f"body-of-{url}" + + +async def crawl(urls: list[str], fetch: Fetcher = fake_fetch, workers: int = 4) -> dict[str, str]: + """Fan URLs out to workers; collect {url: body} whatever the finish order.""" + channel: asyncio.Queue[str] = asyncio.Queue(maxsize=8) + pages: dict[str, str] = {} + + async def worker() -> None: + while True: + url = await channel.get() + try: + pages[url] = await fetch(url) + finally: + channel.task_done() + + async with asyncio.TaskGroup() as group: + tasks = [group.create_task(worker()) for _ in range(workers)] + for url in urls: + await channel.put(url) + await channel.join() + for t in tasks: + t.cancel() + return pages + + +def main() -> None: + urls = [f"https://example.com/{n}" for n in range(3)] + print(asyncio.run(crawl(urls))) + + +if __name__ == "__main__": + main() diff --git a/patterns/modern/async_producer_consumer/tests/__init__.py b/patterns/modern/async_producer_consumer/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/patterns/modern/async_producer_consumer/tests/test_async_producer_consumer.py b/patterns/modern/async_producer_consumer/tests/test_async_producer_consumer.py new file mode 100644 index 0000000..feffcf6 --- /dev/null +++ b/patterns/modern/async_producer_consumer/tests/test_async_producer_consumer.py @@ -0,0 +1,36 @@ +"""Behavioral tests for all three producer/consumer variants.""" + +from patterns.modern.async_producer_consumer import naive, pythonic, real_world + + +class TestNaive: + def test_thread_pool_processes_everything(self) -> None: + assert naive.process_all(["a", "b", "c", "d"]) == ["A", "B", "C", "D"] + + def test_zero_items(self) -> None: + assert naive.process_all([]) == [] + + +class TestPythonic: + async def test_all_items_processed_despite_backpressure(self) -> None: + items = [chr(ord("a") + n) for n in range(10)] # more items than maxsize + assert await pythonic.process_all(items) == [c.upper() for c in items] + + async def test_more_workers_than_items(self) -> None: + assert await pythonic.process_all(["x"], worker_count=5) == ["X"] + + async def test_zero_items_shuts_down_cleanly(self) -> None: + assert await pythonic.process_all([]) == [] + + +class TestRealWorld: + async def test_crawl_collects_every_url(self) -> None: + urls = [f"u{n}" for n in range(9)] + pages = await real_world.crawl(urls, workers=3) + assert pages == {u: f"body-of-{u}" for u in urls} + + async def test_injected_fetcher(self) -> None: + async def fetch(url: str) -> str: + return url[::-1] + + assert await real_world.crawl(["abc"], fetch=fetch) == {"abc": "cba"} diff --git a/patterns/modern/context_manager/README.md b/patterns/modern/context_manager/README.md new file mode 100644 index 0000000..62e3b91 --- /dev/null +++ b/patterns/modern/context_manager/README.md @@ -0,0 +1,44 @@ +--- +id: modern/context_manager +name: Context Manager +aliases: [with-statement, RAII, resource-management] +guide_url: null +problem: "Guarantee acquire/release pairing around a block of code, even when it raises." +symptoms: ["forgot to close", "cleanup on exception", "try/finally everywhere", "temporary state that must be restored"] +verdict: pythonic +caveats: + - "@contextlib.contextmanager wants the yield inside try/finally — without it, an exception in the body skips your cleanup." + - "Returning True from __exit__ swallows the exception; do it only on purpose." +stdlib_sightings: [open, contextlib.contextmanager, contextlib.ExitStack, tempfile.TemporaryDirectory] +--- + +# Context Manager + +## Problem + +Every acquired resource — file, lock, connection, temporary state — must be +released on *every* exit path. Hand-written `try/finally` scattered through a +codebase is where cleanup bugs live. + +## Naive solution + +`naive.py` is the try/finally discipline done by hand, including the nested +two-resource version that shows why it doesn't scale. + +## Pythonic solution + +The `with` statement makes the pairing structural: `pythonic.py` implements +the protocol both ways — a class with `__enter__`/`__exit__`, and the +generator form via `@contextmanager` where the `yield` splits acquire from +release. + +## In the wild + +`open`, locks, and sqlite transactions are all context managers; +`contextlib.ExitStack` manages a *dynamic* number of them, unwinding in +reverse on the way out — shown in `real_world.py`. + +## Verdict + +**Pythonic.** Python's own RAII; any acquire/release pair you write twice +deserves one. diff --git a/patterns/modern/context_manager/__init__.py b/patterns/modern/context_manager/__init__.py new file mode 100644 index 0000000..85b6773 --- /dev/null +++ b/patterns/modern/context_manager/__init__.py @@ -0,0 +1 @@ +"""Context Manager: structural acquire/release pairing.""" diff --git a/patterns/modern/context_manager/naive.py b/patterns/modern/context_manager/naive.py new file mode 100644 index 0000000..dbebb88 --- /dev/null +++ b/patterns/modern/context_manager/naive.py @@ -0,0 +1,55 @@ +"""Cleanup by hand: try/finally on every exit path. + +Correct -- and it must be re-written correctly at every call site. +The nested version shows why the discipline doesn't scale. +""" + +from __future__ import annotations + + +class Resource: + def __init__(self, name: str, log: list[str]) -> None: + self.name = name + self.log = log + self.log.append(f"open {name}") + + def close(self) -> None: + self.log.append(f"close {self.name}") + + +def use_one(log: list[str], *, explode: bool = False) -> None: + resource = Resource("a", log) + try: + log.append("work") + if explode: + raise RuntimeError("boom") + finally: + resource.close() + + +def use_two(log: list[str]) -> None: + first = Resource("a", log) + try: + second = Resource("b", log) # every extra resource nests another level + try: + log.append("work") + finally: + second.close() + finally: + first.close() + + +def main() -> None: + import contextlib + + log: list[str] = [] + with contextlib.suppress(RuntimeError): # itself a context manager! + use_one(log, explode=True) + print(f"cleanup survived the exception: {log}") + log.clear() + use_two(log) + print(f"nested by hand: {log}") + + +if __name__ == "__main__": + main() diff --git a/patterns/modern/context_manager/pythonic.py b/patterns/modern/context_manager/pythonic.py new file mode 100644 index 0000000..516c048 --- /dev/null +++ b/patterns/modern/context_manager/pythonic.py @@ -0,0 +1,52 @@ +"""The protocol, both ways. + +A class with __enter__/__exit__, and the generator form where the yield is +the seam between acquire and release. Note the try/finally around the yield: +without it, an exception in the body skips cleanup. +""" + +from __future__ import annotations + +from collections.abc import Iterator +from contextlib import contextmanager +from types import TracebackType + + +class Managed: + def __init__(self, name: str, log: list[str]) -> None: + self.name = name + self.log = log + + def __enter__(self) -> Managed: + self.log.append(f"open {self.name}") + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: + self.log.append(f"close {self.name}") # returning None: never swallow + + +@contextmanager +def managed(name: str, log: list[str]) -> Iterator[str]: + log.append(f"open {name}") + try: + yield name + finally: + log.append(f"close {name}") + + +def main() -> None: + log: list[str] = [] + with Managed("a", log): + log.append("work") + with managed("b", log): + log.append("more work") + print(log) + + +if __name__ == "__main__": + main() diff --git a/patterns/modern/context_manager/real_world.py b/patterns/modern/context_manager/real_world.py new file mode 100644 index 0000000..0b88142 --- /dev/null +++ b/patterns/modern/context_manager/real_world.py @@ -0,0 +1,32 @@ +"""``contextlib.ExitStack``: a dynamic pile of context managers. + +Open N resources decided at runtime; the stack unwinds them all, in +reverse, on any exit. +""" + +from __future__ import annotations + +import tempfile +from contextlib import ExitStack +from pathlib import Path + + +def concatenate(paths: list[Path]) -> str: + """Open however many files there are; every handle closes on exit.""" + with ExitStack() as stack: + handles = [stack.enter_context(p.open()) for p in paths] + return "".join(h.read() for h in handles) + + +def main() -> None: + with tempfile.TemporaryDirectory() as tmp: # itself a context manager + paths = [] + for i, text in enumerate(["one ", "two ", "three"]): + path = Path(tmp) / f"{i}.txt" + path.write_text(text) + paths.append(path) + print(concatenate(paths)) + + +if __name__ == "__main__": + main() diff --git a/patterns/modern/context_manager/tests/__init__.py b/patterns/modern/context_manager/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/patterns/modern/context_manager/tests/test_context_manager.py b/patterns/modern/context_manager/tests/test_context_manager.py new file mode 100644 index 0000000..d122e90 --- /dev/null +++ b/patterns/modern/context_manager/tests/test_context_manager.py @@ -0,0 +1,55 @@ +"""Behavioral tests for all three context-manager variants.""" + +import tempfile +from pathlib import Path + +import pytest + +from patterns.modern.context_manager import naive, pythonic, real_world + + +class TestNaive: + def test_finally_cleans_up_on_exception(self) -> None: + log: list[str] = [] + with pytest.raises(RuntimeError): + naive.use_one(log, explode=True) + assert log == ["open a", "work", "close a"] + + def test_nested_resources_close_in_reverse(self) -> None: + log: list[str] = [] + naive.use_two(log) + assert log == ["open a", "open b", "work", "close b", "close a"] + + +class TestPythonic: + def test_class_form_pairs_enter_and_exit(self) -> None: + log: list[str] = [] + with pythonic.Managed("a", log): + log.append("work") + assert log == ["open a", "work", "close a"] + + def test_class_form_cleans_up_on_exception(self) -> None: + log: list[str] = [] + with pytest.raises(ValueError, match="boom"), pythonic.Managed("a", log): + raise ValueError("boom") + assert log == ["open a", "close a"] + + def test_generator_form_cleans_up_on_exception(self) -> None: + log: list[str] = [] + with pytest.raises(ValueError), pythonic.managed("g", log): + raise ValueError + assert log == ["open g", "close g"] + + +class TestRealWorld: + def test_exit_stack_handles_a_runtime_number_of_files(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + paths = [] + for i, text in enumerate(["x", "y"]): + p = Path(tmp) / f"{i}.txt" + p.write_text(text) + paths.append(p) + assert real_world.concatenate(paths) == "xy" + + def test_empty_stack_is_fine(self) -> None: + assert real_world.concatenate([]) == "" diff --git a/patterns/modern/dependency_injection/README.md b/patterns/modern/dependency_injection/README.md new file mode 100644 index 0000000..9bcbb05 --- /dev/null +++ b/patterns/modern/dependency_injection/README.md @@ -0,0 +1,43 @@ +--- +id: modern/dependency_injection +name: Dependency Injection +aliases: [DI, constructor-injection, inversion-of-control] +guide_url: null +problem: "Hand an object its collaborators instead of letting it construct them, so they can be swapped — above all in tests." +symptoms: ["can't test without the real database", "class news up its own client", "mock the clock", "swap implementation per environment"] +verdict: pythonic +caveats: + - "In Python DI needs no framework: a keyword argument with a production default is the entire mechanism." + - "Inject at the boundary that varies (clock, storage, transport) — injecting everything turns constructors into wiring diagrams." +stdlib_sightings: [json.dumps cls=, sorted key=, unittest.mock] +--- + +# Dependency Injection + +## Problem + +A class that builds its own collaborators — its clock, its store, its HTTP +client — can only ever be tested with the real things. The hidden `new` is +the coupling. + +## Naive solution + +`naive.py` hard-wires `datetime.now` and a concrete store inside the class. +Watch the test problem appear: the greeting depends on the actual wall +clock. + +## Pythonic solution + +Pass the collaborators in. A `Protocol` types the seam, a keyword argument +carries the production default, and a test hands in a fake. No container, no +framework, no decorators. + +## In the wild + +Every `key=` argument is DI (`sorted`, `min`, `max`); `json.dumps(cls=...)` +injects the encoder; `unittest.mock` exists to be injected. The stdlib does +DI by keyword argument, and so should you. + +## Verdict + +**Pythonic.** The default-argument seam is the pattern, entire. diff --git a/patterns/modern/dependency_injection/__init__.py b/patterns/modern/dependency_injection/__init__.py new file mode 100644 index 0000000..d6ac82a --- /dev/null +++ b/patterns/modern/dependency_injection/__init__.py @@ -0,0 +1 @@ +"""Dependency Injection: pass collaborators in; a kwarg default is the mechanism.""" diff --git a/patterns/modern/dependency_injection/naive.py b/patterns/modern/dependency_injection/naive.py new file mode 100644 index 0000000..d4db417 --- /dev/null +++ b/patterns/modern/dependency_injection/naive.py @@ -0,0 +1,31 @@ +"""Hard-wired dependencies: the class news up its own collaborators. + +The cost is invisible until you try to test it -- there is no seam to +substitute the clock or the store. +""" + +from __future__ import annotations + +from datetime import datetime + + +class GreetingService: + def __init__(self) -> None: + self.sent: list[str] = [] # the "store", welded in + + def greet(self, name: str) -> str: + hour = datetime.now().hour # the clock, welded in + prefix = "good morning" if hour < 12 else "good day" + message = f"{prefix}, {name}" + self.sent.append(message) + return message + + +def main() -> None: + service = GreetingService() + print(service.greet("ada")) + print(f"stored: {service.sent}") + + +if __name__ == "__main__": + main() diff --git a/patterns/modern/dependency_injection/pythonic.py b/patterns/modern/dependency_injection/pythonic.py new file mode 100644 index 0000000..065edcf --- /dev/null +++ b/patterns/modern/dependency_injection/pythonic.py @@ -0,0 +1,45 @@ +"""Constructor injection with Protocol seams and production defaults. + +The test hands in a frozen clock and a fake store; production changes +nothing and passes nothing. +""" + +from __future__ import annotations + +from collections.abc import Callable +from datetime import datetime +from typing import Protocol + + +class Store(Protocol): + def append(self, message: str) -> None: ... + + +def wall_clock_hour() -> int: + return datetime.now().hour + + +class GreetingService: + def __init__( + self, + store: Store | None = None, + hour_now: Callable[[], int] = wall_clock_hour, + ) -> None: + self.store: Store = store if store is not None else [] + self.hour_now = hour_now + + def greet(self, name: str) -> str: + prefix = "good morning" if self.hour_now() < 12 else "good day" + message = f"{prefix}, {name}" + self.store.append(message) + return message + + +def main() -> None: + print(GreetingService().greet("ada")) # production wiring: defaults + frozen = GreetingService(hour_now=lambda: 9) # test wiring: injected + print(frozen.greet("grace")) + + +if __name__ == "__main__": + main() diff --git a/patterns/modern/dependency_injection/real_world.py b/patterns/modern/dependency_injection/real_world.py new file mode 100644 index 0000000..9d44e84 --- /dev/null +++ b/patterns/modern/dependency_injection/real_world.py @@ -0,0 +1,32 @@ +"""The stdlib does DI by keyword argument. + +``sorted(key=...)`` injects the ordering; ``json.dumps(cls=...)`` injects +the encoder. Same seam, same benefit. +""" + +from __future__ import annotations + +import json +from typing import Any + + +class UpperEncoder(json.JSONEncoder): + def encode(self, o: Any) -> str: + return super().encode(o).upper() + + +def sort_by_injected_policy(words: list[str]) -> list[str]: + return sorted(words, key=str.casefold) + + +def dump_with_injected_encoder(data: dict[str, str]) -> str: + return json.dumps(data, cls=UpperEncoder) + + +def main() -> None: + print(sort_by_injected_policy(["b", "A", "c"])) + print(dump_with_injected_encoder({"k": "v"})) + + +if __name__ == "__main__": + main() diff --git a/patterns/modern/dependency_injection/tests/__init__.py b/patterns/modern/dependency_injection/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/patterns/modern/dependency_injection/tests/test_dependency_injection.py b/patterns/modern/dependency_injection/tests/test_dependency_injection.py new file mode 100644 index 0000000..453f818 --- /dev/null +++ b/patterns/modern/dependency_injection/tests/test_dependency_injection.py @@ -0,0 +1,35 @@ +"""Behavioral tests for all three dependency-injection variants.""" + +from patterns.modern.dependency_injection import naive, pythonic, real_world + + +class TestNaive: + def test_works_but_depends_on_the_real_clock(self) -> None: + message = naive.GreetingService().greet("ada") + # The strongest assertion possible without controlling the clock: + assert message.endswith(", ada") + assert message.startswith(("good morning", "good day")) + + +class TestPythonic: + def test_injected_clock_makes_behavior_deterministic(self) -> None: + morning = pythonic.GreetingService(hour_now=lambda: 9) + evening = pythonic.GreetingService(hour_now=lambda: 20) + assert morning.greet("ada") == "good morning, ada" + assert evening.greet("ada") == "good day, ada" + + def test_injected_store_observes_writes(self) -> None: + store: list[str] = [] + pythonic.GreetingService(store=store, hour_now=lambda: 9).greet("ada") + assert store == ["good morning, ada"] + + def test_production_defaults_still_work(self) -> None: + assert pythonic.GreetingService().greet("ada").endswith(", ada") + + +class TestRealWorld: + def test_injected_sort_policy(self) -> None: + assert real_world.sort_by_injected_policy(["b", "A", "c"]) == ["A", "b", "c"] + + def test_injected_encoder(self) -> None: + assert real_world.dump_with_injected_encoder({"k": "v"}) == '{"K": "V"}' diff --git a/patterns/modern/registry/README.md b/patterns/modern/registry/README.md new file mode 100644 index 0000000..9879f37 --- /dev/null +++ b/patterns/modern/registry/README.md @@ -0,0 +1,41 @@ +--- +id: modern/registry +name: Registry +aliases: [plugin-registry, dispatch-table] +guide_url: null +problem: "Let implementations announce themselves by name, so dispatch is a lookup instead of an if/elif ladder." +symptoms: ["if/elif on a type string", "plugin system", "handlers by name", "adding a case means editing the dispatcher"] +verdict: pythonic +caveats: + - "Registration at import time means the module defining a plugin must actually get imported — a plugin nobody imports doesn't exist." + - "Decide the unknown-key policy (KeyError? default handler?) once, in the lookup, not at each call site." +stdlib_sightings: [codecs.register, functools.singledispatch, atexit.register] +--- + +# Registry + +## Problem + +An exporter supports "csv", "json", "xml"… and every new format edits the +same `if/elif` ladder. The dispatcher has become a bottleneck every plugin +must patch. + +## Naive solution + +`naive.py` is that ladder: closed for extension, growing forever. + +## Pythonic solution + +A dict from name to callable, filled by a `@register("csv")` decorator — +defining a handler *is* registering it. Dispatch is a lookup; the unknown-key +policy lives in exactly one place. + +## In the wild + +`codecs.register` is a full plugin registry (every `.encode("rot13")` is a +lookup); `functools.singledispatch` is a registry keyed by type; +`atexit.register` collects callables to run at shutdown. + +## Verdict + +**Pythonic.** The standard cure for if/elif dispatch. diff --git a/patterns/modern/registry/__init__.py b/patterns/modern/registry/__init__.py new file mode 100644 index 0000000..c1480b6 --- /dev/null +++ b/patterns/modern/registry/__init__.py @@ -0,0 +1 @@ +"""Registry: implementations announce themselves; dispatch is a lookup.""" diff --git a/patterns/modern/registry/naive.py b/patterns/modern/registry/naive.py new file mode 100644 index 0000000..7095db2 --- /dev/null +++ b/patterns/modern/registry/naive.py @@ -0,0 +1,26 @@ +"""Dispatch as an if/elif ladder: every new format edits this function.""" + +from __future__ import annotations + + +def export(rows: list[dict[str, str]], fmt: str) -> str: + if fmt == "csv": + if not rows: + return "" + header = ",".join(rows[0]) + body = "\n".join(",".join(row.values()) for row in rows) + return f"{header}\n{body}" + elif fmt == "keyvalue": + return "\n".join(f"{k}={v}" for row in rows for k, v in row.items()) + else: + raise ValueError(f"unknown format: {fmt}") + + +def main() -> None: + rows = [{"name": "ada", "role": "eng"}] + print(export(rows, "csv")) + print(export(rows, "keyvalue")) + + +if __name__ == "__main__": + main() diff --git a/patterns/modern/registry/pythonic.py b/patterns/modern/registry/pythonic.py new file mode 100644 index 0000000..9021658 --- /dev/null +++ b/patterns/modern/registry/pythonic.py @@ -0,0 +1,55 @@ +"""The decorator-filled registry: defining a handler registers it. + +New formats are new functions -- possibly in other modules -- and the +dispatcher never changes again. +""" + +from __future__ import annotations + +from collections.abc import Callable + +Exporter = Callable[[list[dict[str, str]]], str] + +EXPORTERS: dict[str, Exporter] = {} + + +def register(name: str) -> Callable[[Exporter], Exporter]: + def decorator(func: Exporter) -> Exporter: + EXPORTERS[name] = func + return func + + return decorator + + +@register("csv") +def to_csv(rows: list[dict[str, str]]) -> str: + if not rows: + return "" + header = ",".join(rows[0]) + body = "\n".join(",".join(row.values()) for row in rows) + return f"{header}\n{body}" + + +@register("keyvalue") +def to_keyvalue(rows: list[dict[str, str]]) -> str: + return "\n".join(f"{k}={v}" for row in rows for k, v in row.items()) + + +def export(rows: list[dict[str, str]], fmt: str) -> str: + """Dispatch is a lookup; the unknown-key policy lives here, once.""" + try: + exporter = EXPORTERS[fmt] + except KeyError: + known = ", ".join(sorted(EXPORTERS)) + raise ValueError(f"unknown format {fmt!r} (known: {known})") from None + return exporter(rows) + + +def main() -> None: + rows = [{"name": "ada", "role": "eng"}] + print(export(rows, "csv")) + print(export(rows, "keyvalue")) + + +if __name__ == "__main__": + main() diff --git a/patterns/modern/registry/real_world.py b/patterns/modern/registry/real_world.py new file mode 100644 index 0000000..705f901 --- /dev/null +++ b/patterns/modern/registry/real_world.py @@ -0,0 +1,28 @@ +"""``codecs``: the stdlib's plugin registry in daily use. + +Every str.encode(name) is a registry lookup; codecs.register() adds a +search function that can serve entirely new names. +""" + +from __future__ import annotations + +import codecs + + +def rot13(text: str) -> str: + """'rot13' resolves through the codec registry.""" + return codecs.encode(text, "rot13") + + +def lookup_is_the_registry(name: str) -> str: + """Ask the registry directly for a codec entry.""" + return codecs.lookup(name).name + + +def main() -> None: + print(rot13("gura fur fnvq")) + print(f"'UTF8' resolves to: {lookup_is_the_registry('UTF8')}") + + +if __name__ == "__main__": + main() diff --git a/patterns/modern/registry/tests/__init__.py b/patterns/modern/registry/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/patterns/modern/registry/tests/test_registry.py b/patterns/modern/registry/tests/test_registry.py new file mode 100644 index 0000000..18fa282 --- /dev/null +++ b/patterns/modern/registry/tests/test_registry.py @@ -0,0 +1,42 @@ +"""Behavioral tests for all three registry variants.""" + +import pytest + +from patterns.modern.registry import naive, pythonic, real_world + +ROWS = [{"name": "ada", "role": "eng"}] + + +class TestNaive: + def test_ladder_dispatch_works(self) -> None: + assert naive.export(ROWS, "csv") == "name,role\nada,eng" + + def test_unknown_format(self) -> None: + with pytest.raises(ValueError, match="unknown format"): + naive.export(ROWS, "yaml") + + +class TestPythonic: + def test_registered_handlers_dispatch_by_name(self) -> None: + assert pythonic.export(ROWS, "csv") == "name,role\nada,eng" + assert pythonic.export(ROWS, "keyvalue") == "name=ada\nrole=eng" + + def test_new_handler_registers_without_touching_the_dispatcher(self) -> None: + @pythonic.register("upper") + def to_upper(rows: list[dict[str, str]]) -> str: + return " ".join(v.upper() for row in rows for v in row.values()) + + try: + assert pythonic.export(ROWS, "upper") == "ADA ENG" + finally: + del pythonic.EXPORTERS["upper"] + + def test_unknown_format_names_the_known_ones(self) -> None: + with pytest.raises(ValueError, match="known: csv, keyvalue"): + pythonic.export(ROWS, "yaml") + + +class TestRealWorld: + def test_codec_registry_resolves_names(self) -> None: + assert real_world.rot13("gura fur fnvq") == "then she said" + assert real_world.lookup_is_the_registry("UTF8") == "utf-8" diff --git a/patterns/modern/repository/README.md b/patterns/modern/repository/README.md new file mode 100644 index 0000000..f4caf2f --- /dev/null +++ b/patterns/modern/repository/README.md @@ -0,0 +1,43 @@ +--- +id: modern/repository +name: Repository +aliases: [data-access-layer, persistence-port] +guide_url: null +problem: "Keep domain logic ignorant of how objects are stored, behind a collection-like interface." +symptoms: ["SQL scattered through business logic", "tests need a database", "swap sqlite for postgres", "collection-like storage API"] +verdict: use-with-care +caveats: + - "The payoff is the in-memory fake: if your tests still hit a database, the repository isn't earning its keep." + - "Don't build a generic Repository[T] for one entity — write the three methods you need and stop." +stdlib_sightings: [sqlite3, shelve] +--- + +# Repository + +## Problem + +Pricing rules shouldn't know SQL. When persistence details soak into domain +logic, every business test drags a database behind it and every storage +change touches everything. + +## Naive solution + +`naive.py` inlines sqlite calls in the domain function — compact, and +welded shut. + +## Pythonic solution + +A `Protocol` names the collection-like operations the domain needs (`add`, +`get`, `list`); an in-memory dict repo serves tests, a sqlite repo serves +production, and the domain function accepts either. + +## In the wild + +`shelve` is a ready-made key-object repository over `dbm`; `sqlite3` with a +thin class over it is the standard hand-rolled form (shown in +`real_world.py`). + +## Verdict + +**Use with care.** Earn it with a real second implementation (the in-memory +fake counts); skip it for scripts that just need a query. diff --git a/patterns/modern/repository/__init__.py b/patterns/modern/repository/__init__.py new file mode 100644 index 0000000..7f92ffb --- /dev/null +++ b/patterns/modern/repository/__init__.py @@ -0,0 +1 @@ +"""Repository: collection-like storage seam for domain logic.""" diff --git a/patterns/modern/repository/naive.py b/patterns/modern/repository/naive.py new file mode 100644 index 0000000..f01d053 --- /dev/null +++ b/patterns/modern/repository/naive.py @@ -0,0 +1,23 @@ +"""Persistence soaked into domain logic: SQL inline, everywhere.""" + +from __future__ import annotations + +import sqlite3 + + +def total_owed(conn: sqlite3.Connection, customer: str) -> int: + """Domain question, welded to storage details.""" + conn.execute("CREATE TABLE IF NOT EXISTS invoices (customer TEXT, amount INT)") + rows = conn.execute("SELECT amount FROM invoices WHERE customer = ?", (customer,)).fetchall() + return sum(amount for (amount,) in rows) + + +def main() -> None: + conn = sqlite3.connect(":memory:") + conn.execute("CREATE TABLE invoices (customer TEXT, amount INT)") + conn.executemany("INSERT INTO invoices VALUES (?, ?)", [("ada", 100), ("ada", 50)]) + print(f"ada owes {total_owed(conn, 'ada')}") + + +if __name__ == "__main__": + main() diff --git a/patterns/modern/repository/pythonic.py b/patterns/modern/repository/pythonic.py new file mode 100644 index 0000000..708117e --- /dev/null +++ b/patterns/modern/repository/pythonic.py @@ -0,0 +1,54 @@ +"""The repository seam: a Protocol, a fake, and domain logic that can't tell. + +Tests use InMemoryInvoices; production wires something durable. The domain +function is identical either way. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Protocol + + +@dataclass(frozen=True) +class Invoice: + customer: str + amount: int + + +class Invoices(Protocol): + """The collection-like operations the domain actually needs.""" + + def add(self, invoice: Invoice) -> None: ... + + def for_customer(self, customer: str) -> list[Invoice]: ... + + +class InMemoryInvoices: + """The fake that makes domain tests instant.""" + + def __init__(self) -> None: + self._items: list[Invoice] = [] + + def add(self, invoice: Invoice) -> None: + self._items.append(invoice) + + def for_customer(self, customer: str) -> list[Invoice]: + return [i for i in self._items if i.customer == customer] + + +def total_owed(repo: Invoices, customer: str) -> int: + """Pure domain logic: no storage details anywhere in sight.""" + return sum(invoice.amount for invoice in repo.for_customer(customer)) + + +def main() -> None: + repo = InMemoryInvoices() + repo.add(Invoice("ada", 100)) + repo.add(Invoice("ada", 50)) + repo.add(Invoice("grace", 9)) + print(f"ada owes {total_owed(repo, 'ada')}") + + +if __name__ == "__main__": + main() diff --git a/patterns/modern/repository/real_world.py b/patterns/modern/repository/real_world.py new file mode 100644 index 0000000..d1ed1d0 --- /dev/null +++ b/patterns/modern/repository/real_world.py @@ -0,0 +1,36 @@ +"""A sqlite3-backed repository satisfying the same protocol. + +Same domain function, durable storage -- the swap the pattern promises. +""" + +from __future__ import annotations + +import sqlite3 + +from patterns.modern.repository.pythonic import Invoice, total_owed + + +class SqliteInvoices: + def __init__(self, conn: sqlite3.Connection) -> None: + self._conn = conn + self._conn.execute("CREATE TABLE IF NOT EXISTS invoices (customer TEXT, amount INT)") + + def add(self, invoice: Invoice) -> None: + self._conn.execute("INSERT INTO invoices VALUES (?, ?)", (invoice.customer, invoice.amount)) + + def for_customer(self, customer: str) -> list[Invoice]: + rows = self._conn.execute( + "SELECT customer, amount FROM invoices WHERE customer = ?", (customer,) + ).fetchall() + return [Invoice(c, a) for c, a in rows] + + +def main() -> None: + repo = SqliteInvoices(sqlite3.connect(":memory:")) + repo.add(Invoice("ada", 100)) + repo.add(Invoice("ada", 50)) + print(f"ada owes {total_owed(repo, 'ada')} (from sqlite)") + + +if __name__ == "__main__": + main() diff --git a/patterns/modern/repository/tests/__init__.py b/patterns/modern/repository/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/patterns/modern/repository/tests/test_repository.py b/patterns/modern/repository/tests/test_repository.py new file mode 100644 index 0000000..44ac74c --- /dev/null +++ b/patterns/modern/repository/tests/test_repository.py @@ -0,0 +1,41 @@ +"""Behavioral tests for all three repository variants.""" + +import sqlite3 + +from patterns.modern.repository import naive, pythonic, real_world +from patterns.modern.repository.pythonic import Invoice + + +class TestNaive: + def test_inline_sql_works_but_needs_a_database(self) -> None: + conn = sqlite3.connect(":memory:") + conn.execute("CREATE TABLE invoices (customer TEXT, amount INT)") + conn.executemany("INSERT INTO invoices VALUES (?, ?)", [("ada", 100), ("ada", 50)]) + assert naive.total_owed(conn, "ada") == 150 + + +class TestPythonic: + def test_domain_logic_runs_on_the_fake(self) -> None: + repo = pythonic.InMemoryInvoices() + repo.add(Invoice("ada", 100)) + repo.add(Invoice("grace", 9)) + assert pythonic.total_owed(repo, "ada") == 100 + + def test_unknown_customer_owes_nothing(self) -> None: + assert pythonic.total_owed(pythonic.InMemoryInvoices(), "nobody") == 0 + + +class TestRealWorld: + def test_same_domain_function_over_sqlite(self) -> None: + repo = real_world.SqliteInvoices(sqlite3.connect(":memory:")) + repo.add(Invoice("ada", 100)) + repo.add(Invoice("ada", 50)) + assert pythonic.total_owed(repo, "ada") == 150 + + def test_the_two_repos_are_interchangeable(self) -> None: + for repo in ( + pythonic.InMemoryInvoices(), + real_world.SqliteInvoices(sqlite3.connect(":memory:")), + ): + repo.add(Invoice("x", 7)) + assert pythonic.total_owed(repo, "x") == 7 diff --git a/pyproject.toml b/pyproject.toml index 4abcd42..2ab0d70 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,7 @@ dev = [ "ruff>=0.8", "mypy>=1.13", "types-pyyaml>=6.0", + "pytest-asyncio>=0.24", ] [build-system] @@ -57,6 +58,7 @@ files = ["src", "patterns"] [tool.pytest.ini_options] testpaths = ["tests", "patterns"] pythonpath = ["."] +asyncio_mode = "auto" addopts = "-q --cov=src --cov=patterns --cov-report=term-missing" [tool.coverage.report] diff --git a/tests/test_catalog.py b/tests/test_catalog.py index b8144a1..cfc3aef 100644 --- a/tests/test_catalog.py +++ b/tests/test_catalog.py @@ -16,7 +16,7 @@ class TestRealCatalog: def test_loads_all_units(self) -> None: catalog = load_catalog() - assert len(catalog.patterns) == 27 + assert len(catalog.patterns) == 32 assert "structural/decorator" in catalog.ids() def test_every_unit_ships_all_three_variants(self) -> None: @@ -38,7 +38,7 @@ def test_get_unknown_id_raises(self) -> None: def test_index_json_round_trips(self) -> None: entries = json.loads(load_catalog().to_json()) - assert len(entries) == 27 + assert len(entries) == 32 assert all({"id", "name", "problem", "verdict", "variants"} <= e.keys() for e in entries) assert not any("prose" in e or "path" in e for e in entries)