diff --git a/.claude/commands/new-pattern.md b/.claude/commands/new-pattern.md new file mode 100644 index 0000000..7fdab6f --- /dev/null +++ b/.claude/commands/new-pattern.md @@ -0,0 +1,17 @@ +--- +description: Scaffold a new pattern unit under patterns// +argument-hint: / "Pattern Name" +--- + +Scaffold a new pattern unit for $ARGUMENTS. + +1. Validate the group is one of: principle, python, creational, structural, behavioral, modern. + Refuse anything else. +2. Create `patterns///` with the exact template from CLAUDE.md: + README.md (frontmatter with `id: /`, all schema keys present, + `verdict:` left as `use-with-care` with a `TODO` caveat), empty-but-importable + `__init__.py`, and stub `naive.py`, `pythonic.py`, `real_world.py` each with a + typed `main() -> None` and script guard, plus `tests/test_.py` with one + failing `test_todo` marked `xfail(reason="unit not yet written")`. +3. Run `make check` and report the result. Do not write the actual pattern content — + scaffolding only. diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..244f773 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,14 @@ +{ + "permissions": { + "allow": [ + "Bash(make check)", + "Bash(make lint)", + "Bash(make test)", + "Bash(make typecheck)", + "Bash(uv run pytest:*)", + "Bash(uv run ruff:*)", + "Bash(uv run mypy:*)", + "Bash(uv sync:*)" + ] + } +} diff --git a/.claude/skills/pattern-authoring/SKILL.md b/.claude/skills/pattern-authoring/SKILL.md new file mode 100644 index 0000000..7eda9ca --- /dev/null +++ b/.claude/skills/pattern-authoring/SKILL.md @@ -0,0 +1,49 @@ +--- +name: pattern-authoring +description: How to write a complete pattern unit for this repo — variant roles, frontmatter, verdict rubric, and test expectations. Use when authoring or reviewing any patterns/// content. +--- + +# Authoring a pattern unit + +## The three variants have distinct jobs — don't blur them + +- **naive.py** — the Gang-of-Four/Java translation, faithfully. Class-heavy, + interface-driven, even when it looks silly in Python. It exists so a reader can + diff it against pythonic.py and *see* what Python absorbs. Keep it correct and + typed, but do not "improve" it. +- **pythonic.py** — what a fluent Python developer writes for the same problem. + If the pattern collapses into a language feature (first-class functions, modules, + dunder protocols, decorators, singledispatch), show the collapse and name it. +- **real_world.py** — a small program using the *stdlib's own* embodiment of the + pattern (e.g. Iterator → generators/`iter()`, Decorator → `functools.wraps`, + Prototype → `copy.deepcopy`, Command → `functools.partial` callbacks). Import the + stdlib machinery; don't reimplement it. + +## Choosing the verdict + +- `pythonic` — the pattern, in its pythonic form, is what you'd genuinely recommend. +- `use-with-care` — legitimate uses exist, but each caveat in the frontmatter must + name a concrete failure mode (not "be careful"). +- `prefer-alternative` — the honest answer is "don't"; `pythonic.py` must then show + the alternative, and `caveats` must name it explicitly (e.g. "You almost always + want the Global Object pattern instead"). + +When python-patterns.guide has a chapter, its verdict wins; link it in `guide_url` +and align the prose with its argument. Where it has none, reason from its principles +(composition over inheritance, callables over class hierarchies). + +## Prose in README.md (after the frontmatter) + +Sections, in order: **Problem** (2–4 sentences, concrete), **Naive solution** (what +the GoF book prescribes and why it looks that way), **Pythonic solution** (the +collapse or refinement, with the language feature named), **In the wild** (where the +stdlib/ecosystem does this), **Verdict** (one honest paragraph). ~1 page total. +No history lessons, no UML. + +## Tests + +- One test file per unit, covering all three variants. +- Assert observable behavior: outputs, state transitions, raised exceptions, + identity where the pattern is *about* identity (singleton, flyweight). +- Async units use pytest-asyncio; everything else stays synchronous. +- Never test print output by capsys unless the demo output IS the behavior. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1732dcd --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: ci + +on: + push: + branches: [main, staging] + pull_request: + +jobs: + check: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.11", "3.12", "3.13"] + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install + run: uv sync --group dev + - name: Lint + run: | + uv run ruff check . + uv run ruff format --check . + - name: Typecheck + run: uv run mypy + - name: Test + run: uv run pytest diff --git a/.gitignore b/.gitignore index ed8ebf5..5c09459 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,23 @@ -__pycache__ \ No newline at end of file +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +dist/ +build/ +.venv/ + +# Tooling caches +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +htmlcov/ +uv.lock + +# Editors / OS +.idea/ +.vscode/ +.DS_Store + +# oh-my-claudecode runtime state +.omc/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..6f5c045 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,67 @@ +# Agent instructions — python-design-patterns + +Companion catalog to [python-patterns.guide](https://python-patterns.guide/): every design +pattern as runnable, tested, typed Python — plus an MCP server (`src/design_patterns_mcp/`) +that serves the catalog to agents. + +## Layout + +- `patterns///` — one directory per pattern ("unit"). Groups: + `principle`, `python`, `creational`, `structural`, `behavioral`, `modern`. +- `src/design_patterns/` — catalog loader (frontmatter → typed `Pattern` objects). +- `src/design_patterns_mcp/` — FastMCP server (tools, resources, prompts, sandbox). +- Legacy flat dirs (`behavioral/`, `combos/`, `creational/`, `structural/`) are + pre-migration code: excluded from lint, deleted as units absorb them. Do not add to them. + +## Pattern unit template + +Every unit has exactly this shape (scaffold one with `/new-pattern`): + +``` +patterns/// +├── README.md # YAML frontmatter + prose +├── __init__.py +├── naive.py # the literal 1994/Java-style translation +├── pythonic.py # what you actually write in Python +├── real_world.py # the pattern as it appears in the stdlib +└── tests/test_.py +``` + +- Each `.py` variant is import-safe (no side effects at import) and has a + `main() -> None` demo runnable as a script (`if __name__ == "__main__": main()`). +- Tests import the variants and assert behavior — never just "it runs". +- Full type hints; `mypy --strict` must pass. + +## Frontmatter schema (the MCP server indexes this — keep it valid) + +```yaml +id: structural/decorator # must equal / +name: Decorator +aliases: [wrapper] # alternate names searchers might use +guide_url: https://python-patterns.guide/gang-of-four/decorator-pattern/ # or null +problem: "One sentence: the problem this pattern solves." +symptoms: ["logging every call", "caching results"] # phrases a user might say +verdict: pythonic # pythonic | use-with-care | prefer-alternative +caveats: ["Always use functools.wraps."] +stdlib_sightings: [functools.wraps, contextlib.contextmanager] +``` + +Verdicts: `pythonic` = use it as shown; `use-with-care` = valid but has sharp edges +(caveats say which); `prefer-alternative` = the naive form exists for study, the +pythonic file shows what to write instead (e.g. Singleton → module global, +Visitor → singledispatch). See `docs/verdicts.md`. + +## Workflow + +- Branches: `main ← staging ← feat/`. PRs target `staging`. Never push to `main`. +- Gate before any PR: `make check` (ruff lint+format, mypy --strict, pytest+cov) green. +- Commit style: `: ` (`feat`, `fix`, `chore`, `docs`, `refactor`). +- Toolchain is uv only — no pip/poetry. `make install` to set up. + +## Writing style for pattern prose + +- Lead with the problem, not the pattern name's history. +- Say plainly when Python makes the pattern unnecessary — that honesty is the + point of the repo. Cite the guide chapter when one exists. +- naive.py mirrors the GoF book faithfully, even when un-Pythonic (that's its job); + pythonic.py is idiomatic; real_world.py points at real stdlib usage. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9eb6141 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,67 @@ +# python-design-patterns + +Companion catalog to [python-patterns.guide](https://python-patterns.guide/): every design +pattern as runnable, tested, typed Python — plus an MCP server (`src/design_patterns_mcp/`) +that serves the catalog to agents. + +## Layout + +- `patterns///` — one directory per pattern ("unit"). Groups: + `principle`, `python`, `creational`, `structural`, `behavioral`, `modern`. +- `src/design_patterns/` — catalog loader (frontmatter → typed `Pattern` objects). +- `src/design_patterns_mcp/` — FastMCP server (tools, resources, prompts, sandbox). +- Legacy flat dirs (`behavioral/`, `combos/`, `creational/`, `structural/`) are + pre-migration code: excluded from lint, deleted as units absorb them. Do not add to them. + +## Pattern unit template + +Every unit has exactly this shape (scaffold one with `/new-pattern`): + +``` +patterns/// +├── README.md # YAML frontmatter + prose +├── __init__.py +├── naive.py # the literal 1994/Java-style translation +├── pythonic.py # what you actually write in Python +├── real_world.py # the pattern as it appears in the stdlib +└── tests/test_.py +``` + +- Each `.py` variant is import-safe (no side effects at import) and has a + `main() -> None` demo runnable as a script (`if __name__ == "__main__": main()`). +- Tests import the variants and assert behavior — never just "it runs". +- Full type hints; `mypy --strict` must pass. + +## Frontmatter schema (the MCP server indexes this — keep it valid) + +```yaml +id: structural/decorator # must equal / +name: Decorator +aliases: [wrapper] # alternate names searchers might use +guide_url: https://python-patterns.guide/gang-of-four/decorator-pattern/ # or null +problem: "One sentence: the problem this pattern solves." +symptoms: ["logging every call", "caching results"] # phrases a user might say +verdict: pythonic # pythonic | use-with-care | prefer-alternative +caveats: ["Always use functools.wraps."] +stdlib_sightings: [functools.wraps, contextlib.contextmanager] +``` + +Verdicts: `pythonic` = use it as shown; `use-with-care` = valid but has sharp edges +(caveats say which); `prefer-alternative` = the naive form exists for study, the +pythonic file shows what to write instead (e.g. Singleton → module global, +Visitor → singledispatch). See `docs/verdicts.md`. + +## Workflow + +- Branches: `main ← staging ← feat/`. PRs target `staging`. Never push to `main`. +- Gate before any PR: `make check` (ruff lint+format, mypy --strict, pytest+cov) green. +- Commit style: `: ` (`feat`, `fix`, `chore`, `docs`, `refactor`). +- Toolchain is uv only — no pip/poetry. `make install` to set up. + +## Writing style for pattern prose + +- Lead with the problem, not the pattern name's history. +- Say plainly when Python makes the pattern unnecessary — that honesty is the + point of the repo. Cite the guide chapter when one exists. +- naive.py mirrors the GoF book faithfully, even when un-Pythonic (that's its job); + pythonic.py is idiomatic; real_world.py points at real stdlib usage. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e0348e5 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019-2026 SuperElectron + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..26868d7 --- /dev/null +++ b/Makefile @@ -0,0 +1,23 @@ +.PHONY: install lint format typecheck test check clean + +install: ## Sync dev environment + uv sync --group dev + +lint: ## Ruff lint + format check + uv run ruff check . + uv run ruff format --check . + +format: ## Auto-fix lint and formatting + uv run ruff check --fix . + uv run ruff format . + +typecheck: ## mypy --strict + uv run mypy + +test: ## Run test suite with coverage + uv run pytest + +check: lint typecheck test ## Everything CI runs + +clean: + rm -rf .pytest_cache .mypy_cache .ruff_cache .coverage htmlcov dist build diff --git a/behavioral/command.py b/behavioral/command.py deleted file mode 100644 index 38034e1..0000000 --- a/behavioral/command.py +++ /dev/null @@ -1,108 +0,0 @@ -# command design pattern - - -class Screen(object): - """ Object that knows how to perform operation """ - def __init__(self, text=''): - self.text = text - self.clip_board = '' - - def cut(self, start=0, end=0): - self.clip_board = self.text[start:end] - self.text = self.text[:start] + self.text[end:] - - def paste(self, offset=0): - self.text = self.text[:offset] + self.clip_board + self.text[offset:] - - def clear_clipboard(self): - self.clip_board = '' - - def length(self): - return len(self.text) - - def __str__(self): - return self.text - - -class ScreenCommand: - """ Screen command interface """ - def __init__(self, screen): - self.screen = screen - self.previous_state = screen.text - - def execute(self): - pass - - def undo(self): - pass - - -class CutCommand(ScreenCommand): - """ Concrete Cut Command """ - def __init__(self, screen, start=0, end=0): - super().__init__(screen) - self.start = start - self.end = end - - def execute(self): - self.screen.cut(start=self.start, end=self.end) - - def undo(self): - self.screen.clear_clipboard() - self.screen.text = self.previous_state - - -class PasteCommand(ScreenCommand): - """ Concrete Paste Command """ - def __init__(self, screen, offset=0): - super().__init__(screen) - self.offset = offset - - def execute(self): - self.screen.paste(offset=self.offset) - - def undo(self): - self.screen.clear_clipboard() - self.screen.text = self.previous_state - - -class ScreenInvoker: - """ Object that invokes the operation """ - def __init__(self): - self.history = [] - - def store_and_execute(self, command): - command.execute() - self.history.append(command) - - def undo_last(self): - if self.history: - self.history.pop().undo() - - -def main(): - """ The goal is to separate object that invokes operation from the one that knows how to perform it """ - screen = Screen('hello world') - print(screen) - cut = CutCommand(screen, start=5, end=11) - client = ScreenInvoker() - client.store_and_execute(cut) - print(screen) - paste = PasteCommand(screen, offset=0) - client.store_and_execute(paste) - print(screen) - client.undo_last() - print(screen) - client.undo_last() - print(screen) - - -if __name__ == '__main__': - main() - """ - hello world - hello - worldhello - hello - hello world - """ diff --git a/behavioral/command_1.py b/behavioral/command_1.py deleted file mode 100644 index 5452b51..0000000 --- a/behavioral/command_1.py +++ /dev/null @@ -1,113 +0,0 @@ -# command, a behavioral design pattern -# reference: https://medium.com/@rrfd/strategy-and-command-design-patterns-wizards-and-sandwiches-applications-in-python-d1ee1c86e00f -import abc - - -class Command(metaclass=abc.ABCMeta): - """ - The command interface that declares a method (execute) for a particular - action. - """ - @abc.abstractmethod - def execute(self): - pass - - -class Sandwich: - """ - The receiver class, which holds the specifc method to be called to - perform the specific action. - This will be called by the Invoker object. - """ - - def make_sandwich(self): - print("A sandwich is being made") - - -class Salad: - """ - The receiver class, which holds the specific method to be called. - This will be called by the Invoker object. - """ - - def make_salad(self): - print("A salad is being made") - - -class Taco: - """ - The receiver class, which holds the specific method to be called. - This will be called by the Invoker object. - """ - - def make_taco(self): - print("A taco is being made") - - -class SandwichCommand(Command): - """ - A concrete / specific Command class, implementing exectue() - which calls a specific or an appropriate action of a method - from a Receiver class. - Args: - lunch (Lunch): Receiver class to be attached to the command - """ - - def __init__(self, sandwich: Sandwich): - self._sandwich = sandwich - - def execute(self): - self._sandwich.make_sandwich() - - -class SaladCommand(Command): - def __init__(self, salad: Salad): - self._salad = salad - - def execute(self): - self._salad.make_salad() - - -class TacoCommand(Command): - def __init__(self, taco: Taco): - self._taco = taco - - def execute(self): - self._taco.make_taco() - - -class MealInvoker: - """ - Has a reference to the Command, and can execute the method. - Notice how the command.execute() is never directly called, - but always through the invoker. - The action invoked is decoupled from the action performed - by the Receiver. - The Invoker (self) invokes a Command (LunchCommand), - and the Command executes the appropriate action (command.execute()) - """ - - def __init__(self, command: Command): - self._command = command - self._command_list = [] # type: List[Command] - - def set_command(self, command: Command): - self.command = command - - def get_command(self): - print(self.command.__class__.__name__) - - def add_command_to_list(self, command: Command): - self._command_list.append(command) - - def execute_commands(self): - """ - Execute all the saved commands, then empty the list. - """ - for cmd in self._command_list: - cmd.execute() - - self._command_list.clear() - - def invoke(self): - self._command.execute() \ No newline at end of file diff --git a/behavioral/iterator.py b/behavioral/iterator.py deleted file mode 100644 index e362d95..0000000 --- a/behavioral/iterator.py +++ /dev/null @@ -1,97 +0,0 @@ -""" -recommended solution to using the prototype pattern -source: https://python-patterns.guide/gang-of-four/iterator/#implementing-an-iterable-and-iterator - -*What is this pattern about? -Traverses a container and accesses the container's elements. - -*Implementing an iterable and interator - -How can a class implement the Iterator Pattern and plug in to Python’s native iteration mechanisms for, iter(), and next()? -1. make iterable: The container must offer an __iter__() method that returns an iterator object. -2. Each iterator must offer a __next__() method that returns the next item from the container each time it is called. -- It should raise StopIterator when there are no further items. -3.each iterator must have __iter__() that returns itself. -- some users pass iterators to a for loop instead of passing the underlying container. -""" - - -class OddNumbers(object): - "An iterable object." - - def __init__(self, maximum): - self.maximum = maximum - - def __iter__(self): - """ 1. make iterable""" - return OddIterator(self) - - -class EvenNumbers(object): - "An iterable object." - - def __init__(self, maximum): - self.maximum = maximum - - def __iter__(self): - """ 1. make iterable""" - return EvenIterator(self) - - -class OddIterator(object): - "An iterator." - - def __init__(self, container): - self.container = container - self.n = -1 - - def __next__(self): - """ 2. use __next__() to return next item in container""" - self.n += 2 - if self.n > self.container.maximum: - raise StopIteration - return self.n - - def __iter__(self): - """ 3. __iter__() that returns itself """ - return self - - -class EvenIterator(object): - "An iterator." - - def __init__(self, container): - self.container = container - self.n = 0 - - def __next__(self): - """ 2. use __next__() to return next item in container""" - self.n += 2 - if self.n > self.container.maximum: - raise StopIteration - return self.n - - def __iter__(self): - """ 3. __iter__() that returns itself """ - return self - - -def main(): - spacer = "=" * 20 - print(spacer) - - print("Odd iterator in action") - numbers = OddNumbers(7) - for n in numbers: - print(n) - print(spacer) - - print("Even iterator in action") - numbers = EvenNumbers(8) - for n in numbers: - print(n) - print(spacer) - - -if __name__ == "__main__": - main() diff --git a/combos/decorated-strategy-pattern.py b/combos/decorated-strategy-pattern.py deleted file mode 100644 index a956e80..0000000 --- a/combos/decorated-strategy-pattern.py +++ /dev/null @@ -1,50 +0,0 @@ -# Fluent Python by Luciano Ramalho -# Decorator enhanced strategy pattern - - -promos = [] - - -def promotion(promo_func): - """ Strategy Interface """ - promos.append(promo_func) - return promo_func - - -@promotion -def fidelity(order): # concrete strategy - """5% discount for customers with 1000 or more fidelity points""" - return order.total() * .05 if order.customer.fidelity >= 1000 else 0 - - -@promotion -def bulk_item(order): # concrete strategy - """10% discount for each LineItem with 20 or more units""" - discount = 0 - for item in order.cart: - if item.quantity >= 20: - discount += item.total() * .1 - return discount - - -@promotion -def large_order(order): # concrete strategy - """7% discount for orders with 10 or more distinct items""" - distinct_items = {item.product for item in order.cart} - if len(distinct_items) >= 10: - return order.total() * .07 - return 0 - - -def best_promo(order): - """Select best discount available - """ - return max(promo(order) for promo in promos) - - -def main(): - print("runtime: main()") - - -if __name__ == "__main__": - main() diff --git a/creational/builder.py b/creational/builder.py deleted file mode 100644 index 0b2c220..0000000 --- a/creational/builder.py +++ /dev/null @@ -1,115 +0,0 @@ -""" -*What is this pattern about? -It decouples the creation of a complex object and its representation, -so that the same process can be reused to build objects from the same -family. -This is useful when you must separate the specification of an object -from its actual representation (generally for abstraction). -*What does this example do? -The first example achieves this by using an abstract base -class for a building, where the initializer (__init__ method) specifies the -steps needed, and the concrete subclasses implement these steps. -In other programming languages, a more complex arrangement is sometimes -necessary. In particular, you cannot have polymorphic behaviour in a constructor in C++ - -see https://stackoverflow.com/questions/1453131/how-can-i-get-polymorphic-behavior-in-a-c-constructor -- which means this Python technique will not work. The polymorphism -required has to be provided by an external, already constructed -instance of a different class. -In general, in Python this won't be necessary, but a second example showing -this kind of arrangement is also included. -*Where is the pattern used practically? -*References: -https://sourcemaking.com/design_patterns/builder -*TL;DR -Decouples the creation of a complex object and its representation. -""" - - -# Abstract Building -class Building: - def __init__(self): - self.build_floor() - self.build_size() - - def build_floor(self): - raise NotImplementedError - - def build_size(self): - raise NotImplementedError - - def __repr__(self): - return 'Floor: {0.floor} | Size: {0.size}'.format(self) - - -# Concrete Buildings (implements Building) -class House(Building): - def build_floor(self): - self.floor = 'Many' - - def build_size(self): - self.size = 'Big' - - -class Flat(Building): - def build_floor(self): - self.floor = 'More than One' - - def build_size(self): - self.size = 'Small' - - -# In some very complex cases, it might be desirable to pull out the building -# logic into another function (or a method on another class), rather than being -# in the base class '__init__'. (This leaves you in the strange situation where -# a concrete class does not have a useful constructor) - - -class ComplexBuilding: - def __repr__(self): - return 'Floor: {0.floor} | Size: {0.size}'.format(self) - - -class ComplexHouse(ComplexBuilding): - def build_floor(self): - self.floor = 'Many Many' - - def build_size(self): - self.size = 'Big and fancy' - - -def construct_building(cls): - building = cls() - building.build_floor() - building.build_size() - return building - - -def main(): - spacer = "=" * 20 - print(spacer) - - house = House() - print(house) - print(spacer) - # Floor: One | Size: Big - - flat = Flat() - print(flat) - print(spacer) - # Floor: More than One | Size: Small - - # Using an external constructor function: - complex_house = construct_building(ComplexHouse) - print(complex_house) - print(spacer) - # Floor: One | Size: Big and fancy - - # Using an external constructor function: - complex_house = construct_building(Flat) - print(complex_house) - print(spacer) - # Floor: | Size: - - -if __name__ == "__main__": - main() diff --git a/creational/builder_2.py b/creational/builder_2.py deleted file mode 100644 index 955428c..0000000 --- a/creational/builder_2.py +++ /dev/null @@ -1,80 +0,0 @@ -""" -Separate the construction of a complex object from its representation so -that the same construction process can create different representations. -""" - -import abc - - -class Director: - """ - Construct an object using the Builder interface. - """ - - def __init__(self): - self._builder = None - - def construct(self, builder): - self._builder = builder - self._builder._build_part_a() - self._builder._build_part_b() - self._builder._build_part_c() - - -class Builder(metaclass=abc.ABCMeta): - """ - Specify an abstract interface for creating parts of a Product - object. - """ - - def __init__(self): - self.product = Product() - - @abc.abstractmethod - def _build_part_a(self): - pass - - @abc.abstractmethod - def _build_part_b(self): - pass - - @abc.abstractmethod - def _build_part_c(self): - pass - - -class ConcreteBuilder(Builder): - """ - Construct and assemble parts of the product by implementing the - Builder interface. - Define and keep track of the representation it creates. - Provide an interface for retrieving the product. - """ - - def _build_part_a(self): - pass - - def _build_part_b(self): - pass - - def _build_part_c(self): - pass - - -class Product: - """ - Represent the complex object under construction. - """ - - pass - - -def main(): - concrete_builder = ConcreteBuilder() - director = Director() - director.construct(concrete_builder) - product = concrete_builder.product - - -if __name__ == "__main__": - main() diff --git a/creational/prototype.py b/creational/prototype.py deleted file mode 100644 index fed82f7..0000000 --- a/creational/prototype.py +++ /dev/null @@ -1,67 +0,0 @@ -""" -recommended solution to using the prototype pattern -source: https://python-patterns.guide/gang-of-four/prototype/ - -*What is this pattern about? -This patterns aims to reduce the number of classes required by an -application. Instead of relying on subclasses it creates objects by -copying a prototypical instance at run-time. - -Avoiding a factory. -- classes and functions in Python are first-class, thus can be passed as arguments like any other objects. -- first-class objects are stored in data structures can be passed as objects! -- we want to solve our problem without having to mirror each class with a factory. - -- we can use the original objects to store the arguments -- this gives those objects the ability to provide new instances. - -The result is the Prototype pattern! -- All of the factory classes disappear. -""" - -# The Prototype pattern: teach each object -# instance how to build copies of itself. - - -class Note(object): - "Musical note 1 ÷ `fraction` measures long." - def __init__(self, fraction): - self.fraction = fraction - - def clone(self): - return Note(self.fraction) - - -class Sharp(object): - "The symbol ♯." - def clone(self): - return Sharp() - - -class Flat(object): - "The symbol ♭." - def clone(self): - return Flat() - - -def main(): - spacer = "=" * 20 - print(spacer) - sharp = Note(fraction=2) - print(sharp, sharp.fraction) - print(spacer) - - sharp2 = sharp.clone() - print(sharp2, sharp2.fraction) - print(spacer) - - flat = Note(fraction=3) - flat2 = flat.clone() - print(flat, flat.fraction) - print(spacer) - print(flat2, flat.fraction) - print(spacer) - - -if __name__ == '__main__': - main() diff --git a/creational/singleton_0.py b/creational/singleton_0.py deleted file mode 100644 index 50f0926..0000000 --- a/creational/singleton_0.py +++ /dev/null @@ -1,33 +0,0 @@ -""" -Pattern name - Singleton -Pattern type - Creational Design Patterns -""" - - -class Singleton(object): - """ Singleton class""" - def __new__(cls, *args, **kwargs): - if not hasattr(cls, '_instance'): - cls._instance = super().__new__(cls, *args, **kwargs) - return cls._instance - - -def main(): - spacer = "=" * 20 - print(spacer) - - obj1 = Singleton() - print("Object 1: ", obj1) - obj1.data = 10 - obj2 = Singleton() - print("Object 2: ", obj2) - print(spacer) - - print("Object 2 data: ", obj2.data) - obj2.data = 5 - print("Object 1 data: ", obj1.data) - print(spacer) - - -if __name__ == "__main__": - main() diff --git a/creational/singleton_1.py b/creational/singleton_1.py deleted file mode 100644 index d35048e..0000000 --- a/creational/singleton_1.py +++ /dev/null @@ -1,33 +0,0 @@ -""" -Pattern name - SingleTon -Pattern type - Creational Design Pattern -""" - - -# Solution - 1 -class SingleTon(object): - def __new__(cls, *args, **kwargs): - if not hasattr(cls, '_instance'): - cls._instance = super().__new__(cls, *args, **kwargs) - return cls._instance - - -def main(): - spacer = "=" * 20 - print(spacer) - - o1 = SingleTon() - print("Object - 1 ==>", o1) - o1.data = 10 - print(spacer) - - o2 = SingleTon() - print("Object - 2 ==>", o2) - print("Object - 2 data ==>", o2.data) - o2.data = 5 - print("Object - 1 data ==>", o1.data) - print(spacer) - - -if __name__ == "__main__": - main() diff --git a/creational/singleton_2.py b/creational/singleton_2.py deleted file mode 100644 index 065c619..0000000 --- a/creational/singleton_2.py +++ /dev/null @@ -1,43 +0,0 @@ - -""" -Pattern name - SingleTon (Mono state pattern) -Pattern type - Creational Design Pattern -""" - - -# Solution - 2 -class Borg(object): - _shared = {} - - def __init__(self): - self.__dict__ = self._shared - - -class SingleTon(Borg): - def __init__(self, arg): - Borg.__init__(self) - self.val = arg - - # def __str__(self): - # return "<{} - Object>".format(self.val) - - -def main(): - spacer = "=" * 20 - print(spacer) - o1 = SingleTon("Hardik") - print("Object - 1 ==>", o1) - print("Object - 1 val ==>", o1.val) - - o2 = SingleTon("Aarav") - print("Object - 2 ==>", o2) - print("Object - 2 val ==>", o2.val) - print("Object - 1 val ==>", o1.val) - - print(o1.__dict__) - print(o2.__dict__) - print(spacer) - - -if __name__ == "__main__": - main() diff --git a/creational/singleton_3.py b/creational/singleton_3.py deleted file mode 100644 index ccbc0b5..0000000 --- a/creational/singleton_3.py +++ /dev/null @@ -1,48 +0,0 @@ -""" -Pattern name - SingleTon -Pattern type - Creational Design Pattern -""" - - -# Solution - 3 -class SingletonDecorator(object): - def __init__(self, klass): - self.klass = klass - self.instance = None - - def __call__(self, *args, **kwargs): - if self.instance is None: - self.instance = self.klass(*args, **kwargs) - return self.instance - - -@SingletonDecorator -class Logger(object): - def __init__(self): - self.start = None - - def write(self, message): - if self.start: - print(self.start, message) - else: - print(message) - - -def main(): - spacer = "=" * 20 - print(spacer) - - logger1 = Logger() - logger1.start = "# >" - print("Logger 1", logger1) - logger1.write("Logger1 object is created.") - - logger2 = Logger() - logger2.start = "$ >" - print("Logger 2", logger2) - logger1.write("Logger2 object is created.") - print(spacer) - - -if __name__ == "__main__": - main() diff --git a/creational/singleton_4.py b/creational/singleton_4.py deleted file mode 100644 index 1b3a993..0000000 --- a/creational/singleton_4.py +++ /dev/null @@ -1,40 +0,0 @@ -""" -Pattern name - SingleTon -Pattern type - Creational Design Pattern -""" - - -# Solution - 4 -class SingletonMeta(type): - __instances = {} - - def __call__(cls, *args, **kwargs): - if cls not in cls.__instances: - cls.__instances[cls] = super().__call__(*args, **kwargs) - print(cls.__instances) - return cls.__instances[cls] - - -class DBConnector(metaclass=SingletonMeta): - def __init__(self): - self.status = "Not Connected" - - def disconnect(self): - self.status = "Disconnected" - - def connect(self): - self.status = "Connected" - - -if __name__ == "__main__": - spacer = "=" * 20 - print(spacer) - - client1 = DBConnector() - print("Client 1 ", client1) - print(client1.status) - print(spacer) - - client2 = DBConnector() - print("Client 2 ", client2) - print(spacer) diff --git a/docs/contributing.md b/docs/contributing.md new file mode 100644 index 0000000..55a9104 --- /dev/null +++ b/docs/contributing.md @@ -0,0 +1,3 @@ +# contributing + +_Written in the docs phase._ diff --git a/docs/how-to-read-this-repo.md b/docs/how-to-read-this-repo.md new file mode 100644 index 0000000..292a0c2 --- /dev/null +++ b/docs/how-to-read-this-repo.md @@ -0,0 +1,3 @@ +# how-to-read-this-repo + +_Written in the docs phase._ diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..3b3a0e7 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,6 @@ +# Documentation + +- [How to read this repo](how-to-read-this-repo.md) +- [Verdicts](verdicts.md) — what `pythonic` / `use-with-care` / `prefer-alternative` mean +- [MCP server](mcp.md) — connect agents to the catalog +- [Contributing](contributing.md) diff --git a/docs/mcp.md b/docs/mcp.md new file mode 100644 index 0000000..10cdeb0 --- /dev/null +++ b/docs/mcp.md @@ -0,0 +1,3 @@ +# mcp + +_Written in the docs phase._ diff --git a/docs/verdicts.md b/docs/verdicts.md new file mode 100644 index 0000000..099c338 --- /dev/null +++ b/docs/verdicts.md @@ -0,0 +1,3 @@ +# verdicts + +_Written in the docs phase._ diff --git a/patterns/__init__.py b/patterns/__init__.py new file mode 100644 index 0000000..c2468b6 --- /dev/null +++ b/patterns/__init__.py @@ -0,0 +1 @@ +"""Pattern catalog: one directory per pattern unit.""" diff --git a/patterns/behavioral/__init__.py b/patterns/behavioral/__init__.py new file mode 100644 index 0000000..51eeff6 --- /dev/null +++ b/patterns/behavioral/__init__.py @@ -0,0 +1 @@ +"""behavioral patterns.""" diff --git a/patterns/behavioral/chain_of_responsibility/README.md b/patterns/behavioral/chain_of_responsibility/README.md new file mode 100644 index 0000000..aad0e2a --- /dev/null +++ b/patterns/behavioral/chain_of_responsibility/README.md @@ -0,0 +1,43 @@ +--- +id: behavioral/chain_of_responsibility +name: Chain of Responsibility +aliases: [chain, handler-chain] +guide_url: null +problem: "Pass a request along a line of handlers until one of them takes it." +symptoms: ["escalation levels", "middleware chain", "first handler that can, does", "fallback handlers"] +verdict: prefer-alternative +caveats: + - "In Python the chain is a list of callables and a loop — successor pointers threaded through objects add nothing but pointer bookkeeping." + - "Decide up front what an unhandled request means (exception? default?) — the GoF pattern is silent about falling off the end." +stdlib_sightings: [logging propagation, urllib.request opener chain] +--- + +# Chain of Responsibility + +## Problem + +A support ticket should be handled by the first tier able to deal with it; +an HTTP request passes middleware until something produces a response. The +sender must not know which handler will answer. + +## Naive solution + +`naive.py` threads successor pointers through handler objects, GoF-style: +each handler either handles or forwards to `self.successor`. + +## Pythonic solution + +A chain is a *list of callables* tried in order — the first non-`None` answer +wins. Registration is appending; reordering is list surgery; the +fell-off-the-end case is explicit. That's the whole pattern. + +## In the wild + +`logging` propagation is a chain: a record climbs the logger hierarchy, +offered to each logger's handlers on the way up. `urllib.request` passes +requests through its chain of openers/handlers until one claims the scheme. + +## Verdict + +**Prefer an alternative:** a list and a loop. Objects with successor +pointers, only if handlers already are stateful objects. diff --git a/patterns/behavioral/chain_of_responsibility/__init__.py b/patterns/behavioral/chain_of_responsibility/__init__.py new file mode 100644 index 0000000..1885894 --- /dev/null +++ b/patterns/behavioral/chain_of_responsibility/__init__.py @@ -0,0 +1 @@ +"""Chain of Responsibility: first handler that can, does. Verdict: a list and a loop.""" diff --git a/patterns/behavioral/chain_of_responsibility/naive.py b/patterns/behavioral/chain_of_responsibility/naive.py new file mode 100644 index 0000000..e062941 --- /dev/null +++ b/patterns/behavioral/chain_of_responsibility/naive.py @@ -0,0 +1,50 @@ +"""The Gang of Four chain: successor pointers through handler objects.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod + + +class Handler(ABC): + def __init__(self, successor: Handler | None = None) -> None: + self.successor = successor + + def handle(self, severity: int) -> str: + answer = self._attempt(severity) + if answer is not None: + return answer + if self.successor is None: + return "unhandled" + return self.successor.handle(severity) + + @abstractmethod + def _attempt(self, severity: int) -> str | None: ... + + +class Helpdesk(Handler): + def _attempt(self, severity: int) -> str | None: + return "helpdesk resolves it" if severity <= 1 else None + + +class Engineer(Handler): + def _attempt(self, severity: int) -> str | None: + return "engineer resolves it" if severity <= 3 else None + + +class Management(Handler): + def _attempt(self, severity: int) -> str | None: + return "management escalation" if severity <= 5 else None + + +def build_chain() -> Handler: + return Helpdesk(Engineer(Management())) + + +def main() -> None: + chain = build_chain() + for severity in (1, 3, 5, 9): + print(f"severity {severity}: {chain.handle(severity)}") + + +if __name__ == "__main__": + main() diff --git a/patterns/behavioral/chain_of_responsibility/pythonic.py b/patterns/behavioral/chain_of_responsibility/pythonic.py new file mode 100644 index 0000000..ac57252 --- /dev/null +++ b/patterns/behavioral/chain_of_responsibility/pythonic.py @@ -0,0 +1,43 @@ +"""The chain as a list of callables and one loop. + +Each handler returns an answer or None; the first answer wins, and the +unhandled case is explicit at the end of the loop. +""" + +from __future__ import annotations + +from collections.abc import Callable, Sequence + +Handler = Callable[[int], str | None] + + +def helpdesk(severity: int) -> str | None: + return "helpdesk resolves it" if severity <= 1 else None + + +def engineer(severity: int) -> str | None: + return "engineer resolves it" if severity <= 3 else None + + +def management(severity: int) -> str | None: + return "management escalation" if severity <= 5 else None + + +CHAIN: list[Handler] = [helpdesk, engineer, management] + + +def handle(severity: int, chain: Sequence[Handler] | None = None) -> str: + for handler in chain if chain is not None else CHAIN: + answer = handler(severity) + if answer is not None: + return answer + return "unhandled" # falling off the end is a decision, made visible + + +def main() -> None: + for severity in (1, 3, 5, 9): + print(f"severity {severity}: {handle(severity)}") + + +if __name__ == "__main__": + main() diff --git a/patterns/behavioral/chain_of_responsibility/real_world.py b/patterns/behavioral/chain_of_responsibility/real_world.py new file mode 100644 index 0000000..025fd39 --- /dev/null +++ b/patterns/behavioral/chain_of_responsibility/real_world.py @@ -0,0 +1,35 @@ +"""``logging`` propagation: a record climbs the logger hierarchy. + +A child logger with no handlers still gets its records delivered -- they +propagate up the chain until some ancestor's handler takes them. +""" + +from __future__ import annotations + +import logging + + +def chain_delivery(sink: list[str]) -> None: + """Log on the child; watch the parent's handler receive it.""" + + class ListHandler(logging.Handler): + def emit(self, record: logging.LogRecord) -> None: + sink.append(f"{record.name}: {record.getMessage()}") + + parent = logging.getLogger("cor_demo") + parent.handlers.clear() + parent.setLevel(logging.INFO) + parent.addHandler(ListHandler()) + + child = logging.getLogger("cor_demo.web.requests") # no handlers of its own + child.info("timeout on /api") + + +def main() -> None: + sink: list[str] = [] + chain_delivery(sink) + print(sink) + + +if __name__ == "__main__": + main() diff --git a/patterns/behavioral/chain_of_responsibility/tests/__init__.py b/patterns/behavioral/chain_of_responsibility/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/patterns/behavioral/chain_of_responsibility/tests/test_chain_of_responsibility.py b/patterns/behavioral/chain_of_responsibility/tests/test_chain_of_responsibility.py new file mode 100644 index 0000000..97f7c6e --- /dev/null +++ b/patterns/behavioral/chain_of_responsibility/tests/test_chain_of_responsibility.py @@ -0,0 +1,35 @@ +"""Behavioral tests for all three chain-of-responsibility variants.""" + +from patterns.behavioral.chain_of_responsibility import naive, pythonic, real_world + + +class TestNaive: + def test_first_capable_handler_wins(self) -> None: + chain = naive.build_chain() + assert chain.handle(1) == "helpdesk resolves it" + assert chain.handle(3) == "engineer resolves it" + assert chain.handle(5) == "management escalation" + + def test_falling_off_the_end(self) -> None: + assert naive.build_chain().handle(9) == "unhandled" + + +class TestPythonic: + def test_list_chain_matches_naive(self) -> None: + assert pythonic.handle(1) == "helpdesk resolves it" + assert pythonic.handle(3) == "engineer resolves it" + assert pythonic.handle(9) == "unhandled" + + def test_reordering_is_list_surgery(self) -> None: + reordered: list[pythonic.Handler] = [pythonic.management, pythonic.helpdesk] + assert pythonic.handle(1, reordered) == "management escalation" + + def test_empty_chain_is_explicitly_unhandled(self) -> None: + assert pythonic.handle(1, []) == "unhandled" + + +class TestRealWorld: + def test_record_propagates_to_ancestor_handler(self) -> None: + sink: list[str] = [] + real_world.chain_delivery(sink) + assert sink == ["cor_demo.web.requests: timeout on /api"] diff --git a/patterns/behavioral/command/README.md b/patterns/behavioral/command/README.md new file mode 100644 index 0000000..93c117d --- /dev/null +++ b/patterns/behavioral/command/README.md @@ -0,0 +1,45 @@ +--- +id: behavioral/command +name: Command +aliases: [action, transaction] +guide_url: null +problem: "Package a request as an object so it can be queued, logged, undone, or executed later by code that doesn't know its details." +symptoms: ["undo/redo", "task queue", "macro recording", "button callbacks", "audit log of operations"] +verdict: use-with-care +caveats: + - "If you only need 'execute later', a plain callable or functools.partial is the whole pattern — don't build a class hierarchy for a deferred call." + - "The class form earns its keep exactly when commands carry extra behavior: undo(), serialization, or metadata." +stdlib_sightings: [functools.partial, sched.scheduler, unittest.mock.call] +--- + +# Command + +## Problem + +A menu button, a job queue, or an undo stack must trigger operations without +knowing what they do. Reify the request: an object carrying everything needed +to perform (and possibly reverse) it. + +## Naive solution + +`naive.py` is the classic remote-control shape: a `Command` interface with +`execute`/`undo`, concrete commands closing over a receiver, and an invoker +that runs them and keeps a history for undo. + +## Pythonic solution + +Functions are first-class, so *a command is just a callable*. `pythonic.py` +queues `functools.partial` objects for the execute-only case, and uses a pair +of callables (do, undo) where reversibility matters — no interface, no +hierarchy. + +## In the wild + +Every callback API is the Command pattern: `sched.scheduler.enter` takes the +action as a callable, Tkinter buttons take `command=`, `atexit.register` +queues commands to run at shutdown. + +## Verdict + +**Use with care.** Callables for deferral, the class form only once commands +need undo, serialization, or introspection beyond "run me". diff --git a/patterns/behavioral/command/__init__.py b/patterns/behavioral/command/__init__.py new file mode 100644 index 0000000..9f0d6a2 --- /dev/null +++ b/patterns/behavioral/command/__init__.py @@ -0,0 +1 @@ +"""Command: reify a request so it can be queued, logged, or undone.""" diff --git a/behavioral/command-design-pattern.PNG b/patterns/behavioral/command/assets/command-design-pattern.png similarity index 100% rename from behavioral/command-design-pattern.PNG rename to patterns/behavioral/command/assets/command-design-pattern.png diff --git a/patterns/behavioral/command/naive.py b/patterns/behavioral/command/naive.py new file mode 100644 index 0000000..ad39790 --- /dev/null +++ b/patterns/behavioral/command/naive.py @@ -0,0 +1,65 @@ +"""The Gang of Four Command: interface, concrete commands, invoker with undo. + +A text editor whose operations are objects. The invoker keeps history, so +undo is popping the stack and asking the command to reverse itself. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod + + +class Document: + """The receiver: the thing commands operate on.""" + + def __init__(self) -> None: + self.text = "" + + +class Command(ABC): + @abstractmethod + def execute(self) -> None: ... + + @abstractmethod + def undo(self) -> None: ... + + +class AppendText(Command): + def __init__(self, doc: Document, text: str) -> None: + self.doc = doc + self.text = text + + def execute(self) -> None: + self.doc.text += self.text + + def undo(self) -> None: + self.doc.text = self.doc.text[: -len(self.text)] + + +class Editor: + """The invoker: runs commands and remembers them for undo.""" + + def __init__(self) -> None: + self._history: list[Command] = [] + + def do(self, command: Command) -> None: + command.execute() + self._history.append(command) + + def undo(self) -> None: + if self._history: + self._history.pop().undo() + + +def main() -> None: + doc = Document() + editor = Editor() + editor.do(AppendText(doc, "hello")) + editor.do(AppendText(doc, " world")) + print(f"after edits: {doc.text!r}") + editor.undo() + print(f"after undo: {doc.text!r}") + + +if __name__ == "__main__": + main() diff --git a/patterns/behavioral/command/pythonic.py b/patterns/behavioral/command/pythonic.py new file mode 100644 index 0000000..8c7852c --- /dev/null +++ b/patterns/behavioral/command/pythonic.py @@ -0,0 +1,67 @@ +"""Commands as callables. + +For plain deferral, ``functools.partial`` packages the call and its +arguments. For undo, a command is a (do, undo) pair -- here a small frozen +dataclass of two callables, still no interface or hierarchy. +""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass, field +from functools import partial + + +def run_queue(queue: list[Callable[[], None]]) -> None: + """The execute-only invoker: call everything, in order.""" + for command in queue: + command() + + +@dataclass(frozen=True) +class Undoable: + """A reversible command: two callables, no ceremony.""" + + do: Callable[[], None] + undo: Callable[[], None] + + +@dataclass +class Editor: + text: str = "" + _history: list[Undoable] = field(default_factory=list) + + def append(self, chunk: str) -> None: + command = Undoable( + do=partial(self._append, chunk), + undo=partial(self._chop, len(chunk)), + ) + command.do() + self._history.append(command) + + def undo(self) -> None: + if self._history: + self._history.pop().undo() + + def _append(self, chunk: str) -> None: + self.text += chunk + + def _chop(self, n: int) -> None: + self.text = self.text[:-n] + + +def main() -> None: + log: list[str] = [] + queue: list[Callable[[], None]] = [partial(log.append, "a"), partial(log.append, "b")] + run_queue(queue) + print(f"queued callables ran: {log}") + + editor = Editor() + editor.append("hello") + editor.append(" world") + editor.undo() + print(f"after undo: {editor.text!r}") + + +if __name__ == "__main__": + main() diff --git a/patterns/behavioral/command/real_world.py b/patterns/behavioral/command/real_world.py new file mode 100644 index 0000000..48f1cfc --- /dev/null +++ b/patterns/behavioral/command/real_world.py @@ -0,0 +1,41 @@ +"""Callbacks in the stdlib are the Command pattern. + +``sched.scheduler`` queues (time, priority, action, arguments) records -- +commands with metadata -- and its run loop is the invoker. +""" + +from __future__ import annotations + +import sched + + +class FakeClock: + """A clock the scheduler advances by 'sleeping' -- tests run instantly.""" + + def __init__(self) -> None: + self.now = 0.0 + + def time(self) -> float: + return self.now + + def sleep(self, duration: float) -> None: + self.now += duration + + +def run_scheduled(chunks: list[str]) -> list[str]: + """Queue one append-command per chunk; the scheduler invokes them in order.""" + log: list[str] = [] + clock = FakeClock() + scheduler = sched.scheduler(timefunc=clock.time, delayfunc=clock.sleep) + for delay, chunk in enumerate(chunks): + scheduler.enter(float(delay), 1, log.append, argument=(chunk,)) + scheduler.run() + return log + + +def main() -> None: + print(run_scheduled(["first", "second", "third"])) + + +if __name__ == "__main__": + main() diff --git a/patterns/behavioral/command/tests/__init__.py b/patterns/behavioral/command/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/patterns/behavioral/command/tests/test_command.py b/patterns/behavioral/command/tests/test_command.py new file mode 100644 index 0000000..ba5da6a --- /dev/null +++ b/patterns/behavioral/command/tests/test_command.py @@ -0,0 +1,43 @@ +"""Behavioral tests for all three command variants.""" + +from functools import partial + +from patterns.behavioral.command import naive, pythonic, real_world + + +class TestNaive: + def test_execute_mutates_receiver(self) -> None: + doc, editor = naive.Document(), naive.Editor() + editor.do(naive.AppendText(doc, "hi")) + assert doc.text == "hi" + + def test_undo_reverses_last_command(self) -> None: + doc, editor = naive.Document(), naive.Editor() + editor.do(naive.AppendText(doc, "hello")) + editor.do(naive.AppendText(doc, " world")) + editor.undo() + assert doc.text == "hello" + + def test_undo_on_empty_history_is_a_noop(self) -> None: + naive.Editor().undo() # must not raise + + +class TestPythonic: + def test_partial_queue_runs_in_order(self) -> None: + log: list[str] = [] + pythonic.run_queue([partial(log.append, "a"), partial(log.append, "b")]) + assert log == ["a", "b"] + + def test_undoable_editor_round_trip(self) -> None: + editor = pythonic.Editor() + editor.append("hello") + editor.append(" world") + assert editor.text == "hello world" + editor.undo() + editor.undo() + assert editor.text == "" + + +class TestRealWorld: + def test_scheduler_invokes_queued_commands_in_order(self) -> None: + assert real_world.run_scheduled(["x", "y", "z"]) == ["x", "y", "z"] diff --git a/patterns/behavioral/interpreter/README.md b/patterns/behavioral/interpreter/README.md new file mode 100644 index 0000000..166e659 --- /dev/null +++ b/patterns/behavioral/interpreter/README.md @@ -0,0 +1,44 @@ +--- +id: behavioral/interpreter +name: Interpreter +aliases: [little-language, expression-tree] +guide_url: null +problem: "Represent a small language's grammar as data and evaluate sentences in it." +symptoms: ["mini query language", "user-supplied formulas", "rules engine", "evaluate expressions safely"] +verdict: prefer-alternative +caveats: + - "Before inventing a language, check whether Python is the language: ast.literal_eval for data, a vetted ast walk for arithmetic, a real parser library beyond that." + - "Never eval() user input — the safe version of this pattern exists precisely to avoid that." +stdlib_sightings: [ast.literal_eval, ast.NodeVisitor, re] +--- + +# Interpreter + +## Problem + +Users need to supply small formulas — spreadsheet expressions, feature-flag +rules — that your program must evaluate, safely, without shipping them to +`eval()`. + +## Naive solution + +`naive.py` is the GoF class-per-grammar-rule form: `Number`, `Add`, `Mul` +nodes each carrying `interpret()`, composed into an expression tree. + +## Pythonic solution + +The tree doesn't need a class per rule: nested tuples plus one recursive +function interpret the same grammar in a screenful. Adding an operation to +the language is one dict entry, not a class. + +## In the wild + +The `re` module is a full Interpreter-pattern implementation you use daily +(pattern → compiled program → evaluated against strings). `ast.literal_eval` +safely interprets Python's own literal grammar, and `real_world.py` builds +the classic safe arithmetic evaluator from a restricted `ast` walk. + +## Verdict + +**Prefer an alternative.** Python's own parsers (`ast`, `re`) cover most +"little language" needs; write a grammar only when you truly have a language. diff --git a/patterns/behavioral/interpreter/__init__.py b/patterns/behavioral/interpreter/__init__.py new file mode 100644 index 0000000..5362fe1 --- /dev/null +++ b/patterns/behavioral/interpreter/__init__.py @@ -0,0 +1 @@ +"""Interpreter: grammar as data. Verdict: use Python own parsers first.""" diff --git a/patterns/behavioral/interpreter/naive.py b/patterns/behavioral/interpreter/naive.py new file mode 100644 index 0000000..29809e3 --- /dev/null +++ b/patterns/behavioral/interpreter/naive.py @@ -0,0 +1,44 @@ +"""The Gang of Four Interpreter: one class per grammar rule.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod + + +class Expression(ABC): + @abstractmethod + def interpret(self) -> int: ... + + +class Number(Expression): + def __init__(self, value: int) -> None: + self.value = value + + def interpret(self) -> int: + return self.value + + +class Add(Expression): + def __init__(self, left: Expression, right: Expression) -> None: + self.left, self.right = left, right + + def interpret(self) -> int: + return self.left.interpret() + self.right.interpret() + + +class Mul(Expression): + def __init__(self, left: Expression, right: Expression) -> None: + self.left, self.right = left, right + + def interpret(self) -> int: + return self.left.interpret() * self.right.interpret() + + +def main() -> None: + # (2 + 3) * 4 + tree = Mul(Add(Number(2), Number(3)), Number(4)) + print(tree.interpret()) + + +if __name__ == "__main__": + main() diff --git a/patterns/behavioral/interpreter/pythonic.py b/patterns/behavioral/interpreter/pythonic.py new file mode 100644 index 0000000..356c9fd --- /dev/null +++ b/patterns/behavioral/interpreter/pythonic.py @@ -0,0 +1,33 @@ +"""The same grammar as data: nested tuples, one recursive evaluator. + +Extending the language is a dict entry, not a class. +""" + +from __future__ import annotations + +import operator +from collections.abc import Callable + +Expr = int | tuple[str, "Expr", "Expr"] + +OPS: dict[str, Callable[[int, int], int]] = { + "+": operator.add, + "*": operator.mul, + "-": operator.sub, +} + + +def interpret(expr: Expr) -> int: + if isinstance(expr, int): + return expr + op, left, right = expr + return OPS[op](interpret(left), interpret(right)) + + +def main() -> None: + tree: Expr = ("*", ("+", 2, 3), 4) + print(interpret(tree)) + + +if __name__ == "__main__": + main() diff --git a/patterns/behavioral/interpreter/real_world.py b/patterns/behavioral/interpreter/real_world.py new file mode 100644 index 0000000..f288305 --- /dev/null +++ b/patterns/behavioral/interpreter/real_world.py @@ -0,0 +1,45 @@ +"""Interpreting with Python's own parser: a safe arithmetic evaluator. + +``ast.parse`` builds the tree; a restricted walk evaluates only the node +types we allow. User input never reaches eval(). +""" + +from __future__ import annotations + +import ast +import operator +from collections.abc import Callable + +_BINOPS: dict[type[ast.operator], Callable[[float, float], float]] = { + ast.Add: operator.add, + ast.Sub: operator.sub, + ast.Mult: operator.mul, + ast.Div: operator.truediv, +} + + +def safe_eval(formula: str) -> float: + """Evaluate arithmetic like '2 * (3 + 4)'; reject everything else.""" + return _walk(ast.parse(formula, mode="eval").body) + + +def _walk(node: ast.expr) -> float: + if isinstance(node, ast.Constant) and isinstance(node.value, int | float): + return float(node.value) + if isinstance(node, ast.BinOp) and type(node.op) in _BINOPS: + return _BINOPS[type(node.op)](_walk(node.left), _walk(node.right)) + if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.USub): + return -_walk(node.operand) + raise ValueError(f"disallowed syntax: {ast.dump(node)[:40]}") + + +def main() -> None: + print(safe_eval("2 * (3 + 4)")) + try: + safe_eval("__import__('os')") + except ValueError as exc: + print(f"rejected: {exc}") + + +if __name__ == "__main__": + main() diff --git a/patterns/behavioral/interpreter/tests/__init__.py b/patterns/behavioral/interpreter/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/patterns/behavioral/interpreter/tests/test_interpreter.py b/patterns/behavioral/interpreter/tests/test_interpreter.py new file mode 100644 index 0000000..deb7f77 --- /dev/null +++ b/patterns/behavioral/interpreter/tests/test_interpreter.py @@ -0,0 +1,36 @@ +"""Behavioral tests for all three interpreter variants.""" + +import pytest + +from patterns.behavioral.interpreter import naive, pythonic, real_world + + +class TestNaive: + def test_tree_interprets(self) -> None: + tree = naive.Mul(naive.Add(naive.Number(2), naive.Number(3)), naive.Number(4)) + assert tree.interpret() == 20 + + +class TestPythonic: + def test_tuple_tree_interprets(self) -> None: + assert pythonic.interpret(("*", ("+", 2, 3), 4)) == 20 + + def test_bare_number(self) -> None: + assert pythonic.interpret(7) == 7 + + def test_language_extends_by_dict_entry(self) -> None: + assert pythonic.interpret(("-", 10, 4)) == 6 + + +class TestRealWorld: + def test_safe_arithmetic(self) -> None: + assert real_world.safe_eval("2 * (3 + 4)") == 14.0 + assert real_world.safe_eval("-5 + 1") == -4.0 + + def test_attack_is_rejected_not_executed(self) -> None: + with pytest.raises(ValueError, match="disallowed"): + real_world.safe_eval("__import__('os').system('true')") + + def test_names_are_rejected(self) -> None: + with pytest.raises(ValueError): + real_world.safe_eval("x + 1") diff --git a/patterns/behavioral/iterator/README.md b/patterns/behavioral/iterator/README.md new file mode 100644 index 0000000..be3dc97 --- /dev/null +++ b/patterns/behavioral/iterator/README.md @@ -0,0 +1,47 @@ +--- +id: behavioral/iterator +name: Iterator +aliases: [cursor] +guide_url: https://python-patterns.guide/gang-of-four/iterator/ +problem: "Traverse a container's elements without exposing how the container stores them." +symptoms: ["custom traversal order", "lazy sequence", "for loop over my own class", "stream elements one at a time"] +verdict: pythonic +caveats: + - "The container and its iterator are different objects with different jobs: the container's __iter__ returns a fresh iterator; the iterator's __iter__ returns itself." + - "Writing __next__ by hand is almost always the wrong level — a generator implements the whole protocol for you." +stdlib_sightings: [iter, next, generators, itertools] +--- + +# Iterator + +## Problem + +Callers want to walk a collection's elements — possibly lazily, possibly in a +custom order — without coupling to its storage. The GoF answer is a separate +cursor object with a "give me the next one" method. + +## Naive solution + +`naive.py` implements the protocol by hand, the way the guide teaches it: +an iterable whose `__iter__` returns a fresh iterator object, and an iterator +with `__next__` (raising `StopIteration`) plus `__iter__` returning itself so +it can be used directly in a `for` loop. + +## Pythonic solution + +Python absorbed this pattern deeper than any other — `for`, unpacking, and +comprehensions all speak the protocol natively, and **generators** write the +iterator for you: a function with `yield` returns an object implementing +`__iter__` and `__next__` correctly, with all cursor state kept in the frame. +`pythonic.py` re-does `naive.py` in a fraction of the code. + +## In the wild + +`itertools` is an entire stdlib module of composable iterators; files iterate +by line; `dict` yields keys. `real_world.py` composes `itertools.islice` and +`itertools.count` into a lazy, infinite-but-bounded pipeline. + +## Verdict + +**Pythonic.** Know the manual protocol (it's the machinery underneath), write +generators in practice. diff --git a/patterns/behavioral/iterator/__init__.py b/patterns/behavioral/iterator/__init__.py new file mode 100644 index 0000000..c910a88 --- /dev/null +++ b/patterns/behavioral/iterator/__init__.py @@ -0,0 +1 @@ +"""Iterator: traverse a container without exposing its storage.""" diff --git a/patterns/behavioral/iterator/naive.py b/patterns/behavioral/iterator/naive.py new file mode 100644 index 0000000..b0e4a69 --- /dev/null +++ b/patterns/behavioral/iterator/naive.py @@ -0,0 +1,46 @@ +"""The iterator protocol implemented by hand. + +The guide's three rules: +1. the iterable's ``__iter__`` returns a new iterator; +2. the iterator's ``__next__`` returns items and raises ``StopIteration``; +3. the iterator's ``__iter__`` returns itself. +""" + +from __future__ import annotations + + +class OddNumbers: + """An iterable: knows its contents, delegates traversal.""" + + def __init__(self, maximum: int) -> None: + self.maximum = maximum + + def __iter__(self) -> OddIterator: + return OddIterator(self) + + +class OddIterator: + """An iterator: owns the cursor state.""" + + def __init__(self, container: OddNumbers) -> None: + self.container = container + self.n = -1 + + def __next__(self) -> int: + self.n += 2 + if self.n > self.container.maximum: + raise StopIteration + return self.n + + def __iter__(self) -> OddIterator: + return self + + +def main() -> None: + numbers = OddNumbers(7) + print(list(numbers)) + print(list(numbers)) # a fresh iterator each time -- iteration restarts + + +if __name__ == "__main__": + main() diff --git a/patterns/behavioral/iterator/pythonic.py b/patterns/behavioral/iterator/pythonic.py new file mode 100644 index 0000000..2590125 --- /dev/null +++ b/patterns/behavioral/iterator/pythonic.py @@ -0,0 +1,41 @@ +"""Generators: the iterator pattern as a language feature. + +A function with ``yield`` returns an object that already implements +``__iter__`` and ``__next__``; the cursor state lives in the paused frame. +An ``__iter__`` written as a generator makes a class iterable in one line. +""" + +from __future__ import annotations + +from collections.abc import Iterator + + +def odd_numbers(maximum: int) -> Iterator[int]: + """The whole of naive.py, as a generator.""" + n = 1 + while n <= maximum: + yield n + n += 2 + + +class OddNumbers: + """An iterable class whose __iter__ is itself a generator.""" + + def __init__(self, maximum: int) -> None: + self.maximum = maximum + + def __iter__(self) -> Iterator[int]: + n = 1 + while n <= self.maximum: + yield n + n += 2 + + +def main() -> None: + print(list(odd_numbers(7))) + print(list(OddNumbers(7))) + print([n * n for n in OddNumbers(9)]) # comprehensions speak the protocol + + +if __name__ == "__main__": + main() diff --git a/patterns/behavioral/iterator/real_world.py b/patterns/behavioral/iterator/real_world.py new file mode 100644 index 0000000..650a16d --- /dev/null +++ b/patterns/behavioral/iterator/real_world.py @@ -0,0 +1,26 @@ +"""``itertools``: the stdlib's iterator toolbox. + +Iterators compose: ``count`` is infinite, ``islice`` bounds it, and nothing +is computed until iteration demands it. +""" + +from __future__ import annotations + +import itertools +from collections.abc import Iterator + + +def first_n_odd_squares(n: int) -> Iterator[int]: + """A lazy pipeline over an infinite source.""" + odds = itertools.count(start=1, step=2) # 1, 3, 5, ... forever + return itertools.islice((x * x for x in odds), n) + + +def main() -> None: + print(list(first_n_odd_squares(5))) + evens_then_odds = itertools.chain([0, 2, 4], [1, 3, 5]) + print(list(evens_then_odds)) + + +if __name__ == "__main__": + main() diff --git a/patterns/behavioral/iterator/tests/__init__.py b/patterns/behavioral/iterator/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/patterns/behavioral/iterator/tests/test_iterator.py b/patterns/behavioral/iterator/tests/test_iterator.py new file mode 100644 index 0000000..e2c9b4a --- /dev/null +++ b/patterns/behavioral/iterator/tests/test_iterator.py @@ -0,0 +1,40 @@ +"""Behavioral tests for all three iterator variants.""" + +import pytest + +from patterns.behavioral.iterator import naive, pythonic, real_world + + +class TestNaive: + def test_yields_odds_up_to_maximum(self) -> None: + assert list(naive.OddNumbers(7)) == [1, 3, 5, 7] + + def test_iterable_restarts_iterator_does_not(self) -> None: + numbers = naive.OddNumbers(5) + assert list(numbers) == list(numbers) == [1, 3, 5] + it = iter(numbers) + assert list(it) == [1, 3, 5] + assert list(it) == [] # the iterator itself is exhausted + + def test_next_raises_stop_iteration_when_done(self) -> None: + it = iter(naive.OddNumbers(1)) + assert next(it) == 1 + with pytest.raises(StopIteration): + next(it) + + +class TestPythonic: + def test_generator_function_matches_naive(self) -> None: + assert list(pythonic.odd_numbers(7)) == [1, 3, 5, 7] + + def test_generator_dunder_iter_makes_class_iterable(self) -> None: + assert list(pythonic.OddNumbers(9)) == [1, 3, 5, 7, 9] + + def test_generators_are_lazy(self) -> None: + gen = pythonic.odd_numbers(10**12) # instant: nothing computed yet + assert next(gen) == 1 + + +class TestRealWorld: + def test_bounded_pipeline_over_infinite_source(self) -> None: + assert list(real_world.first_n_odd_squares(4)) == [1, 9, 25, 49] diff --git a/patterns/behavioral/mediator/README.md b/patterns/behavioral/mediator/README.md new file mode 100644 index 0000000..e8716b2 --- /dev/null +++ b/patterns/behavioral/mediator/README.md @@ -0,0 +1,44 @@ +--- +id: behavioral/mediator +name: Mediator +aliases: [coordinator, hub] +guide_url: null +problem: "Stop a web of objects from referencing each other by routing their interactions through one coordinator." +symptoms: ["widgets updating each other", "N-squared object references", "form fields with interdependent rules", "components need decoupling"] +verdict: use-with-care +caveats: + - "The mediator earns its keep by deleting pairwise references; if it grows into a god object that knows everything, you traded a web for a blob." + - "For pipeline-shaped decoupling, a queue between producers and consumers is the simpler mediator." +stdlib_sightings: [queue.Queue, asyncio.Queue] +--- + +# Mediator + +## Problem + +A signup form: the submit button enables only when username and password +fields validate, the password strength meter watches the password field… +Let the widgets reference each other and you get N² couplings that no one +can safely change. + +## Naive solution + +`naive.py` is the GoF dialog: colleagues report every change to the mediator +and *only* the mediator decides who reacts. + +## Pythonic solution + +The mediator doesn't need a Colleague base class — widgets accept a +`notify` callable, and the mediator is a small coordinator holding the +interaction rules in one readable place. + +## In the wild + +`queue.Queue` mediates producers and consumers: neither side knows the +other exists, and the coupling that used to be pairwise lives in one +thread-safe object. + +## Verdict + +**Use with care.** Excellent for genuinely tangled interaction rules; watch +for god-object drift. diff --git a/patterns/behavioral/mediator/__init__.py b/patterns/behavioral/mediator/__init__.py new file mode 100644 index 0000000..42a9583 --- /dev/null +++ b/patterns/behavioral/mediator/__init__.py @@ -0,0 +1 @@ +"""Mediator: interactions routed through one coordinator.""" diff --git a/patterns/behavioral/mediator/naive.py b/patterns/behavioral/mediator/naive.py new file mode 100644 index 0000000..308f830 --- /dev/null +++ b/patterns/behavioral/mediator/naive.py @@ -0,0 +1,52 @@ +"""The Gang of Four Mediator: colleagues talk only to the dialog.""" + +from __future__ import annotations + + +class Widget: + def __init__(self, mediator: SignupDialog, name: str) -> None: + self.mediator = mediator + self.name = name + + def changed(self) -> None: + self.mediator.widget_changed(self) + + +class TextField(Widget): + def __init__(self, mediator: SignupDialog, name: str) -> None: + super().__init__(mediator, name) + self.text = "" + + def type_text(self, text: str) -> None: + self.text = text + self.changed() + + +class Button(Widget): + def __init__(self, mediator: SignupDialog, name: str) -> None: + super().__init__(mediator, name) + self.enabled = False + + +class SignupDialog: + """All interaction rules live here; widgets know none of them.""" + + def __init__(self) -> None: + self.username = TextField(self, "username") + self.password = TextField(self, "password") + self.submit = Button(self, "submit") + + def widget_changed(self, _widget: Widget) -> None: + self.submit.enabled = bool(self.username.text) and len(self.password.text) >= 8 + + +def main() -> None: + dialog = SignupDialog() + dialog.username.type_text("ada") + print(f"after username: submit enabled = {dialog.submit.enabled}") + dialog.password.type_text("correcthorse") + print(f"after password: submit enabled = {dialog.submit.enabled}") + + +if __name__ == "__main__": + main() diff --git a/patterns/behavioral/mediator/pythonic.py b/patterns/behavioral/mediator/pythonic.py new file mode 100644 index 0000000..614e800 --- /dev/null +++ b/patterns/behavioral/mediator/pythonic.py @@ -0,0 +1,44 @@ +"""The mediator without a Colleague hierarchy. + +Widgets take a ``notify`` callable; the coordinator holds every interaction +rule in one place and the widgets hold none. +""" + +from __future__ import annotations + +from collections.abc import Callable + + +class TextField: + def __init__(self, notify: Callable[[], None]) -> None: + self.text = "" + self._notify = notify + + def type_text(self, text: str) -> None: + self.text = text + self._notify() + + +class SignupForm: + """The mediator: rules in one readable method.""" + + def __init__(self) -> None: + self.username = TextField(self._recheck) + self.password = TextField(self._recheck) + self.submit_enabled = False + + def _recheck(self) -> None: + self.submit_enabled = bool(self.username.text) and len(self.password.text) >= 8 + + +def main() -> None: + form = SignupForm() + form.username.type_text("ada") + form.password.type_text("short") + print(f"weak password: {form.submit_enabled}") + form.password.type_text("correcthorse") + print(f"valid form: {form.submit_enabled}") + + +if __name__ == "__main__": + main() diff --git a/patterns/behavioral/mediator/real_world.py b/patterns/behavioral/mediator/real_world.py new file mode 100644 index 0000000..96d6fbf --- /dev/null +++ b/patterns/behavioral/mediator/real_world.py @@ -0,0 +1,40 @@ +"""``queue.Queue``: the mediator between threads. + +Producer and consumer never reference each other; the queue owns all the +coordination (ordering, blocking, thread safety). +""" + +from __future__ import annotations + +import queue +import threading + + +def pipeline(items: list[str]) -> list[str]: + """Producer and consumer meet only at the queue.""" + channel: queue.Queue[str | None] = queue.Queue() + results: list[str] = [] + + def producer() -> None: + for item in items: + channel.put(item) + channel.put(None) # sentinel: end of stream + + def consumer() -> None: + while (item := channel.get()) is not None: + results.append(item.upper()) + + threads = [threading.Thread(target=producer), threading.Thread(target=consumer)] + for t in threads: + t.start() + for t in threads: + t.join() + return results + + +def main() -> None: + print(pipeline(["a", "b", "c"])) + + +if __name__ == "__main__": + main() diff --git a/patterns/behavioral/mediator/tests/__init__.py b/patterns/behavioral/mediator/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/patterns/behavioral/mediator/tests/test_mediator.py b/patterns/behavioral/mediator/tests/test_mediator.py new file mode 100644 index 0000000..460bd5d --- /dev/null +++ b/patterns/behavioral/mediator/tests/test_mediator.py @@ -0,0 +1,41 @@ +"""Behavioral tests for all three mediator variants.""" + +from patterns.behavioral.mediator import naive, pythonic, real_world + + +class TestNaive: + def test_rules_live_in_the_mediator(self) -> None: + dialog = naive.SignupDialog() + dialog.username.type_text("ada") + assert not dialog.submit.enabled + dialog.password.type_text("correcthorse") + assert dialog.submit.enabled + + def test_weak_password_keeps_submit_disabled(self) -> None: + dialog = naive.SignupDialog() + dialog.username.type_text("ada") + dialog.password.type_text("short") + assert not dialog.submit.enabled + + +class TestPythonic: + def test_form_coordination(self) -> None: + form = pythonic.SignupForm() + form.username.type_text("ada") + form.password.type_text("correcthorse") + assert form.submit_enabled + + def test_widgets_know_no_rules(self) -> None: + # A TextField is reusable with any notify callable -- no form coupling. + pings: list[str] = [] + field = pythonic.TextField(lambda: pings.append("changed")) + field.type_text("x") + assert pings == ["changed"] + + +class TestRealWorld: + def test_queue_mediates_producer_and_consumer(self) -> None: + assert real_world.pipeline(["a", "b", "c"]) == ["A", "B", "C"] + + def test_empty_stream(self) -> None: + assert real_world.pipeline([]) == [] diff --git a/patterns/behavioral/memento/README.md b/patterns/behavioral/memento/README.md new file mode 100644 index 0000000..e1da153 --- /dev/null +++ b/patterns/behavioral/memento/README.md @@ -0,0 +1,43 @@ +--- +id: behavioral/memento +name: Memento +aliases: [snapshot, undo-token] +guide_url: null +problem: "Capture an object's state so it can be restored later, without exposing its internals." +symptoms: ["undo", "checkpoint and rollback", "save game", "restore previous state"] +verdict: use-with-care +caveats: + - "Immutable state makes the pattern nearly free: a snapshot is just keeping the old object. Design the state to be frozen and mementos fall out." + - "Deep-copying big mutable graphs per keystroke is the naive cost; snapshot the smallest state that matters." +stdlib_sightings: [copy.deepcopy, pickle.dumps, dataclasses.replace] +--- + +# Memento + +## Problem + +An editor needs undo; a migration needs rollback. Something outside the +object must hold "how it was" without being allowed to poke around inside. + +## Naive solution + +`naive.py` is the GoF trio: Originator produces opaque mementos, a +Caretaker stacks them, restore hands one back. The memento's fields are +private by convention — Python has no way to truly seal them. + +## Pythonic solution + +Make the state an immutable dataclass and the whole pattern collapses: +a snapshot *is* the current state object, history is a list of them, undo is +popping. `dataclasses.replace` produces each next state. + +## In the wild + +`pickle.dumps` is a memento serializer: the bytes are an opaque snapshot +restorable with `loads`, even in another process. `copy.deepcopy` is the +in-memory equivalent for mutable state you can't freeze. + +## Verdict + +**Use with care** — and tilt the design toward immutable state, where the +pattern costs nothing. diff --git a/patterns/behavioral/memento/__init__.py b/patterns/behavioral/memento/__init__.py new file mode 100644 index 0000000..b957db8 --- /dev/null +++ b/patterns/behavioral/memento/__init__.py @@ -0,0 +1 @@ +"""Memento: capture state for later restore.""" diff --git a/patterns/behavioral/memento/naive.py b/patterns/behavioral/memento/naive.py new file mode 100644 index 0000000..5a7c1a6 --- /dev/null +++ b/patterns/behavioral/memento/naive.py @@ -0,0 +1,57 @@ +"""The Gang of Four Memento: originator, opaque memento, caretaker.""" + +from __future__ import annotations + + +class Memento: + """Opaque by convention: only the originator reads its fields.""" + + def __init__(self, text: str, cursor: int) -> None: + self._text = text + self._cursor = cursor + + +class Editor: + """The originator.""" + + def __init__(self) -> None: + self.text = "" + self.cursor = 0 + + def type_text(self, text: str) -> None: + self.text += text + self.cursor = len(self.text) + + def save(self) -> Memento: + return Memento(self.text, self.cursor) + + def restore(self, memento: Memento) -> None: + self.text = memento._text + self.cursor = memento._cursor + + +class History: + """The caretaker: stores mementos, never looks inside.""" + + def __init__(self) -> None: + self._stack: list[Memento] = [] + + def push(self, memento: Memento) -> None: + self._stack.append(memento) + + def pop(self) -> Memento: + return self._stack.pop() + + +def main() -> None: + editor, history = Editor(), History() + editor.type_text("hello") + history.push(editor.save()) + editor.type_text(" world") + print(f"before undo: {editor.text!r}") + editor.restore(history.pop()) + print(f"after undo: {editor.text!r}") + + +if __name__ == "__main__": + main() diff --git a/patterns/behavioral/memento/pythonic.py b/patterns/behavioral/memento/pythonic.py new file mode 100644 index 0000000..30fd51f --- /dev/null +++ b/patterns/behavioral/memento/pythonic.py @@ -0,0 +1,43 @@ +"""Immutable state makes mementos free. + +The state is a frozen dataclass; a snapshot IS the state object, history is +a list of them, and undo is pop. No Memento class, no copying. +""" + +from __future__ import annotations + +from dataclasses import dataclass, replace + + +@dataclass(frozen=True) +class EditorState: + text: str = "" + cursor: int = 0 + + +class Editor: + def __init__(self) -> None: + self.state = EditorState() + self._history: list[EditorState] = [] + + def type_text(self, text: str) -> None: + self._history.append(self.state) # the old state object is the memento + new_text = self.state.text + text + self.state = replace(self.state, text=new_text, cursor=len(new_text)) + + def undo(self) -> None: + if self._history: + self.state = self._history.pop() + + +def main() -> None: + editor = Editor() + editor.type_text("hello") + editor.type_text(" world") + print(f"before undo: {editor.state.text!r}") + editor.undo() + print(f"after undo: {editor.state.text!r}") + + +if __name__ == "__main__": + main() diff --git a/patterns/behavioral/memento/real_world.py b/patterns/behavioral/memento/real_world.py new file mode 100644 index 0000000..c17c7c0 --- /dev/null +++ b/patterns/behavioral/memento/real_world.py @@ -0,0 +1,39 @@ +"""``pickle``: mementos that survive the process. + +dumps() produces an opaque snapshot; loads() restores an equivalent object +-- checkpoint/rollback for anything picklable. +""" + +from __future__ import annotations + +import pickle +from dataclasses import dataclass, field + + +@dataclass +class Game: + level: int = 1 + inventory: list[str] = field(default_factory=list) + + +def checkpoint(game: Game) -> bytes: + return pickle.dumps(game) + + +def rollback(snapshot: bytes) -> Game: + restored = pickle.loads(snapshot) + assert isinstance(restored, Game) + return restored + + +def main() -> None: + game = Game() + game.inventory.append("sword") + save = checkpoint(game) + game.level, game.inventory = 9, [] + print(f"after disaster: {game}") + print(f"rolled back: {rollback(save)}") + + +if __name__ == "__main__": + main() diff --git a/patterns/behavioral/memento/tests/__init__.py b/patterns/behavioral/memento/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/patterns/behavioral/memento/tests/test_memento.py b/patterns/behavioral/memento/tests/test_memento.py new file mode 100644 index 0000000..99a7406 --- /dev/null +++ b/patterns/behavioral/memento/tests/test_memento.py @@ -0,0 +1,53 @@ +"""Behavioral tests for all three memento variants.""" + +from patterns.behavioral.memento import naive, pythonic, real_world + + +class TestNaive: + def test_save_and_restore(self) -> None: + editor, history = naive.Editor(), naive.History() + editor.type_text("hello") + history.push(editor.save()) + editor.type_text(" world") + editor.restore(history.pop()) + assert (editor.text, editor.cursor) == ("hello", 5) + + +class TestPythonic: + def test_undo_restores_previous_state(self) -> None: + editor = pythonic.Editor() + editor.type_text("hello") + editor.type_text(" world") + editor.undo() + assert editor.state == pythonic.EditorState("hello", 5) + + def test_undo_to_the_beginning_then_noop(self) -> None: + editor = pythonic.Editor() + editor.type_text("x") + editor.undo() + editor.undo() # empty history: must not raise + assert editor.state == pythonic.EditorState() + + def test_snapshots_are_immutable(self) -> None: + import dataclasses + + import pytest + + with pytest.raises(dataclasses.FrozenInstanceError): + pythonic.EditorState().text = "nope" # type: ignore[misc] + + +class TestRealWorld: + def test_pickle_round_trip_restores_state(self) -> None: + game = real_world.Game() + game.inventory.append("sword") + save = real_world.checkpoint(game) + game.level, game.inventory = 9, [] + restored = real_world.rollback(save) + assert (restored.level, restored.inventory) == (1, ["sword"]) + + def test_snapshot_is_independent_of_later_mutation(self) -> None: + game = real_world.Game(inventory=["map"]) + save = real_world.checkpoint(game) + game.inventory.clear() + assert real_world.rollback(save).inventory == ["map"] diff --git a/patterns/behavioral/observer/README.md b/patterns/behavioral/observer/README.md new file mode 100644 index 0000000..90e3e26 --- /dev/null +++ b/patterns/behavioral/observer/README.md @@ -0,0 +1,43 @@ +--- +id: behavioral/observer +name: Observer +aliases: [publish-subscribe, listener, event-handler] +guide_url: null +problem: "Notify interested parties when something changes, without the subject knowing who they are." +symptoms: ["react to changes", "event listeners", "pub/sub", "on_change callbacks", "model updates views"] +verdict: pythonic +caveats: + - "Observers are callables — an Observer ABC with one update() method is a function with extra steps." + - "Decide the failure policy: one raising observer can silence the rest. Notify inside try/except or document that observers must not raise." +stdlib_sightings: [concurrent.futures.Future.add_done_callback, asyncio.Future] +--- + +# Observer + +## Problem + +A model changes and three views must repaint; a download finishes and +logging, metrics, and the UI all care. The subject must broadcast without +compiling a list of friends into itself. + +## Naive solution + +`naive.py` is the GoF form: Subject with attach/detach/notify, an Observer +ABC, concrete observers implementing `update()`. + +## Pythonic solution + +Observers are callables in a list; subscribing is appending. `pythonic.py` +also shows the property-setter variant — assignment to `.temperature` +triggers the callbacks — which is how observation usually hides inside +Python APIs. + +## In the wild + +`concurrent.futures.Future.add_done_callback` is the stdlib observer: +register any callable, it fires when the future resolves — even if it +already has. + +## Verdict + +**Pythonic.** Lists of callables, everywhere, deliberately. diff --git a/patterns/behavioral/observer/__init__.py b/patterns/behavioral/observer/__init__.py new file mode 100644 index 0000000..942e3df --- /dev/null +++ b/patterns/behavioral/observer/__init__.py @@ -0,0 +1 @@ +"""Observer: broadcast changes to subscribed callables.""" diff --git a/patterns/behavioral/observer/naive.py b/patterns/behavioral/observer/naive.py new file mode 100644 index 0000000..d5b28eb --- /dev/null +++ b/patterns/behavioral/observer/naive.py @@ -0,0 +1,60 @@ +"""The Gang of Four Observer: Subject, Observer ABC, update().""" + +from __future__ import annotations + +from abc import ABC, abstractmethod + + +class Observer(ABC): + @abstractmethod + def update(self, temperature: float) -> None: ... + + +class Display(Observer): + def __init__(self) -> None: + self.shown: float | None = None + + def update(self, temperature: float) -> None: + self.shown = temperature + + +class AlarmLog(Observer): + def __init__(self, threshold: float) -> None: + self.threshold = threshold + self.alerts: list[float] = [] + + def update(self, temperature: float) -> None: + if temperature > self.threshold: + self.alerts.append(temperature) + + +class WeatherStation: + """The subject.""" + + def __init__(self) -> None: + self._observers: list[Observer] = [] + self._temperature = 0.0 + + def attach(self, observer: Observer) -> None: + self._observers.append(observer) + + def detach(self, observer: Observer) -> None: + self._observers.remove(observer) + + def set_temperature(self, value: float) -> None: + self._temperature = value + for observer in self._observers: + observer.update(value) + + +def main() -> None: + station, display, alarm = WeatherStation(), Display(), AlarmLog(30.0) + station.attach(display) + station.attach(alarm) + station.set_temperature(21.5) + station.set_temperature(35.0) + print(f"display shows {display.shown}, alarms: {alarm.alerts}") + + +if __name__ == "__main__": + main() diff --git a/patterns/behavioral/observer/pythonic.py b/patterns/behavioral/observer/pythonic.py new file mode 100644 index 0000000..94e7dea --- /dev/null +++ b/patterns/behavioral/observer/pythonic.py @@ -0,0 +1,43 @@ +"""Observers as callables; observation hidden behind a property. + +Subscribing is appending a function. The property setter shows the idiom +most Python APIs actually use: plain assignment triggers the broadcast. +""" + +from __future__ import annotations + +from collections.abc import Callable + +Listener = Callable[[float], None] + + +class WeatherStation: + def __init__(self) -> None: + self.listeners: list[Listener] = [] + self._temperature = 0.0 + + @property + def temperature(self) -> float: + return self._temperature + + @temperature.setter + def temperature(self, value: float) -> None: + self._temperature = value + for listen in list(self.listeners): # copy: observers may unsubscribe + listen(value) + + +def main() -> None: + station = WeatherStation() + seen: list[float] = [] + alerts: list[float] = [] + station.listeners.append(seen.append) + station.listeners.append(lambda t: alerts.append(t) if t > 30 else None) + + station.temperature = 21.5 # plain assignment broadcasts + station.temperature = 35.0 + print(f"seen: {seen}, alerts: {alerts}") + + +if __name__ == "__main__": + main() diff --git a/patterns/behavioral/observer/real_world.py b/patterns/behavioral/observer/real_world.py new file mode 100644 index 0000000..f1e4b75 --- /dev/null +++ b/patterns/behavioral/observer/real_world.py @@ -0,0 +1,35 @@ +"""``Future.add_done_callback``: the stdlib observer. + +Any callable can subscribe to a future's completion; late subscribers to an +already-resolved future fire immediately. +""" + +from __future__ import annotations + +from concurrent.futures import Future + + +def observe_completion() -> list[str]: + events: list[str] = [] + future: Future[int] = Future() + future.add_done_callback(lambda f: events.append(f"log: {f.result()}")) + future.add_done_callback(lambda f: events.append(f"metrics: {f.result()}")) + future.set_result(42) + return events + + +def late_subscription_fires_immediately() -> bool: + future: Future[str] = Future() + future.set_result("done") + fired: list[str] = [] + future.add_done_callback(lambda f: fired.append(f.result())) + return fired == ["done"] + + +def main() -> None: + print(observe_completion()) + print(f"late subscriber still notified: {late_subscription_fires_immediately()}") + + +if __name__ == "__main__": + main() diff --git a/patterns/behavioral/observer/tests/__init__.py b/patterns/behavioral/observer/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/patterns/behavioral/observer/tests/test_observer.py b/patterns/behavioral/observer/tests/test_observer.py new file mode 100644 index 0000000..cfceeb5 --- /dev/null +++ b/patterns/behavioral/observer/tests/test_observer.py @@ -0,0 +1,50 @@ +"""Behavioral tests for all three observer variants.""" + +from patterns.behavioral.observer import naive, pythonic, real_world + + +class TestNaive: + def test_all_attached_observers_are_notified(self) -> None: + station, display, alarm = naive.WeatherStation(), naive.Display(), naive.AlarmLog(30.0) + station.attach(display) + station.attach(alarm) + station.set_temperature(35.0) + assert display.shown == 35.0 + assert alarm.alerts == [35.0] + + def test_detached_observer_stops_receiving(self) -> None: + station, display = naive.WeatherStation(), naive.Display() + station.attach(display) + station.set_temperature(10.0) + station.detach(display) + station.set_temperature(99.0) + assert display.shown == 10.0 + + +class TestPythonic: + def test_assignment_broadcasts_to_callables(self) -> None: + station = pythonic.WeatherStation() + seen: list[float] = [] + station.listeners.append(seen.append) + station.temperature = 21.5 + assert seen == [21.5] + assert station.temperature == 21.5 + + def test_observer_may_unsubscribe_during_notification(self) -> None: + station = pythonic.WeatherStation() + + def once(value: float) -> None: + station.listeners.remove(once) + + station.listeners.append(once) + station.temperature = 1.0 # must not blow up mid-iteration + station.temperature = 2.0 + assert station.listeners == [] + + +class TestRealWorld: + def test_done_callbacks_fire_in_order(self) -> None: + assert real_world.observe_completion() == ["log: 42", "metrics: 42"] + + def test_late_subscription(self) -> None: + assert real_world.late_subscription_fires_immediately() diff --git a/patterns/behavioral/state/README.md b/patterns/behavioral/state/README.md new file mode 100644 index 0000000..4433f2e --- /dev/null +++ b/patterns/behavioral/state/README.md @@ -0,0 +1,43 @@ +--- +id: behavioral/state +name: State +aliases: [state-machine, finite-state-machine] +guide_url: null +problem: "Change an object's behavior when its internal state changes, without an if-forest over a mode flag." +symptoms: ["mode flag with branches everywhere", "state machine", "turnstile/order lifecycle", "behavior depends on current phase"] +verdict: use-with-care +caveats: + - "For small machines, an Enum plus a transition table beats a class per state — the whole machine fits on one screen." + - "A generator is often the best state machine of all: the suspension point is the state, and the interpreter maintains it for you." +stdlib_sightings: [enum.Enum, generators] +--- + +# State + +## Problem + +A turnstile behaves differently locked vs unlocked; an order moves through a +lifecycle. Branching on a mode flag in every method scatters the machine +across the class. + +## Naive solution + +`naive.py` is the GoF form: a class per state, the context delegating to the +current state object, transitions swapping the object. + +## Pythonic solution + +Two idioms in `pythonic.py`: an `Enum` + transition-table machine (data, not +classes — the whole machine visible in one dict), and a **generator** machine +where the paused frame *is* the state. + +## In the wild + +Generators are the language's own state machines — every coroutine and every +`itertools`-style pipeline stage relies on frame suspension keeping state. +`real_world.py` shows a protocol scanner built on exactly that. + +## Verdict + +**Use with care.** Class-per-state pays off only for large machines with +state-specific data; tables and generators cover the rest. diff --git a/patterns/behavioral/state/__init__.py b/patterns/behavioral/state/__init__.py new file mode 100644 index 0000000..74e5afe --- /dev/null +++ b/patterns/behavioral/state/__init__.py @@ -0,0 +1 @@ +"""State: behavior that changes with internal state.""" diff --git a/patterns/behavioral/state/naive.py b/patterns/behavioral/state/naive.py new file mode 100644 index 0000000..b7cd2d0 --- /dev/null +++ b/patterns/behavioral/state/naive.py @@ -0,0 +1,53 @@ +"""The Gang of Four State: a class per state, context delegates.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod + + +class TurnstileState(ABC): + @abstractmethod + def coin(self, turnstile: Turnstile) -> str: ... + + @abstractmethod + def push(self, turnstile: Turnstile) -> str: ... + + +class Locked(TurnstileState): + def coin(self, turnstile: Turnstile) -> str: + turnstile.state = Unlocked() + return "unlocked" + + def push(self, turnstile: Turnstile) -> str: + return "locked: push refused" + + +class Unlocked(TurnstileState): + def coin(self, turnstile: Turnstile) -> str: + return "already unlocked: coin returned" + + def push(self, turnstile: Turnstile) -> str: + turnstile.state = Locked() + return "pushed through, locking" + + +class Turnstile: + def __init__(self) -> None: + self.state: TurnstileState = Locked() + + def coin(self) -> str: + return self.state.coin(self) + + def push(self) -> str: + return self.state.push(self) + + +def main() -> None: + turnstile = Turnstile() + for event in ("push", "coin", "coin", "push", "push"): + result = turnstile.coin() if event == "coin" else turnstile.push() + print(f"{event}: {result}") + + +if __name__ == "__main__": + main() diff --git a/patterns/behavioral/state/pythonic.py b/patterns/behavioral/state/pythonic.py new file mode 100644 index 0000000..d39c4b6 --- /dev/null +++ b/patterns/behavioral/state/pythonic.py @@ -0,0 +1,61 @@ +"""Two pythonic state machines. + +1. Enum + transition table: the machine is data, visible in one dict. +2. A generator: the suspension point is the state; send() drives it. +""" + +from __future__ import annotations + +from collections.abc import Generator +from enum import Enum, auto + + +class State(Enum): + LOCKED = auto() + UNLOCKED = auto() + + +#: (state, event) -> (next_state, output) +TRANSITIONS: dict[tuple[State, str], tuple[State, str]] = { + (State.LOCKED, "coin"): (State.UNLOCKED, "unlocked"), + (State.LOCKED, "push"): (State.LOCKED, "locked: push refused"), + (State.UNLOCKED, "coin"): (State.UNLOCKED, "already unlocked: coin returned"), + (State.UNLOCKED, "push"): (State.LOCKED, "pushed through, locking"), +} + + +class Turnstile: + def __init__(self) -> None: + self.state = State.LOCKED + + def handle(self, event: str) -> str: + self.state, output = TRANSITIONS[(self.state, event)] + return output + + +def turnstile_machine() -> Generator[str, str, None]: + """The generator form: 'where the code is paused' is the state.""" + output = "ready" + while True: + event = yield output + if event == "coin": + output = "unlocked" + event = yield output # ---- the UNLOCKED state lives here ---- + while event == "coin": + event = yield "already unlocked: coin returned" + output = "pushed through, locking" + else: + output = "locked: push refused" + + +def main() -> None: + machine = Turnstile() + print([machine.handle(e) for e in ("push", "coin", "push")]) + + gen = turnstile_machine() + next(gen) + print([gen.send(e) for e in ("push", "coin", "push")]) + + +if __name__ == "__main__": + main() diff --git a/patterns/behavioral/state/real_world.py b/patterns/behavioral/state/real_world.py new file mode 100644 index 0000000..d34460f --- /dev/null +++ b/patterns/behavioral/state/real_world.py @@ -0,0 +1,31 @@ +"""Generators as protocol scanners: frame suspension holds the state. + +A scanner for BEGIN/END blocks -- no state flag anywhere; being inside the +``while`` loop IS the "in a block" state. +""" + +from __future__ import annotations + +from collections.abc import Iterable, Iterator + + +def blocks(lines: Iterable[str]) -> Iterator[list[str]]: + """Yield the lines between each BEGIN/END pair.""" + it = iter(lines) + for line in it: + if line == "BEGIN": + collected: list[str] = [] + for inner in it: # <- the machine is now in the "collecting" state + if inner == "END": + break + collected.append(inner) + yield collected + + +def main() -> None: + text = ["noise", "BEGIN", "a", "b", "END", "more noise", "BEGIN", "c", "END"] + print(list(blocks(text))) + + +if __name__ == "__main__": + main() diff --git a/patterns/behavioral/state/tests/__init__.py b/patterns/behavioral/state/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/patterns/behavioral/state/tests/test_state.py b/patterns/behavioral/state/tests/test_state.py new file mode 100644 index 0000000..fead77e --- /dev/null +++ b/patterns/behavioral/state/tests/test_state.py @@ -0,0 +1,43 @@ +"""Behavioral tests for all three state variants.""" + +from patterns.behavioral.state import naive, pythonic, real_world + + +class TestNaive: + def test_full_cycle(self) -> None: + turnstile = naive.Turnstile() + assert turnstile.push() == "locked: push refused" + assert turnstile.coin() == "unlocked" + assert turnstile.coin() == "already unlocked: coin returned" + assert turnstile.push() == "pushed through, locking" + assert turnstile.push() == "locked: push refused" + + +class TestPythonic: + def test_table_machine_matches_naive(self) -> None: + machine = pythonic.Turnstile() + outputs = [machine.handle(e) for e in ("push", "coin", "coin", "push", "push")] + assert outputs == [ + "locked: push refused", + "unlocked", + "already unlocked: coin returned", + "pushed through, locking", + "locked: push refused", + ] + + def test_generator_machine(self) -> None: + gen = pythonic.turnstile_machine() + assert next(gen) == "ready" + assert gen.send("push") == "locked: push refused" + assert gen.send("coin") == "unlocked" + assert gen.send("coin") == "already unlocked: coin returned" + assert gen.send("push") == "pushed through, locking" + + +class TestRealWorld: + def test_scanner_extracts_blocks(self) -> None: + text = ["x", "BEGIN", "a", "b", "END", "y", "BEGIN", "c", "END"] + assert list(real_world.blocks(text)) == [["a", "b"], ["c"]] + + def test_unterminated_block_yields_partial(self) -> None: + assert list(real_world.blocks(["BEGIN", "a"])) == [["a"]] diff --git a/patterns/behavioral/strategy/README.md b/patterns/behavioral/strategy/README.md new file mode 100644 index 0000000..6e163a6 --- /dev/null +++ b/patterns/behavioral/strategy/README.md @@ -0,0 +1,45 @@ +--- +id: behavioral/strategy +name: Strategy +aliases: [policy] +guide_url: null +problem: "Make an algorithm interchangeable at runtime without the caller knowing which variant it got." +symptoms: ["swap algorithm at runtime", "pricing rules", "pluggable policy", "if/elif chain choosing behavior"] +verdict: prefer-alternative +caveats: + - "In Python a strategy is just a function passed as an argument — the class-per-algorithm hierarchy is Java's workaround for lacking first-class functions." + - "Reach for the class form only when a strategy carries its own state or several related methods." +stdlib_sightings: [sorted, list.sort, functools.cmp_to_key] +--- + +# Strategy + +## Problem + +A checkout applies one of several promotion rules; a sorter orders by one of +several keys. The algorithm must vary independently of the code that uses it. + +## Naive solution + +`naive.py` is the book's shape: a `Promotion` interface, one class per +algorithm, and a context object holding the chosen strategy. (Fluent Python +fans will recognize the running example.) + +## Pythonic solution + +Functions *are* strategies. `pythonic.py` passes plain functions, and adds the +decorator-registry twist: `@promotion` collects every rule into a list so +`best_promo` can try them all — new rules register themselves by existing. +This also fixes the legacy repo's bug, where a misplaced `return` inside the +loop made `bulk_item` score only the first cart line. + +## In the wild + +`sorted(data, key=...)` is the Strategy pattern as an argument: the key +function is an interchangeable ordering algorithm, and `functools.cmp_to_key` +adapts old-style comparator strategies into key strategies. + +## Verdict + +**Prefer an alternative** — the alternative being a plain function. The +pattern's *intent* is everywhere in Python; the class ceremony almost never is. diff --git a/patterns/behavioral/strategy/__init__.py b/patterns/behavioral/strategy/__init__.py new file mode 100644 index 0000000..5989b89 --- /dev/null +++ b/patterns/behavioral/strategy/__init__.py @@ -0,0 +1 @@ +"""Strategy: interchangeable algorithms. Verdict: pass a function.""" diff --git a/patterns/behavioral/strategy/naive.py b/patterns/behavioral/strategy/naive.py new file mode 100644 index 0000000..d1b89e1 --- /dev/null +++ b/patterns/behavioral/strategy/naive.py @@ -0,0 +1,67 @@ +"""The Gang of Four Strategy: one class per algorithm, a context that holds one. + +An order applies whichever promotion strategy it was configured with. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass + + +@dataclass(frozen=True) +class LineItem: + product: str + quantity: int + price: float + + def total(self) -> float: + return self.quantity * self.price + + +class Promotion(ABC): + """The strategy interface.""" + + @abstractmethod + def discount(self, order: Order) -> float: ... + + +class Order: + """The context: holds cart plus one interchangeable strategy.""" + + def __init__(self, cart: list[LineItem], promotion: Promotion | None = None) -> None: + self.cart = cart + self.promotion = promotion + + def total(self) -> float: + return sum(item.total() for item in self.cart) + + def due(self) -> float: + discount = self.promotion.discount(self) if self.promotion else 0.0 + return self.total() - discount + + +class BulkItemPromo(Promotion): + """10% off each line item of 20+ units.""" + + def discount(self, order: Order) -> float: + return sum(item.total() * 0.1 for item in order.cart if item.quantity >= 20) + + +class LargeOrderPromo(Promotion): + """7% off orders with 10+ distinct products.""" + + def discount(self, order: Order) -> float: + if len({item.product for item in order.cart}) >= 10: + return order.total() * 0.07 + return 0.0 + + +def main() -> None: + cart = [LineItem("banana", 30, 0.5), LineItem("apple", 10, 1.5)] + print(f"bulk promo due: {Order(cart, BulkItemPromo()).due():.2f}") + print(f"no promo due: {Order(cart).due():.2f}") + + +if __name__ == "__main__": + main() diff --git a/patterns/behavioral/strategy/pythonic.py b/patterns/behavioral/strategy/pythonic.py new file mode 100644 index 0000000..05df98f --- /dev/null +++ b/patterns/behavioral/strategy/pythonic.py @@ -0,0 +1,74 @@ +"""Strategies as plain functions, plus the decorator registry. + +``@promotion`` appends each rule to a module-level list, so ``best_promo`` +always considers every registered rule -- adding a strategy is just defining +one. +""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + + +@dataclass(frozen=True) +class LineItem: + product: str + quantity: int + price: float + + def total(self) -> float: + return self.quantity * self.price + + +@dataclass(frozen=True) +class Order: + cart: tuple[LineItem, ...] + + def total(self) -> float: + return sum(item.total() for item in self.cart) + + +PromoFunc = Callable[[Order], float] + +promos: list[PromoFunc] = [] + + +def promotion(func: PromoFunc) -> PromoFunc: + """Register a promotion strategy by decorating it.""" + promos.append(func) + return func + + +@promotion +def bulk_item(order: Order) -> float: + """10% off each line item of 20+ units.""" + return sum(item.total() * 0.1 for item in order.cart if item.quantity >= 20) + + +@promotion +def large_order(order: Order) -> float: + """7% off orders with 10+ distinct products.""" + if len({item.product for item in order.cart}) >= 10: + return order.total() * 0.07 + return 0.0 + + +def best_promo(order: Order) -> float: + """Try every registered strategy; keep the best discount.""" + return max(promo(order) for promo in promos) + + +def due(order: Order, promo: PromoFunc | None = None) -> float: + """A strategy is just an argument.""" + return order.total() - (promo(order) if promo else 0.0) + + +def main() -> None: + order = Order((LineItem("banana", 30, 0.5), LineItem("apple", 10, 1.5))) + print(f"bulk_item due: {due(order, bulk_item):.2f}") + print(f"best promo: {best_promo(order):.2f}") + + +if __name__ == "__main__": + main() diff --git a/patterns/behavioral/strategy/real_world.py b/patterns/behavioral/strategy/real_world.py new file mode 100644 index 0000000..6f6cc58 --- /dev/null +++ b/patterns/behavioral/strategy/real_world.py @@ -0,0 +1,30 @@ +"""``sorted(key=...)``: the Strategy pattern as an argument. + +The key function is an interchangeable ordering algorithm; swapping +strategies is passing a different callable. +""" + +from __future__ import annotations + + +def by_length(words: list[str]) -> list[str]: + return sorted(words, key=len) + + +def by_last_letter(words: list[str]) -> list[str]: + return sorted(words, key=lambda w: w[-1]) + + +def case_insensitive(words: list[str]) -> list[str]: + return sorted(words, key=str.casefold) + + +def main() -> None: + words = ["banana", "Fig", "cherry"] + print(f"by length: {by_length(words)}") + print(f"by last letter: {by_last_letter(words)}") + print(f"case-insensitive: {case_insensitive(words)}") + + +if __name__ == "__main__": + main() diff --git a/patterns/behavioral/strategy/tests/__init__.py b/patterns/behavioral/strategy/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/patterns/behavioral/strategy/tests/test_strategy.py b/patterns/behavioral/strategy/tests/test_strategy.py new file mode 100644 index 0000000..f8ffa53 --- /dev/null +++ b/patterns/behavioral/strategy/tests/test_strategy.py @@ -0,0 +1,48 @@ +"""Behavioral tests for all three strategy variants.""" + +from patterns.behavioral.strategy import naive, pythonic, real_world + + +def _cart() -> list[naive.LineItem]: + return [naive.LineItem("banana", 30, 0.5), naive.LineItem("apple", 10, 1.5)] + + +class TestNaive: + def test_bulk_promo_discounts_every_qualifying_line(self) -> None: + # 30 bananas -> 15.00 total -> 1.50 off; apples don't qualify. + order = naive.Order(_cart(), naive.BulkItemPromo()) + assert order.due() == 30.0 - 1.5 + + def test_swapping_strategy_changes_result(self) -> None: + cart = _cart() + assert naive.Order(cart, naive.LargeOrderPromo()).due() == 30.0 # <10 products + assert naive.Order(cart).due() == 30.0 + + def test_regression_all_lines_counted(self) -> None: + # The legacy repo returned inside the loop, scoring only the first line. + cart = [naive.LineItem("a", 20, 1.0), naive.LineItem("b", 20, 2.0)] + assert naive.BulkItemPromo().discount(naive.Order(cart)) == 2.0 + 4.0 + + +class TestPythonic: + def _order(self) -> pythonic.Order: + return pythonic.Order( + (pythonic.LineItem("banana", 30, 0.5), pythonic.LineItem("apple", 10, 1.5)) + ) + + def test_function_is_the_strategy(self) -> None: + assert pythonic.due(self._order(), pythonic.bulk_item) == 30.0 - 1.5 + + def test_decorator_registered_all_strategies(self) -> None: + assert pythonic.bulk_item in pythonic.promos + assert pythonic.large_order in pythonic.promos + + def test_best_promo_picks_the_maximum(self) -> None: + assert pythonic.best_promo(self._order()) == 1.5 + + +class TestRealWorld: + def test_key_functions_are_swappable_strategies(self) -> None: + words = ["banana", "Fig", "cherry"] + assert real_world.by_length(words) == ["Fig", "banana", "cherry"] + assert real_world.case_insensitive(words) == ["banana", "cherry", "Fig"] diff --git a/patterns/behavioral/template_method/README.md b/patterns/behavioral/template_method/README.md new file mode 100644 index 0000000..bdbbd55 --- /dev/null +++ b/patterns/behavioral/template_method/README.md @@ -0,0 +1,42 @@ +--- +id: behavioral/template_method +name: Template Method +aliases: [hook-methods, skeleton-algorithm] +guide_url: null +problem: "Fix an algorithm's skeleton while letting callers vary individual steps." +symptoms: ["same steps, different details", "framework calls your overrides", "setUp/tearDown-style hooks"] +verdict: prefer-alternative +caveats: + - "Passing step callables as keyword arguments with defaults does the same job without inheritance, and composes better." + - "Subclass hooks make sense at framework boundaries (unittest, socketserver) where the framework owns the loop and you own the steps." +stdlib_sightings: [json.JSONEncoder.default, unittest.TestCase.setUp, socketserver.BaseRequestHandler.handle] +--- + +# Template Method + +## Problem + +Report generation always goes fetch → format → deliver, but each report +formats differently. The skeleton must stay fixed while steps vary. + +## Naive solution + +`naive.py` is the GoF form: the base class owns the skeleton as a concrete +method; subclasses override the abstract hook steps. + +## Pythonic solution + +The skeleton is a function; the varying steps are callable parameters with +defaults. No subclass per variation, and steps combine freely at call time. + +## In the wild + +`json.JSONEncoder` runs the encoding skeleton and calls your `default()` +hook for objects it can't serialize — a template method you've probably +already overridden. `unittest.TestCase.setUp`/`tearDown` and +`socketserver.BaseRequestHandler.handle` are the same shape. + +## Verdict + +**Prefer an alternative** in your own code — pass the steps. Recognize and +use the subclass form at framework boundaries. diff --git a/patterns/behavioral/template_method/__init__.py b/patterns/behavioral/template_method/__init__.py new file mode 100644 index 0000000..e928fcb --- /dev/null +++ b/patterns/behavioral/template_method/__init__.py @@ -0,0 +1 @@ +"""Template Method: fixed skeleton, variable steps. Verdict: pass the steps.""" diff --git a/patterns/behavioral/template_method/naive.py b/patterns/behavioral/template_method/naive.py new file mode 100644 index 0000000..1863347 --- /dev/null +++ b/patterns/behavioral/template_method/naive.py @@ -0,0 +1,44 @@ +"""The Gang of Four Template Method: skeleton in the base, hooks in subclasses.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod + + +class Report(ABC): + def render(self, data: dict[str, int]) -> str: + """The template method: the skeleton nobody overrides.""" + rows = self.format_rows(data) + return f"{self.header()}\n{rows}" + + @abstractmethod + def header(self) -> str: ... + + @abstractmethod + def format_rows(self, data: dict[str, int]) -> str: ... + + +class TextReport(Report): + def header(self) -> str: + return "REPORT" + + def format_rows(self, data: dict[str, int]) -> str: + return "\n".join(f"{key}: {value}" for key, value in data.items()) + + +class CsvReport(Report): + def header(self) -> str: + return "key,value" + + def format_rows(self, data: dict[str, int]) -> str: + return "\n".join(f"{key},{value}" for key, value in data.items()) + + +def main() -> None: + data = {"apples": 3, "pears": 5} + print(TextReport().render(data)) + print(CsvReport().render(data)) + + +if __name__ == "__main__": + main() diff --git a/patterns/behavioral/template_method/pythonic.py b/patterns/behavioral/template_method/pythonic.py new file mode 100644 index 0000000..8a56ed6 --- /dev/null +++ b/patterns/behavioral/template_method/pythonic.py @@ -0,0 +1,33 @@ +"""The skeleton as a function, the steps as callable parameters.""" + +from __future__ import annotations + +from collections.abc import Callable + + +def plain_rows(data: dict[str, int]) -> str: + return "\n".join(f"{key}: {value}" for key, value in data.items()) + + +def csv_rows(data: dict[str, int]) -> str: + return "\n".join(f"{key},{value}" for key, value in data.items()) + + +def render( + data: dict[str, int], + *, + header: str = "REPORT", + format_rows: Callable[[dict[str, int]], str] = plain_rows, +) -> str: + """The whole template method: skeleton fixed, steps injected.""" + return f"{header}\n{format_rows(data)}" + + +def main() -> None: + data = {"apples": 3, "pears": 5} + print(render(data)) + print(render(data, header="key,value", format_rows=csv_rows)) + + +if __name__ == "__main__": + main() diff --git a/patterns/behavioral/template_method/real_world.py b/patterns/behavioral/template_method/real_world.py new file mode 100644 index 0000000..98a98ad --- /dev/null +++ b/patterns/behavioral/template_method/real_world.py @@ -0,0 +1,32 @@ +"""``json.JSONEncoder``: a template method you override in the wild. + +encode() owns the skeleton; the default() hook is called exactly at the +step the skeleton cannot handle itself. +""" + +from __future__ import annotations + +import json +from datetime import date +from typing import Any + + +class DateAwareEncoder(json.JSONEncoder): + """Override the one hook; inherit the whole encoding skeleton.""" + + def default(self, o: Any) -> Any: + if isinstance(o, date): + return o.isoformat() + return super().default(o) + + +def dump_event(event: dict[str, object]) -> str: + return json.dumps(event, cls=DateAwareEncoder, sort_keys=True) + + +def main() -> None: + print(dump_event({"name": "launch", "when": date(2026, 8, 26)})) + + +if __name__ == "__main__": + main() diff --git a/patterns/behavioral/template_method/tests/__init__.py b/patterns/behavioral/template_method/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/patterns/behavioral/template_method/tests/test_template_method.py b/patterns/behavioral/template_method/tests/test_template_method.py new file mode 100644 index 0000000..60db5f2 --- /dev/null +++ b/patterns/behavioral/template_method/tests/test_template_method.py @@ -0,0 +1,38 @@ +"""Behavioral tests for all three template-method variants.""" + +import json +from datetime import date + +import pytest + +from patterns.behavioral.template_method import naive, pythonic, real_world + + +class TestNaive: + def test_subclasses_vary_steps_not_skeleton(self) -> None: + data = {"apples": 3} + assert naive.TextReport().render(data) == "REPORT\napples: 3" + assert naive.CsvReport().render(data) == "key,value\napples,3" + + +class TestPythonic: + def test_default_steps(self) -> None: + assert pythonic.render({"apples": 3}) == "REPORT\napples: 3" + + def test_injected_steps(self) -> None: + out = pythonic.render({"apples": 3}, header="key,value", format_rows=pythonic.csv_rows) + assert out == "key,value\napples,3" + + def test_steps_compose_at_call_time(self) -> None: + loud = pythonic.render({"a": 1}, format_rows=lambda d: pythonic.plain_rows(d).upper()) + assert loud == "REPORT\nA: 1" + + +class TestRealWorld: + def test_hook_handles_dates_inside_the_inherited_skeleton(self) -> None: + out = real_world.dump_event({"name": "launch", "when": date(2026, 8, 26)}) + assert json.loads(out) == {"name": "launch", "when": "2026-08-26"} + + def test_unknown_types_still_raise_via_super(self) -> None: + with pytest.raises(TypeError): + real_world.dump_event({"bad": object()}) diff --git a/patterns/behavioral/visitor/README.md b/patterns/behavioral/visitor/README.md new file mode 100644 index 0000000..2dd74ef --- /dev/null +++ b/patterns/behavioral/visitor/README.md @@ -0,0 +1,42 @@ +--- +id: behavioral/visitor +name: Visitor +aliases: [double-dispatch] +guide_url: null +problem: "Run a new operation over every node of an object structure without adding a method to every node class." +symptoms: ["walk an AST", "operation per node type", "double dispatch", "add behavior across a class family"] +verdict: prefer-alternative +caveats: + - "functools.singledispatch dispatches on type without touching the node classes — the visitor with the accept() plumbing deleted." + - "The subclass form survives where the stdlib hands it to you: ast.NodeVisitor is the right tool for walking Python source." +stdlib_sightings: [functools.singledispatch, ast.NodeVisitor] +--- + +# Visitor + +## Problem + +An expression tree (or document tree, or AST) needs new operations — render, +optimize, measure — and you'd rather not add a method to every node class for +every new operation. + +## Naive solution + +`naive.py` is the full GoF double dispatch: every node implements +`accept(visitor)`, every visitor implements one `visit_X` per node type. + +## Pythonic solution + +`functools.singledispatch` dispatches on the node's type directly — the +`accept()` plumbing evaporates, node classes stay untouched, and a new +operation is one decorated function per node type. + +## In the wild + +`ast.NodeVisitor` walks Python source with a `visit_ClassName` method per +node — the Visitor pattern as a supported stdlib API. + +## Verdict + +**Prefer an alternative:** `singledispatch`. Use `ast.NodeVisitor` when the +tree is Python itself. diff --git a/patterns/behavioral/visitor/__init__.py b/patterns/behavioral/visitor/__init__.py new file mode 100644 index 0000000..2e01634 --- /dev/null +++ b/patterns/behavioral/visitor/__init__.py @@ -0,0 +1 @@ +"""Visitor: new operations over a node family. Verdict: singledispatch.""" diff --git a/patterns/behavioral/visitor/naive.py b/patterns/behavioral/visitor/naive.py new file mode 100644 index 0000000..9d79e5d --- /dev/null +++ b/patterns/behavioral/visitor/naive.py @@ -0,0 +1,51 @@ +"""The Gang of Four Visitor: accept() on every node, visit_X on every visitor.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod + + +class Node(ABC): + @abstractmethod + def accept(self, visitor: Visitor) -> str: ... + + +class Number(Node): + def __init__(self, value: int) -> None: + self.value = value + + def accept(self, visitor: Visitor) -> str: + return visitor.visit_number(self) + + +class Add(Node): + def __init__(self, left: Node, right: Node) -> None: + self.left, self.right = left, right + + def accept(self, visitor: Visitor) -> str: + return visitor.visit_add(self) + + +class Visitor(ABC): + @abstractmethod + def visit_number(self, node: Number) -> str: ... + + @abstractmethod + def visit_add(self, node: Add) -> str: ... + + +class Renderer(Visitor): + def visit_number(self, node: Number) -> str: + return str(node.value) + + def visit_add(self, node: Add) -> str: + return f"({node.left.accept(self)} + {node.right.accept(self)})" + + +def main() -> None: + tree = Add(Number(1), Add(Number(2), Number(3))) + print(tree.accept(Renderer())) + + +if __name__ == "__main__": + main() diff --git a/patterns/behavioral/visitor/pythonic.py b/patterns/behavioral/visitor/pythonic.py new file mode 100644 index 0000000..3cf3e3b --- /dev/null +++ b/patterns/behavioral/visitor/pythonic.py @@ -0,0 +1,60 @@ +"""The visitor with the plumbing deleted: functools.singledispatch. + +Node classes are plain dataclasses with no accept(); each operation is a +dispatch family of small functions. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from functools import singledispatch + + +@dataclass(frozen=True) +class Number: + value: int + + +@dataclass(frozen=True) +class Add: + left: Number | Add + right: Number | Add + + +@singledispatch +def render(node: object) -> str: + raise TypeError(f"no renderer for {type(node).__name__}") + + +@render.register +def _(node: Number) -> str: + return str(node.value) + + +@render.register +def _(node: Add) -> str: + return f"({render(node.left)} + {render(node.right)})" + + +@singledispatch +def evaluate(node: object) -> int: + raise TypeError(f"no evaluator for {type(node).__name__}") + + +@evaluate.register +def _(node: Number) -> int: + return node.value + + +@evaluate.register +def _(node: Add) -> int: + return evaluate(node.left) + evaluate(node.right) + + +def main() -> None: + tree = Add(Number(1), Add(Number(2), Number(3))) + print(f"{render(tree)} = {evaluate(tree)}") + + +if __name__ == "__main__": + main() diff --git a/patterns/behavioral/visitor/real_world.py b/patterns/behavioral/visitor/real_world.py new file mode 100644 index 0000000..5265bcf --- /dev/null +++ b/patterns/behavioral/visitor/real_world.py @@ -0,0 +1,38 @@ +"""``ast.NodeVisitor``: the Visitor pattern as a stdlib API. + +Count the function definitions and calls in any piece of Python source. +""" + +from __future__ import annotations + +import ast + + +class Census(ast.NodeVisitor): + def __init__(self) -> None: + self.functions: list[str] = [] + self.calls = 0 + + def visit_FunctionDef(self, node: ast.FunctionDef) -> None: + self.functions.append(node.name) + self.generic_visit(node) + + def visit_Call(self, node: ast.Call) -> None: + self.calls += 1 + self.generic_visit(node) + + +def census_of(source: str) -> Census: + census = Census() + census.visit(ast.parse(source)) + return census + + +def main() -> None: + source = "def greet():\n print('hi')\n\ndef leave():\n print(exit())\n" + census = census_of(source) + print(f"functions: {census.functions}, calls: {census.calls}") + + +if __name__ == "__main__": + main() diff --git a/patterns/behavioral/visitor/tests/__init__.py b/patterns/behavioral/visitor/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/patterns/behavioral/visitor/tests/test_visitor.py b/patterns/behavioral/visitor/tests/test_visitor.py new file mode 100644 index 0000000..2c4918c --- /dev/null +++ b/patterns/behavioral/visitor/tests/test_visitor.py @@ -0,0 +1,32 @@ +"""Behavioral tests for all three visitor variants.""" + +import pytest + +from patterns.behavioral.visitor import naive, pythonic, real_world + + +class TestNaive: + def test_double_dispatch_renders_the_tree(self) -> None: + tree = naive.Add(naive.Number(1), naive.Add(naive.Number(2), naive.Number(3))) + assert tree.accept(naive.Renderer()) == "(1 + (2 + 3))" + + +class TestPythonic: + def test_two_operations_no_node_changes(self) -> None: + tree = pythonic.Add( + pythonic.Number(1), pythonic.Add(pythonic.Number(2), pythonic.Number(3)) + ) + assert pythonic.render(tree) == "(1 + (2 + 3))" + assert pythonic.evaluate(tree) == 6 + + def test_unknown_node_type_fails_loudly(self) -> None: + with pytest.raises(TypeError, match="no renderer"): + pythonic.render("not a node") + + +class TestRealWorld: + def test_ast_census(self) -> None: + source = "def greet():\n print('hi')\n\ndef leave():\n print(exit())\n" + census = real_world.census_of(source) + assert census.functions == ["greet", "leave"] + assert census.calls == 3 diff --git a/patterns/creational/__init__.py b/patterns/creational/__init__.py new file mode 100644 index 0000000..d423003 --- /dev/null +++ b/patterns/creational/__init__.py @@ -0,0 +1 @@ +"""creational patterns.""" diff --git a/patterns/creational/abstract_factory/README.md b/patterns/creational/abstract_factory/README.md new file mode 100644 index 0000000..bf635fe --- /dev/null +++ b/patterns/creational/abstract_factory/README.md @@ -0,0 +1,45 @@ +--- +id: creational/abstract_factory +name: Abstract Factory +aliases: [kit, factory-of-factories] +guide_url: https://python-patterns.guide/gang-of-four/abstract-factory/ +problem: "Let code build families of related objects without naming their concrete classes." +symptoms: ["swap whole family of implementations", "test doubles for created objects", "library must not hardcode which class it builds"] +verdict: prefer-alternative +caveats: + - "The pattern exists because 1990s languages could not pass classes or functions as values — Python can, so a factory is usually just a callable argument." + - "Reach for a factory *object* only when the family of factories is large enough that bundling them beats passing them individually." +stdlib_sightings: [json.load parse_float, decimal.Decimal, unittest.mock] +--- + +# Abstract Factory + +## Problem + +A JSON parser must build numbers, but which number type — `float`? +`Decimal`? The parsing code shouldn't hardcode the class, and callers should +be able to swap the whole family of built objects (numbers, lists, maps) at +once. + +## Naive solution + +`naive.py` is the book's shape: an abstract factory interface, one concrete +factory per family, and client code programmed against the interface. + +## Pythonic solution + +Classes and functions are first-class, so the guide's advice is: accept +*callables*. `pythonic.py` passes `Decimal` itself as the number factory; the +"complete" factory bundling several builders is just a small dataclass of +callables — no abstract base required. + +## In the wild + +`json.load(fp, parse_float=Decimal)` is the exact pattern: the stdlib parser +accepts factory callables for every family member it builds. `unittest.mock` +is a factory for stand-ins of anything. + +## Verdict + +**Prefer an alternative:** pass callables. Bundle them in an object only when +the family is genuinely large. diff --git a/patterns/creational/abstract_factory/__init__.py b/patterns/creational/abstract_factory/__init__.py new file mode 100644 index 0000000..afc6b8d --- /dev/null +++ b/patterns/creational/abstract_factory/__init__.py @@ -0,0 +1 @@ +"""Abstract Factory: build families of objects. Verdict: pass callables.""" diff --git a/patterns/creational/abstract_factory/naive.py b/patterns/creational/abstract_factory/naive.py new file mode 100644 index 0000000..c1fddb7 --- /dev/null +++ b/patterns/creational/abstract_factory/naive.py @@ -0,0 +1,42 @@ +"""The Gang of Four Abstract Factory, translated literally. + +An abstract factory interface, one concrete factory per "family", and a +client that never names a concrete class. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from decimal import Decimal + + +class NumberFactory(ABC): + """The abstract factory: builds the number family.""" + + @abstractmethod + def build_number(self, text: str) -> object: ... + + +class FloatFactory(NumberFactory): + def build_number(self, text: str) -> object: + return float(text) + + +class DecimalFactory(NumberFactory): + def build_number(self, text: str) -> object: + return Decimal(text) + + +def parse_numbers(texts: list[str], factory: NumberFactory) -> list[object]: + """The client: programmed against the interface only.""" + return [factory.build_number(t) for t in texts] + + +def main() -> None: + texts = ["1.1", "2.2"] + print(parse_numbers(texts, FloatFactory())) + print(parse_numbers(texts, DecimalFactory())) + + +if __name__ == "__main__": + main() diff --git a/patterns/creational/abstract_factory/pythonic.py b/patterns/creational/abstract_factory/pythonic.py new file mode 100644 index 0000000..85e34bc --- /dev/null +++ b/patterns/creational/abstract_factory/pythonic.py @@ -0,0 +1,42 @@ +"""What to write instead: factories are callables, families are dataclasses. + +``Decimal`` itself is already a factory -- pass it. When several builders +travel together, bundle them in a plain dataclass; swapping the family is +constructing a different bundle. +""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from decimal import Decimal + + +def parse_numbers(texts: list[str], build: Callable[[str], object] = float) -> list[object]: + """A factory is just an argument with a sensible default.""" + return [build(t) for t in texts] + + +@dataclass(frozen=True) +class Family: + """The 'complete' abstract factory: a bundle of callables.""" + + number: Callable[[str], object] + sequence: Callable[[list[object]], object] + + +PYTHON_FAMILY = Family(number=float, sequence=list) +EXACT_FAMILY = Family(number=Decimal, sequence=tuple) + + +def parse(texts: list[str], family: Family = PYTHON_FAMILY) -> object: + return family.sequence([family.number(t) for t in texts]) + + +def main() -> None: + print(parse_numbers(["1.1"], Decimal)) + print(parse(["1.1", "2.2"], EXACT_FAMILY)) + + +if __name__ == "__main__": + main() diff --git a/patterns/creational/abstract_factory/real_world.py b/patterns/creational/abstract_factory/real_world.py new file mode 100644 index 0000000..6d0c1c8 --- /dev/null +++ b/patterns/creational/abstract_factory/real_world.py @@ -0,0 +1,28 @@ +"""The stdlib's abstract factory: ``json.loads`` parse hooks. + +The parser builds every float through the callable you hand it -- swap +``float`` for ``Decimal`` and the whole document changes family. +""" + +from __future__ import annotations + +import json +from decimal import Decimal + + +def load_exact(document: str) -> object: + """Parse JSON with exact decimal arithmetic instead of binary floats.""" + return json.loads(document, parse_float=Decimal) + + +def main() -> None: + doc = '{"price": 0.1, "qty": 3}' + default = json.loads(doc) + exact = load_exact(doc) + assert isinstance(exact, dict) and isinstance(default, dict) + print(f"float family: {default['price']!r}") + print(f"Decimal family: {exact['price']!r}") + + +if __name__ == "__main__": + main() diff --git a/patterns/creational/abstract_factory/tests/__init__.py b/patterns/creational/abstract_factory/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/patterns/creational/abstract_factory/tests/test_abstract_factory.py b/patterns/creational/abstract_factory/tests/test_abstract_factory.py new file mode 100644 index 0000000..e3e869e --- /dev/null +++ b/patterns/creational/abstract_factory/tests/test_abstract_factory.py @@ -0,0 +1,34 @@ +"""Behavioral tests for all three abstract-factory variants.""" + +from decimal import Decimal + +from patterns.creational.abstract_factory import naive, pythonic, real_world + + +class TestNaive: + def test_client_builds_through_the_interface(self) -> None: + floats = naive.parse_numbers(["1.5"], naive.FloatFactory()) + exacts = naive.parse_numbers(["1.5"], naive.DecimalFactory()) + assert floats == [1.5] and isinstance(floats[0], float) + assert exacts == [Decimal("1.5")] and isinstance(exacts[0], Decimal) + + +class TestPythonic: + def test_callable_is_the_factory(self) -> None: + assert pythonic.parse_numbers(["2.5"], Decimal) == [Decimal("2.5")] + + def test_default_factory(self) -> None: + assert pythonic.parse_numbers(["2.5"]) == [2.5] + + def test_family_bundle_swaps_every_member(self) -> None: + result = pythonic.parse(["1.1"], pythonic.EXACT_FAMILY) + assert result == (Decimal("1.1"),) + assert pythonic.parse(["1.1"]) == [1.1] + + +class TestRealWorld: + def test_parse_float_hook_changes_the_family(self) -> None: + doc = real_world.load_exact('{"x": 0.1}') + assert isinstance(doc, dict) + assert doc["x"] == Decimal("0.1") + assert isinstance(doc["x"], Decimal) diff --git a/patterns/creational/builder/README.md b/patterns/creational/builder/README.md new file mode 100644 index 0000000..dc1ebcd --- /dev/null +++ b/patterns/creational/builder/README.md @@ -0,0 +1,50 @@ +--- +id: creational/builder +name: Builder +aliases: [fluent-builder] +guide_url: https://python-patterns.guide/gang-of-four/builder/ +problem: "Assemble a complex object step by step, so the assembly process is reusable and readable." +symptoms: ["constructor with ten arguments", "object needs staged assembly", "fluent chained construction", "same steps, different representations"] +verdict: use-with-care +caveats: + - "Python's keyword arguments with defaults already solve the 'telescoping constructor' problem the GoF Builder exists for." + - "The guide's verdict: the Builder survives in Python mainly as a convenience for callers (e.g. matplotlib's pyplot), not as a construction ceremony." +stdlib_sightings: [email.message.EmailMessage, configparser.ConfigParser] +--- + +# Builder + +## Problem + +Some objects are miserable to construct in one shot: many parts, ordering +constraints, optional pieces. In 1994 Java/C++ the answer was a separate +Builder class walked by a Director, so the same step sequence could produce +different representations. + +## Naive solution + +`naive.py` is the full ceremony: an abstract builder interface, two concrete +builders, and a director that walks the steps. Faithful to the book — and +visibly over-engineered for Python. + +## Pythonic solution + +Python removes the two problems the pattern solved. Keyword arguments with +defaults kill the telescoping constructor, and first-class classes mean "the +same process, different representation" is just passing a different callable. +What *survives* is the Builder-as-convenience: a friendly object that +accumulates settings and then emits the real, immutable product — +`pythonic.py` builds a frozen dataclass through one. + +## In the wild + +`email.message.EmailMessage` is a builder you mutate call by call +(`msg["To"] = ...`, `set_content(...)`) before serializing; +`configparser.ConfigParser` accumulates sections the same way. Matplotlib's +`pyplot` interface is the guide's own headline example. + +## Verdict + +**Use with care.** Reach for keyword arguments first. Write a builder when +construction is genuinely staged or when you want a mutable assembly surface +in front of an immutable product. diff --git a/patterns/creational/builder/__init__.py b/patterns/creational/builder/__init__.py new file mode 100644 index 0000000..8681512 --- /dev/null +++ b/patterns/creational/builder/__init__.py @@ -0,0 +1 @@ +"""Builder: staged assembly of complex objects. Verdict: kwargs first.""" diff --git a/patterns/creational/builder/naive.py b/patterns/creational/builder/naive.py new file mode 100644 index 0000000..e6895a3 --- /dev/null +++ b/patterns/creational/builder/naive.py @@ -0,0 +1,68 @@ +"""The Gang of Four Builder, translated literally. + +Abstract builder interface + concrete builders + a Director that walks the +steps. The point of studying it: in Python, every one of these moving parts +except the concrete build steps is ceremony. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod + + +class House: + """The product under construction.""" + + def __init__(self) -> None: + self.parts: list[str] = [] + + def describe(self) -> str: + return " + ".join(self.parts) + + +class HouseBuilder(ABC): + """The abstract builder interface the Director programs against.""" + + def __init__(self) -> None: + self.house = House() + + @abstractmethod + def build_walls(self) -> None: ... + + @abstractmethod + def build_roof(self) -> None: ... + + +class StoneHouseBuilder(HouseBuilder): + def build_walls(self) -> None: + self.house.parts.append("stone walls") + + def build_roof(self) -> None: + self.house.parts.append("slate roof") + + +class WoodHouseBuilder(HouseBuilder): + def build_walls(self) -> None: + self.house.parts.append("timber walls") + + def build_roof(self) -> None: + self.house.parts.append("shingle roof") + + +class Director: + """Walks the build steps in order; knows nothing about representations.""" + + def construct(self, builder: HouseBuilder) -> House: + builder.build_walls() + builder.build_roof() + return builder.house + + +def main() -> None: + director = Director() + print(director.construct(StoneHouseBuilder()).describe()) + print(director.construct(WoodHouseBuilder()).describe()) + + +if __name__ == "__main__": + main() diff --git a/patterns/creational/builder/pythonic.py b/patterns/creational/builder/pythonic.py new file mode 100644 index 0000000..6b0f65d --- /dev/null +++ b/patterns/creational/builder/pythonic.py @@ -0,0 +1,48 @@ +"""What survives of the Builder in Python. + +First: keyword arguments with defaults already solve the telescoping +constructor, so most "builders" should just be a call. Second: when assembly +really is staged, a small mutable builder in front of a frozen product keeps +the product immutable while giving callers a friendly surface. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + + +@dataclass(frozen=True) +class Pizza: + """The immutable product.""" + + size: str + toppings: tuple[str, ...] = () + + +def order_pizza(size: str = "medium", *toppings: str) -> Pizza: + """The kwargs 'builder': one readable call, no ceremony.""" + return Pizza(size=size, toppings=toppings) + + +@dataclass +class PizzaBuilder: + """The staged builder: mutate freely, then emit the frozen product.""" + + size: str = "medium" + _toppings: list[str] = field(default_factory=list) + + def topped_with(self, *toppings: str) -> PizzaBuilder: + self._toppings.extend(toppings) + return self # chainable + + def build(self) -> Pizza: + return Pizza(size=self.size, toppings=tuple(self._toppings)) + + +def main() -> None: + print(order_pizza("large", "basil", "mozzarella")) + print(PizzaBuilder(size="small").topped_with("olive").topped_with("caper").build()) + + +if __name__ == "__main__": + main() diff --git a/patterns/creational/builder/real_world.py b/patterns/creational/builder/real_world.py new file mode 100644 index 0000000..bf4e733 --- /dev/null +++ b/patterns/creational/builder/real_world.py @@ -0,0 +1,29 @@ +"""The stdlib's builders. + +``email.message.EmailMessage`` is assembled call by call -- headers by +item assignment, body by ``set_content`` -- and only serialized at the end. +That is the Builder-as-convenience the guide describes. +""" + +from __future__ import annotations + +from email.message import EmailMessage + + +def build_email(sender: str, to: str, subject: str, body: str) -> EmailMessage: + """Staged assembly of an RFC 5322 message.""" + msg = EmailMessage() + msg["From"] = sender + msg["To"] = to + msg["Subject"] = subject + msg.set_content(body) + return msg + + +def main() -> None: + msg = build_email("a@example.com", "b@example.com", "hi", "Builder in the stdlib.\n") + print(msg.as_string()) + + +if __name__ == "__main__": + main() diff --git a/patterns/creational/builder/tests/__init__.py b/patterns/creational/builder/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/patterns/creational/builder/tests/test_builder.py b/patterns/creational/builder/tests/test_builder.py new file mode 100644 index 0000000..df64010 --- /dev/null +++ b/patterns/creational/builder/tests/test_builder.py @@ -0,0 +1,43 @@ +"""Behavioral tests for all three builder variants.""" + +from patterns.creational.builder import naive, pythonic, real_world + + +class TestNaive: + def test_director_reuses_steps_across_representations(self) -> None: + director = naive.Director() + stone = director.construct(naive.StoneHouseBuilder()) + wood = director.construct(naive.WoodHouseBuilder()) + assert stone.describe() == "stone walls + slate roof" + assert wood.describe() == "timber walls + shingle roof" + + def test_each_construct_yields_a_fresh_product(self) -> None: + director = naive.Director() + assert director.construct(naive.StoneHouseBuilder()) is not director.construct( + naive.StoneHouseBuilder() + ) + + +class TestPythonic: + def test_kwargs_builder(self) -> None: + pizza = pythonic.order_pizza("large", "basil") + assert (pizza.size, pizza.toppings) == ("large", ("basil",)) + + def test_staged_builder_chains_and_freezes(self) -> None: + pizza = pythonic.PizzaBuilder(size="small").topped_with("olive", "caper").build() + assert pizza == pythonic.Pizza(size="small", toppings=("olive", "caper")) + + def test_product_is_immutable(self) -> None: + import dataclasses + + import pytest + + with pytest.raises(dataclasses.FrozenInstanceError): + pythonic.Pizza("medium").size = "large" # type: ignore[misc] + + +class TestRealWorld: + def test_email_assembles_headers_and_body(self) -> None: + msg = real_world.build_email("a@x.com", "b@x.com", "s", "body\n") + assert msg["To"] == "b@x.com" + assert msg.get_content() == "body\n" diff --git a/patterns/creational/factory_method/README.md b/patterns/creational/factory_method/README.md new file mode 100644 index 0000000..4df10d4 --- /dev/null +++ b/patterns/creational/factory_method/README.md @@ -0,0 +1,47 @@ +--- +id: creational/factory_method +name: Factory Method +aliases: [virtual-constructor, class-attribute-factory] +guide_url: https://python-patterns.guide/gang-of-four/factory-method/ +problem: "Let a class defer which helper object it constructs, so subclasses or callers can substitute another." +symptoms: ["subclass to change what gets built", "framework builds objects the app must customize", "response_class-style override"] +verdict: prefer-alternative +caveats: + - "The guide's dodge is Dependency Injection: if you already have the object, pass the object, not a method that builds it." + - "When creation must stay inside the class, prefer a class attribute factory (override by assignment or subclass) over an abstract method — any callable can be plugged in." +stdlib_sightings: [http.client.HTTPConnection.response_class, json.JSONDecoder] +--- + +# Factory Method + +## Problem + +A class needs a helper object mid-work — an HTTP connection needs a response +object — and users must be able to substitute their own helper class without +rewriting the containing class. + +## Naive solution + +`naive.py` is the book's: an abstract creator with an abstract +`factory_method()`, and one subclass per helper choice. Note the cost — a +subclass per configuration, just to change one constructor call. + +## Pythonic solution + +The guide's ranking, in `pythonic.py`: (1) **dependency injection** — just +pass the helper in; (2) a **class attribute factory** — creation stays +internal, but overriding is assignment or a one-line subclass, and *any* +callable is accepted; (3) an **instance attribute factory** for per-object +overrides without any subclass at all. + +## In the wild + +`http.client.HTTPConnection.response_class` is the canonical class attribute +factory: subclass, point it at your response type, done. `json.JSONDecoder` +does the same with its parse hooks. + +## Verdict + +**Prefer an alternative:** inject the dependency; failing that, a class +attribute factory. The abstract-method form is Java with the serial numbers +filed off. diff --git a/patterns/creational/factory_method/__init__.py b/patterns/creational/factory_method/__init__.py new file mode 100644 index 0000000..089799e --- /dev/null +++ b/patterns/creational/factory_method/__init__.py @@ -0,0 +1 @@ +"""Factory Method: defer which helper gets built. Verdict: inject, or class attribute.""" diff --git a/patterns/creational/factory_method/naive.py b/patterns/creational/factory_method/naive.py new file mode 100644 index 0000000..33f0d91 --- /dev/null +++ b/patterns/creational/factory_method/naive.py @@ -0,0 +1,53 @@ +"""The Gang of Four Factory Method, translated literally. + +An abstract creator defers one construction decision to an abstract method; +each choice of helper costs a subclass. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod + + +class Shipment: + def __init__(self, kind: str) -> None: + self.kind = kind + + +class Express(Shipment): + def __init__(self) -> None: + super().__init__("express") + + +class Standard(Shipment): + def __init__(self) -> None: + super().__init__("standard") + + +class Store(ABC): + """The creator: works with shipments, defers building them.""" + + @abstractmethod + def make_shipment(self) -> Shipment: ... + + def ship(self) -> str: + return f"shipping via {self.make_shipment().kind}" + + +class ExpressStore(Store): + def make_shipment(self) -> Shipment: + return Express() + + +class StandardStore(Store): + def make_shipment(self) -> Shipment: + return Standard() + + +def main() -> None: + print(ExpressStore().ship()) + print(StandardStore().ship()) + + +if __name__ == "__main__": + main() diff --git a/patterns/creational/factory_method/pythonic.py b/patterns/creational/factory_method/pythonic.py new file mode 100644 index 0000000..30b05b8 --- /dev/null +++ b/patterns/creational/factory_method/pythonic.py @@ -0,0 +1,62 @@ +"""The guide's alternatives, best first. + +1. Dependency Injection: if you can build the helper up front, pass it in. +2. Class attribute factory: creation stays internal, overriding is trivial. +3. Instance attribute factory: per-object override, no subclass at all. +""" + +from __future__ import annotations + +from collections.abc import Callable + + +class Shipment: + def __init__(self, kind: str) -> None: + self.kind = kind + + +def express() -> Shipment: + return Shipment("express") + + +def standard() -> Shipment: + return Shipment("standard") + + +class InjectedStore: + """1. The dodge: don't defer creation -- receive the object.""" + + def __init__(self, shipment: Shipment) -> None: + self.shipment = shipment + + def ship(self) -> str: + return f"shipping via {self.shipment.kind}" + + +class Store: + """2. Class attribute factory: any callable; override by subclass or assignment.""" + + shipment_factory: Callable[[], Shipment] = staticmethod(standard) + + def __init__(self, shipment_factory: Callable[[], Shipment] | None = None) -> None: + # 3. Instance attribute overrides the class attribute per object. + if shipment_factory is not None: + self.shipment_factory = shipment_factory + + def ship(self) -> str: + return f"shipping via {self.shipment_factory().kind}" + + +class ExpressStore(Store): + shipment_factory = staticmethod(express) + + +def main() -> None: + print(InjectedStore(express()).ship()) + print(Store().ship()) + print(ExpressStore().ship()) + print(Store(shipment_factory=express).ship()) + + +if __name__ == "__main__": + main() diff --git a/patterns/creational/factory_method/real_world.py b/patterns/creational/factory_method/real_world.py new file mode 100644 index 0000000..d27d806 --- /dev/null +++ b/patterns/creational/factory_method/real_world.py @@ -0,0 +1,33 @@ +"""The canonical class attribute factory: ``HTTPConnection.response_class``. + +The connection builds its response objects through a class attribute, so a +one-line subclass swaps in your own response type -- no network needed to +see the wiring. +""" + +from __future__ import annotations + +from http.client import HTTPConnection, HTTPResponse + + +class LoggedResponse(HTTPResponse): + """A custom response type the connection should build instead.""" + + +class LoggedConnection(HTTPConnection): + response_class = LoggedResponse + + +def factory_of(cls: type[HTTPConnection]) -> type[HTTPResponse]: + result = cls.response_class + assert isinstance(result, type) + return result + + +def main() -> None: + print(f"stock factory: {factory_of(HTTPConnection).__name__}") + print(f"overridden factory: {factory_of(LoggedConnection).__name__}") + + +if __name__ == "__main__": + main() diff --git a/patterns/creational/factory_method/tests/__init__.py b/patterns/creational/factory_method/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/patterns/creational/factory_method/tests/test_factory_method.py b/patterns/creational/factory_method/tests/test_factory_method.py new file mode 100644 index 0000000..29e4d77 --- /dev/null +++ b/patterns/creational/factory_method/tests/test_factory_method.py @@ -0,0 +1,33 @@ +"""Behavioral tests for all three factory-method variants.""" + +from http.client import HTTPConnection, HTTPResponse + +from patterns.creational.factory_method import naive, pythonic, real_world + + +class TestNaive: + def test_each_subclass_builds_its_helper(self) -> None: + assert naive.ExpressStore().ship() == "shipping via express" + assert naive.StandardStore().ship() == "shipping via standard" + + +class TestPythonic: + def test_dependency_injection(self) -> None: + assert pythonic.InjectedStore(pythonic.express()).ship() == "shipping via express" + + def test_class_attribute_default(self) -> None: + assert pythonic.Store().ship() == "shipping via standard" + + def test_subclass_overrides_class_attribute(self) -> None: + assert pythonic.ExpressStore().ship() == "shipping via express" + + def test_instance_attribute_beats_class_attribute(self) -> None: + assert pythonic.Store(shipment_factory=pythonic.express).ship() == "shipping via express" + + +class TestRealWorld: + def test_stock_connection_builds_httpresponse(self) -> None: + assert real_world.factory_of(HTTPConnection) is HTTPResponse + + def test_subclass_swaps_the_response_factory(self) -> None: + assert real_world.factory_of(real_world.LoggedConnection) is real_world.LoggedResponse diff --git a/patterns/creational/prototype/README.md b/patterns/creational/prototype/README.md new file mode 100644 index 0000000..21ad735 --- /dev/null +++ b/patterns/creational/prototype/README.md @@ -0,0 +1,46 @@ +--- +id: creational/prototype +name: Prototype +aliases: [clone] +guide_url: https://python-patterns.guide/gang-of-four/prototype/ +problem: "Create new objects by copying a pre-configured exemplar instead of constructing from scratch." +symptoms: ["expensive construction", "objects that start from a template", "registry of preconfigured instances", "clone this object"] +verdict: prefer-alternative +caveats: + - "The pattern targets a 1990s problem: languages where classes weren't first-class values. In Python you just pass the class, or a functools.partial, or a bound copy call." + - "If you do copy, know your depth: copy.copy shares nested mutable state; copy.deepcopy does not." +stdlib_sightings: [copy.copy, copy.deepcopy, functools.partial] +--- + +# Prototype + +## Problem + +A framework needs to stamp out new objects without knowing how to construct +them — the classic case is a menu of pre-configured instances the user picks +from. The GoF answer: store an exemplar ("prototype") and `clone()` it. + +## Naive solution + +`naive.py` follows the book: an abstract `clone()` method, concrete prototypes, +and a registry mapping names to exemplars that get cloned on demand. + +## Pythonic solution + +Python doesn't need the interface, because *callables* are the interface. A +registry can hold classes, `functools.partial` objects pre-loading the +arguments, or bound methods — anything you can call to get a fresh instance. +`pythonic.py` shows the guide's recommendation: a registry of zero-argument +factories. + +## In the wild + +`copy.copy` and `copy.deepcopy` are the stdlib's clone operation, complete +with the `__copy__`/`__deepcopy__` protocol for classes that need custom +cloning — that protocol *is* the Prototype pattern, absorbed into the language. + +## Verdict + +**Prefer an alternative.** Store callables, not exemplars. Reach for +`copy.deepcopy` only when instances are genuinely expensive or awkward to +rebuild from arguments. diff --git a/patterns/creational/prototype/__init__.py b/patterns/creational/prototype/__init__.py new file mode 100644 index 0000000..4c2c58a --- /dev/null +++ b/patterns/creational/prototype/__init__.py @@ -0,0 +1 @@ +"""Prototype: new instances by cloning an exemplar. Verdict: store callables instead.""" diff --git a/patterns/creational/prototype/naive.py b/patterns/creational/prototype/naive.py new file mode 100644 index 0000000..f07d669 --- /dev/null +++ b/patterns/creational/prototype/naive.py @@ -0,0 +1,53 @@ +"""The Gang of Four Prototype, translated literally. + +An abstract ``clone()`` interface, concrete prototypes, and a registry of +exemplars that are copied -- never handed out directly -- on request. +""" + +from __future__ import annotations + +import copy +from abc import ABC, abstractmethod +from typing import Self + + +class Shape(ABC): + """The prototype interface.""" + + @abstractmethod + def clone(self) -> Self: ... + + +class Circle(Shape): + def __init__(self, radius: int, color: str) -> None: + self.radius = radius + self.color = color + + def clone(self) -> Self: + return copy.deepcopy(self) + + +class PrototypeRegistry: + """Menu of pre-configured exemplars; every request gets a private copy.""" + + def __init__(self) -> None: + self._prototypes: dict[str, Shape] = {} + + def register(self, name: str, prototype: Shape) -> None: + self._prototypes[name] = prototype + + def create(self, name: str) -> Shape: + return self._prototypes[name].clone() + + +def main() -> None: + registry = PrototypeRegistry() + registry.register("small-red", Circle(radius=1, color="red")) + + a = registry.create("small-red") + b = registry.create("small-red") + print(f"independent copies: {a is not b}") + + +if __name__ == "__main__": + main() diff --git a/patterns/creational/prototype/pythonic.py b/patterns/creational/prototype/pythonic.py new file mode 100644 index 0000000..2280640 --- /dev/null +++ b/patterns/creational/prototype/pythonic.py @@ -0,0 +1,40 @@ +"""What to write instead: a registry of callables. + +Classes are first-class values in Python, and ``functools.partial`` turns +"this class plus these arguments" into a zero-argument factory. The registry +stores factories; asking for a fresh instance is just calling one. +""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from functools import partial + + +@dataclass +class Circle: + radius: int + color: str + + +#: The whole pattern: names mapped to zero-argument factories. +MENU: dict[str, Callable[[], Circle]] = { + "small-red": partial(Circle, radius=1, color="red"), + "big-blue": partial(Circle, radius=10, color="blue"), +} + + +def create(name: str) -> Circle: + return MENU[name]() + + +def main() -> None: + a = create("small-red") + b = create("small-red") + print(f"fresh instances: {a is not b}, equal config: {a == b}") + print(create("big-blue")) + + +if __name__ == "__main__": + main() diff --git a/patterns/creational/prototype/real_world.py b/patterns/creational/prototype/real_world.py new file mode 100644 index 0000000..d146ea1 --- /dev/null +++ b/patterns/creational/prototype/real_world.py @@ -0,0 +1,39 @@ +"""The stdlib's clone operation: the ``copy`` module. + +``copy.copy`` is a shallow clone (nested mutables are shared); +``copy.deepcopy`` clones the whole object graph. Classes customize both via +the ``__copy__`` / ``__deepcopy__`` protocol -- the Prototype pattern as a +language protocol. +""" + +from __future__ import annotations + +import copy +from dataclasses import dataclass, field + + +@dataclass +class Board: + name: str + tiles: list[list[int]] = field(default_factory=lambda: [[0, 0], [0, 0]]) + + +def shallow_shares_nested_state(template: Board) -> bool: + clone = copy.copy(template) + clone.tiles[0][0] = 9 + return template.tiles[0][0] == 9 # the nested list is shared! + + +def deep_is_independent(template: Board) -> bool: + clone = copy.deepcopy(template) + clone.tiles[0][0] = 9 + return template.tiles[0][0] == 0 + + +def main() -> None: + print(f"shallow copy shares nested state: {shallow_shares_nested_state(Board('a'))}") + print(f"deep copy is independent: {deep_is_independent(Board('b'))}") + + +if __name__ == "__main__": + main() diff --git a/patterns/creational/prototype/tests/__init__.py b/patterns/creational/prototype/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/patterns/creational/prototype/tests/test_prototype.py b/patterns/creational/prototype/tests/test_prototype.py new file mode 100644 index 0000000..2156411 --- /dev/null +++ b/patterns/creational/prototype/tests/test_prototype.py @@ -0,0 +1,40 @@ +"""Behavioral tests for all three prototype variants.""" + +from patterns.creational.prototype import naive, pythonic, real_world + + +class TestNaive: + def test_registry_clones_are_independent(self) -> None: + registry = naive.PrototypeRegistry() + registry.register("c", naive.Circle(radius=2, color="green")) + a, b = registry.create("c"), registry.create("c") + assert a is not b + assert isinstance(a, naive.Circle) + assert (a.radius, a.color) == (2, "green") + + def test_mutating_a_clone_leaves_the_exemplar_alone(self) -> None: + registry = naive.PrototypeRegistry() + exemplar = naive.Circle(radius=2, color="green") + registry.register("c", exemplar) + clone = registry.create("c") + assert isinstance(clone, naive.Circle) + clone.radius = 99 + assert exemplar.radius == 2 + + +class TestPythonic: + def test_factory_menu_produces_fresh_equal_instances(self) -> None: + a, b = pythonic.create("small-red"), pythonic.create("small-red") + assert a is not b + assert a == b == pythonic.Circle(radius=1, color="red") + + def test_distinct_entries_differ(self) -> None: + assert pythonic.create("big-blue") == pythonic.Circle(radius=10, color="blue") + + +class TestRealWorld: + def test_shallow_copy_shares_nested_state(self) -> None: + assert real_world.shallow_shares_nested_state(real_world.Board("t")) + + def test_deepcopy_is_independent(self) -> None: + assert real_world.deep_is_independent(real_world.Board("t")) diff --git a/patterns/creational/singleton/README.md b/patterns/creational/singleton/README.md new file mode 100644 index 0000000..05fc2de --- /dev/null +++ b/patterns/creational/singleton/README.md @@ -0,0 +1,50 @@ +--- +id: creational/singleton +name: Singleton +aliases: [single-instance] +guide_url: https://python-patterns.guide/gang-of-four/singleton/ +problem: "Guarantee a class has exactly one instance and give the whole program access to it." +symptoms: ["shared config object", "one connection pool", "global registry", "only one instance"] +verdict: prefer-alternative +caveats: + - "You almost always want the Global Object pattern instead: build the instance at import time in a module and import it." + - "Singleton classes make tests order-dependent — state leaks between tests through the hidden instance." + - "The GoF __new__ dance still runs __init__ on every call in Python; a factory function avoids the trap entirely." +stdlib_sightings: [None, Ellipsis, NotImplemented] +--- + +# Singleton + +## Problem + +Some resources must exist exactly once: a configuration object, a connection +pool, a process-wide registry. The Gang of Four answer is a class that +intercepts construction and always hands back the same instance. + +## Naive solution + +`naive.py` is the classic implementation: override `__new__`, cache the +instance on the class. It works, but note what Python forces on you — callers +still *look* like they're constructing (`Logger()`), `__init__` re-runs on +every call unless you guard it, and subclassing gets weird fast. + +## Pythonic solution + +Python already has singletons: **modules**. A module is created once, cached in +`sys.modules`, and every `import` returns the same object. `pythonic.py` shows +the Global Object pattern — instantiate a plain class once at module level (or +lazily behind a function) and import that. No metaclass, no `__new__`, nothing +to explain in review. + +## In the wild + +`None`, `Ellipsis`, and `NotImplemented` are the interpreter's own singletons — +that's why `is` comparison against them is correct. Every imported module is +one too: `real_world.py` proves it. + +## Verdict + +**Prefer an alternative.** The naive form exists here for study; if you're +reaching for it, write a module-level instance instead. The exceptions are rare +enough that you'll know them when you hit them (lazy construction that must be +thread-safe, C-extension interop). diff --git a/patterns/creational/singleton/__init__.py b/patterns/creational/singleton/__init__.py new file mode 100644 index 0000000..3a3757f --- /dev/null +++ b/patterns/creational/singleton/__init__.py @@ -0,0 +1 @@ +"""Singleton: one instance, program-wide access. Verdict: prefer the Global Object pattern.""" diff --git a/patterns/creational/singleton/naive.py b/patterns/creational/singleton/naive.py new file mode 100644 index 0000000..c27364c --- /dev/null +++ b/patterns/creational/singleton/naive.py @@ -0,0 +1,42 @@ +"""The Gang of Four Singleton, translated literally. + +The class intercepts construction in ``__new__`` and caches the sole instance. +Note the wart this forces in Python: ``__init__`` runs on *every* call, so it +must guard against re-initialization itself. +""" + +from __future__ import annotations + +from typing import ClassVar, Self + + +class Logger: + """A classic GoF singleton: every construction returns the same instance.""" + + _instance: ClassVar[Self | None] = None + + def __new__(cls) -> Self: + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __init__(self) -> None: + # Without this guard, a second Logger() call would wipe the log. + if not hasattr(self, "lines"): + self.lines: list[str] = [] + + def log(self, message: str) -> None: + self.lines.append(message) + + +def main() -> None: + a = Logger() + b = Logger() + a.log("first") + b.log("second") + print(f"a is b: {a is b}") + print(f"log seen by both: {a.lines}") + + +if __name__ == "__main__": + main() diff --git a/patterns/creational/singleton/pythonic.py b/patterns/creational/singleton/pythonic.py new file mode 100644 index 0000000..8764022 --- /dev/null +++ b/patterns/creational/singleton/pythonic.py @@ -0,0 +1,47 @@ +"""What to write instead: the Global Object pattern. + +A module is itself a singleton -- created once, cached in ``sys.modules``. +So the pythonic "Singleton" is a perfectly ordinary class instantiated once +at module level. Callers ``import`` the instance instead of constructing it. + +For construction that is expensive or needs configuration first, hide the +instance behind a small accessor function instead (shown below). +""" + +from __future__ import annotations + + +class Logger: + """An ordinary class -- nothing about it knows it will be shared.""" + + def __init__(self) -> None: + self.lines: list[str] = [] + + def log(self, message: str) -> None: + self.lines.append(message) + + +#: The Global Object: built once, at import time. This is the whole pattern. +logger = Logger() + + +# Lazy variant, for when construction must wait until first use: +_lazy_instance: Logger | None = None + + +def get_logger() -> Logger: + """Build the shared instance on first call, then keep handing it back.""" + global _lazy_instance + if _lazy_instance is None: + _lazy_instance = Logger() + return _lazy_instance + + +def main() -> None: + logger.log("hello") + print(f"module global is shared: {logger.lines}") + print(f"lazy accessor is stable: {get_logger() is get_logger()}") + + +if __name__ == "__main__": + main() diff --git a/patterns/creational/singleton/real_world.py b/patterns/creational/singleton/real_world.py new file mode 100644 index 0000000..7a161d1 --- /dev/null +++ b/patterns/creational/singleton/real_world.py @@ -0,0 +1,37 @@ +"""Singletons the interpreter already ships. + +``None``, ``Ellipsis``, and ``NotImplemented`` each have exactly one instance, +which is why identity comparison (``is``) against them is the correct idiom. +And every module is a singleton: ``import`` consults ``sys.modules`` and +returns the cached object rather than building a new one. +""" + +from __future__ import annotations + +import sys +import types + + +def none_is_a_singleton() -> bool: + """All ``None`` values in a program are the very same object.""" + a: object | None = None + b: object | None = None + # Every None in the process is literally the same object. + return a is b and a is None + + +def modules_are_singletons() -> bool: + """A second import returns the cached module object, not a copy.""" + first = __import__("json") + second = __import__("json") + return first is second and sys.modules["json"] is first + + +def main() -> None: + print(f"None is a singleton: {none_is_a_singleton()}") + print(f"modules are singletons: {modules_are_singletons()}") + print(f"a module's type: {types.ModuleType.__name__}") + + +if __name__ == "__main__": + main() diff --git a/patterns/creational/singleton/tests/__init__.py b/patterns/creational/singleton/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/patterns/creational/singleton/tests/test_singleton.py b/patterns/creational/singleton/tests/test_singleton.py new file mode 100644 index 0000000..c009161 --- /dev/null +++ b/patterns/creational/singleton/tests/test_singleton.py @@ -0,0 +1,43 @@ +"""Behavioral tests for all three singleton variants.""" + +from patterns.creational.singleton import naive, pythonic, real_world + + +class TestNaive: + def test_identity(self) -> None: + assert naive.Logger() is naive.Logger() + + def test_state_is_shared(self) -> None: + a = naive.Logger() + a.lines.clear() + naive.Logger().log("hi") + assert a.lines == ["hi"] + + def test_reinit_does_not_wipe_state(self) -> None: + a = naive.Logger() + a.lines.clear() + a.log("kept") + naive.Logger() # __init__ runs again; the guard must preserve state + assert a.lines == ["kept"] + + +class TestPythonic: + def test_module_global_is_stable(self) -> None: + assert pythonic.logger is pythonic.logger + + def test_lazy_accessor_returns_same_instance(self) -> None: + assert pythonic.get_logger() is pythonic.get_logger() + + def test_lazy_accessor_builds_a_real_logger(self) -> None: + log = pythonic.get_logger() + log.lines.clear() + log.log("x") + assert pythonic.get_logger().lines == ["x"] + + +class TestRealWorld: + def test_none_identity(self) -> None: + assert real_world.none_is_a_singleton() + + def test_module_identity(self) -> None: + assert real_world.modules_are_singletons() 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/patterns/principle/__init__.py b/patterns/principle/__init__.py new file mode 100644 index 0000000..14f93e4 --- /dev/null +++ b/patterns/principle/__init__.py @@ -0,0 +1 @@ +"""Design principles.""" diff --git a/patterns/principle/composition_over_inheritance/README.md b/patterns/principle/composition_over_inheritance/README.md new file mode 100644 index 0000000..8af7d3d --- /dev/null +++ b/patterns/principle/composition_over_inheritance/README.md @@ -0,0 +1,44 @@ +--- +id: principle/composition_over_inheritance +name: Composition Over Inheritance +aliases: [subclass-explosion, favor-composition] +guide_url: https://python-patterns.guide/gang-of-four/composition-over-inheritance/ +problem: "Vary independent behaviors without one subclass per combination of them." +symptoms: ["subclass explosion", "FilteredSocketLogger-style names", "M x N class combinations", "mixin soup"] +verdict: pythonic +caveats: + - "Multiple inheritance, mixins, and dynamically built classes are the guide's 'dodges' — they postpone the explosion instead of ending it." + - "Each independent axis of variation should become its own small object, injected where needed." +stdlib_sightings: [logging.Logger, logging.Handler, logging.Filter] +--- + +# Composition Over Inheritance + +## Problem + +A logger can filter messages and can write to a file or a socket. With +inheritance, every combination costs a class: `FilteredLogger`, +`SocketLogger`, `FilteredSocketLogger`… M filters × N destinations = M×N +classes. This is the guide's opening case study. + +## Naive solution + +`naive.py` builds exactly that explosion, three classes deep, so you can +watch the combinatorics happen. + +## Pythonic solution + +Split each axis into its own object — filters decide, handlers write — and +*compose* them in one logger. M + N small classes cover all M × N behaviors, +and new combinations are constructor arguments, not new classes. + +## In the wild + +The stdlib `logging` module is this principle shipped at scale: `Logger` +composes `Handler`s, `Filter`s, and `Formatter`s, and no class named +`FilteredRotatingSyslogLogger` needs to exist. + +## Verdict + +**Pythonic** — and the single most load-bearing idea behind the other +patterns in this catalog. diff --git a/patterns/principle/composition_over_inheritance/__init__.py b/patterns/principle/composition_over_inheritance/__init__.py new file mode 100644 index 0000000..6732fce --- /dev/null +++ b/patterns/principle/composition_over_inheritance/__init__.py @@ -0,0 +1 @@ +"""Composition over inheritance: objects per axis, not classes per combination.""" diff --git a/patterns/principle/composition_over_inheritance/naive.py b/patterns/principle/composition_over_inheritance/naive.py new file mode 100644 index 0000000..3c7a6ad --- /dev/null +++ b/patterns/principle/composition_over_inheritance/naive.py @@ -0,0 +1,53 @@ +"""The subclass explosion, reproduced faithfully. + +Two independent axes (filtering, destination) already cost four classes; +each new filter or destination multiplies, not adds. +""" + +from __future__ import annotations + + +class Logger: + def __init__(self, sink: list[str]) -> None: + self.sink = sink + + def log(self, message: str) -> None: + self.sink.append(message) + + +class FilteredLogger(Logger): + """Axis 1 bolted on by subclassing.""" + + def __init__(self, pattern: str, sink: list[str]) -> None: + super().__init__(sink) + self.pattern = pattern + + def log(self, message: str) -> None: + if self.pattern in message: + super().log(message) + + +class UppercaseLogger(Logger): + """Axis 2 bolted on by subclassing.""" + + def log(self, message: str) -> None: + super().log(message.upper()) + + +class FilteredUppercaseLogger(FilteredLogger): + """And here is the explosion: one class PER COMBINATION.""" + + def log(self, message: str) -> None: + if self.pattern in message: + self.sink.append(message.upper()) + + +def main() -> None: + sink: list[str] = [] + FilteredUppercaseLogger("error", sink).log("error: disk full") + FilteredUppercaseLogger("error", sink).log("all fine") + print(sink) + + +if __name__ == "__main__": + main() diff --git a/patterns/principle/composition_over_inheritance/pythonic.py b/patterns/principle/composition_over_inheritance/pythonic.py new file mode 100644 index 0000000..2c21476 --- /dev/null +++ b/patterns/principle/composition_over_inheritance/pythonic.py @@ -0,0 +1,45 @@ +"""Composition: one small object per axis, combined at runtime. + +M filters + N transforms cover M x N behaviors with M + N classes; a new +combination is a constructor call, not a new class. +""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass, field + +Filter = Callable[[str], bool] +Transform = Callable[[str], str] + + +def contains(pattern: str) -> Filter: + return lambda message: pattern in message + + +def identity(message: str) -> str: + return message + + +@dataclass +class Logger: + """One logger class, ever. Behavior comes from what you compose into it.""" + + sink: list[str] = field(default_factory=list) + filters: tuple[Filter, ...] = () + transform: Transform = identity + + def log(self, message: str) -> None: + if all(f(message) for f in self.filters): + self.sink.append(self.transform(message)) + + +def main() -> None: + loud_errors = Logger(filters=(contains("error"),), transform=str.upper) + loud_errors.log("error: disk full") + loud_errors.log("all fine") + print(loud_errors.sink) + + +if __name__ == "__main__": + main() diff --git a/patterns/principle/composition_over_inheritance/real_world.py b/patterns/principle/composition_over_inheritance/real_world.py new file mode 100644 index 0000000..7ae4289 --- /dev/null +++ b/patterns/principle/composition_over_inheritance/real_world.py @@ -0,0 +1,37 @@ +"""The ``logging`` module: composition at industrial scale. + +A Logger composes Handlers and Filters; nobody subclasses per combination. +""" + +from __future__ import annotations + +import logging + + +def build_error_logger(name: str, sink: list[str]) -> logging.Logger: + """Compose: a list-writing handler + a substring filter, one stock Logger.""" + + class ListHandler(logging.Handler): + def emit(self, record: logging.LogRecord) -> None: + sink.append(record.getMessage()) + + logger = logging.getLogger(name) + logger.handlers.clear() + logger.propagate = False + logger.setLevel(logging.INFO) + handler = ListHandler() + handler.addFilter(lambda record: "error" in record.getMessage()) + logger.addHandler(handler) + return logger + + +def main() -> None: + sink: list[str] = [] + logger = build_error_logger("demo", sink) + logger.info("error: disk full") + logger.info("all fine") + print(sink) + + +if __name__ == "__main__": + main() diff --git a/patterns/principle/composition_over_inheritance/tests/__init__.py b/patterns/principle/composition_over_inheritance/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/patterns/principle/composition_over_inheritance/tests/test_composition_over_inheritance.py b/patterns/principle/composition_over_inheritance/tests/test_composition_over_inheritance.py new file mode 100644 index 0000000..5681935 --- /dev/null +++ b/patterns/principle/composition_over_inheritance/tests/test_composition_over_inheritance.py @@ -0,0 +1,45 @@ +"""Behavioral tests for the composition-over-inheritance unit.""" + +from patterns.principle.composition_over_inheritance import naive, pythonic, real_world + + +class TestNaive: + def test_combination_class_works_but_had_to_exist(self) -> None: + sink: list[str] = [] + logger = naive.FilteredUppercaseLogger("error", sink) + logger.log("error: disk full") + logger.log("all fine") + assert sink == ["ERROR: DISK FULL"] + + def test_the_explosion_is_real(self) -> None: + # Four classes for two axes -- the M x N cost, in the flesh. + assert issubclass(naive.FilteredUppercaseLogger, naive.FilteredLogger) + assert issubclass(naive.FilteredLogger, naive.Logger) + + +class TestPythonic: + def test_composed_behavior_matches_the_combination_class(self) -> None: + logger = pythonic.Logger(filters=(pythonic.contains("error"),), transform=str.upper) + logger.log("error: disk full") + logger.log("all fine") + assert logger.sink == ["ERROR: DISK FULL"] + + def test_new_combination_is_a_constructor_call(self) -> None: + plain = pythonic.Logger(filters=(pythonic.contains("warn"),)) + plain.log("warn: low disk") + plain.log("error: ignored here") + assert plain.sink == ["warn: low disk"] + + def test_no_filters_means_log_everything(self) -> None: + logger = pythonic.Logger() + logger.log("anything") + assert logger.sink == ["anything"] + + +class TestRealWorld: + def test_stdlib_logging_composes_filter_and_handler(self) -> None: + sink: list[str] = [] + logger = real_world.build_error_logger("pdp-test", sink) + logger.info("error: disk full") + logger.info("all fine") + assert sink == ["error: disk full"] diff --git a/patterns/python/__init__.py b/patterns/python/__init__.py new file mode 100644 index 0000000..00d09a4 --- /dev/null +++ b/patterns/python/__init__.py @@ -0,0 +1 @@ +"""Python-native patterns from python-patterns.guide.""" diff --git a/patterns/python/global_object/README.md b/patterns/python/global_object/README.md new file mode 100644 index 0000000..796b215 --- /dev/null +++ b/patterns/python/global_object/README.md @@ -0,0 +1,45 @@ +--- +id: python/global_object +name: Global Object +aliases: [module-global, constant-pattern] +guide_url: https://python-patterns.guide/python/module-globals/ +problem: "Give a whole program shared access to a constant or a pre-built object by assigning it at module level." +symptoms: ["shared constants", "one shared instance", "config everyone imports", "what Singleton actually wants to be"] +verdict: use-with-care +caveats: + - "Mutable globals couple everything that touches them and make tests order-dependent — prefer constants, or objects whose mutation is their documented job (like os.environ)." + - "Never do I/O at import time: importing must be cheap and safe, or every consumer pays (and test runs touch the network/disk)." +stdlib_sightings: [os.environ, calendar.day_name, math.pi] +--- + +# Global Object + +## Problem + +Many parts of a program need the same value — a constant table, a compiled +regex, a configured client. Passing it through every call chain is noise; +building it repeatedly is waste. + +## Naive solution + +`naive.py` shows the two classic misuses: hidden *mutable* module state that +couples callers together, and import-time I/O that makes `import` slow, +fragile, and untestable. + +## Pythonic solution + +`pythonic.py` shows the pattern done well: immutable constants computed at +import time (cheap, deterministic), a pre-built global object whose +construction is pure, and lazy initialization for anything expensive — +so importing the module never costs more than defining functions. + +## In the wild + +`math.pi` is the Constant Pattern; `calendar.day_name` is an import-time +computed global object; `os.environ` is the rare *documented* mutable global, +mutation being its entire purpose. + +## Verdict + +**Use with care.** Constants and immutable pre-built objects: freely. Mutable +globals: only when shared mutation is the feature, not an accident. diff --git a/patterns/python/global_object/__init__.py b/patterns/python/global_object/__init__.py new file mode 100644 index 0000000..acbaef7 --- /dev/null +++ b/patterns/python/global_object/__init__.py @@ -0,0 +1 @@ +"""Global Object: module-level constants and shared instances.""" diff --git a/patterns/python/global_object/naive.py b/patterns/python/global_object/naive.py new file mode 100644 index 0000000..579dbd2 --- /dev/null +++ b/patterns/python/global_object/naive.py @@ -0,0 +1,33 @@ +"""The two classic misuses of module globals. + +1. Hidden mutable state: every caller of ``tally`` is coupled to every other. +2. Import-time I/O (simulated): importing becomes slow, order-dependent, and + untestable. Real code that does ``open()``/network at module level fails + in exactly the ways this pretends to. +""" + +from __future__ import annotations + +# Misuse 1: a mutable global that functions quietly share. +_counts: dict[str, int] = {} + + +def tally(word: str) -> int: + """Two callers who have never met now share state through _counts.""" + _counts[word] = _counts.get(word, 0) + 1 + return _counts[word] + + +# Misuse 2: work at import time. Here it is only a computation standing in +# for the real sin (reading files, opening sockets) -- but note that it runs +# before any caller has asked for anything. +IMPORT_TIME_WORK: list[int] = [n * n for n in range(1000)] + + +def main() -> None: + print(f"tally('a') twice: {tally('a')}, {tally('a')}") + print(f"import already paid for {len(IMPORT_TIME_WORK)} squares nobody asked for") + + +if __name__ == "__main__": + main() diff --git a/patterns/python/global_object/pythonic.py b/patterns/python/global_object/pythonic.py new file mode 100644 index 0000000..a030ae6 --- /dev/null +++ b/patterns/python/global_object/pythonic.py @@ -0,0 +1,44 @@ +"""The Global Object pattern done well. + +Constants, cheap deterministic import-time computation, and lazy +initialization for anything expensive. Importing this module does no I/O +and mutates nothing observable. +""" + +from __future__ import annotations + +import re + +#: The Constant Pattern: immutable, named, computed once. +MONTHS_PER_YEAR = 12 +VOWELS = frozenset("aeiou") + +#: Import-time computation is fine when it is cheap and pure: +#: a compiled regex is the guide's own example of a good global object. +IDENTIFIER = re.compile(r"[A-Za-z_][A-Za-z0-9_]*") + + +def count_vowels(text: str) -> int: + return sum(1 for ch in text if ch in VOWELS) + + +# Lazy initialization: pay for expensive construction on first use, not import. +_big_table: dict[int, int] | None = None + + +def big_table() -> dict[int, int]: + global _big_table + if _big_table is None: + _big_table = {n: n * n for n in range(10_000)} + return _big_table + + +def main() -> None: + print(f"constant: {MONTHS_PER_YEAR}") + print(f"regex global: {bool(IDENTIFIER.fullmatch('valid_name'))}") + print(f"vowels in text: {count_vowels('global object')}") + print(f"lazy table size: {len(big_table())}") + + +if __name__ == "__main__": + main() diff --git a/patterns/python/global_object/real_world.py b/patterns/python/global_object/real_world.py new file mode 100644 index 0000000..7293ec1 --- /dev/null +++ b/patterns/python/global_object/real_world.py @@ -0,0 +1,39 @@ +"""Global objects the stdlib ships. + +``math.pi``: the Constant Pattern. ``calendar.day_name``: an import-time +built global object. ``os.environ``: the rare mutable global whose mutation +is its documented job. +""" + +from __future__ import annotations + +import calendar +import math +import os + + +def midweek_day() -> str: + return str(calendar.day_name[2]) + + +def circle_area(radius: float) -> float: + return math.pi * radius**2 + + +def with_temp_env(key: str, value: str) -> str: + """os.environ is mutable by design; clean up what you touch.""" + os.environ[key] = value + try: + return os.environ[key] + finally: + del os.environ[key] + + +def main() -> None: + print(f"constant pattern: math.pi = {math.pi}") + print(f"global object: day_name[2] = {midweek_day()}") + print(f"mutable global: {with_temp_env('DEMO_KEY', 'demo')}") + + +if __name__ == "__main__": + main() diff --git a/patterns/python/global_object/tests/__init__.py b/patterns/python/global_object/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/patterns/python/global_object/tests/test_global_object.py b/patterns/python/global_object/tests/test_global_object.py new file mode 100644 index 0000000..a94d31a --- /dev/null +++ b/patterns/python/global_object/tests/test_global_object.py @@ -0,0 +1,41 @@ +"""Behavioral tests for all three global-object variants.""" + +import math +import os + +from patterns.python.global_object import naive, pythonic, real_world + + +class TestNaive: + def test_mutable_global_couples_callers(self) -> None: + naive._counts.clear() + naive.tally("x") + # A "different caller" is affected by the first one's state: + assert naive.tally("x") == 2 + + def test_import_time_work_already_happened(self) -> None: + assert len(naive.IMPORT_TIME_WORK) == 1000 + + +class TestPythonic: + def test_constants_are_immutable_types(self) -> None: + assert isinstance(pythonic.VOWELS, frozenset) + assert pythonic.count_vowels("aeiou xyz") == 5 + + def test_compiled_regex_global(self) -> None: + assert pythonic.IDENTIFIER.fullmatch("valid_name") + assert not pythonic.IDENTIFIER.fullmatch("1bad") + + def test_lazy_table_builds_once(self) -> None: + assert pythonic.big_table() is pythonic.big_table() + assert pythonic.big_table()[99] == 9801 + + +class TestRealWorld: + def test_stdlib_globals(self) -> None: + assert real_world.midweek_day() == "Wednesday" + assert real_world.circle_area(1.0) == math.pi + + def test_environ_mutation_cleans_up(self) -> None: + assert real_world.with_temp_env("PDP_TEST_KEY", "v") == "v" + assert "PDP_TEST_KEY" not in os.environ diff --git a/patterns/python/prebound_method/README.md b/patterns/python/prebound_method/README.md new file mode 100644 index 0000000..e007341 --- /dev/null +++ b/patterns/python/prebound_method/README.md @@ -0,0 +1,44 @@ +--- +id: python/prebound_method +name: Prebound Method +aliases: [bound-method-global] +guide_url: https://python-patterns.guide/python/prebound-methods/ +problem: "Offer module-level functions that share state, by binding the methods of one hidden instance to module globals." +symptoms: ["module-level API over shared state", "random.random-style interface", "convenience functions plus an instantiable class"] +verdict: pythonic +caveats: + - "Build the hidden instance cheaply and without I/O — it is constructed at import time." + - "Keep the class public too, so users needing isolated state can instantiate their own (exactly as random.Random allows)." +stdlib_sightings: [random.random, random.seed, secrets.token_hex] +--- + +# Prebound Method + +## Problem + +You want the ergonomic module-level API — `random.random()`, not +`random.get_default_generator().random()` — but the functions must share +state (a seed, a counter, a connection). + +## Naive solution + +`naive.py` shows the alternatives the guide rejects: bare module functions +mutating a loose module global (state and behavior drift apart), or making +every caller instantiate the class themselves (ergonomics lost). + +## Pythonic solution + +Define a normal class, build **one instance** at module level, then assign +its bound methods to module-global names: `roll = _instance.roll`. Callers +get plain functions; the instance travels along inside each bound method. + +## In the wild + +`random.random`, `random.seed`, and friends are exactly this — bound methods +of a hidden `random.Random()` built at import; `random.Random` stays public +for anyone needing isolated streams. + +## Verdict + +**Pythonic.** The stdlib's own favorite way to put a friendly face on shared +state. diff --git a/patterns/python/prebound_method/__init__.py b/patterns/python/prebound_method/__init__.py new file mode 100644 index 0000000..d8c039d --- /dev/null +++ b/patterns/python/prebound_method/__init__.py @@ -0,0 +1 @@ +"""Prebound Method: module functions that are bound methods of one hidden instance.""" diff --git a/patterns/python/prebound_method/naive.py b/patterns/python/prebound_method/naive.py new file mode 100644 index 0000000..7ec58e4 --- /dev/null +++ b/patterns/python/prebound_method/naive.py @@ -0,0 +1,39 @@ +"""The alternatives the guide rejects. + +Option A: bare functions over a loose module global -- state and the +functions that guard it are separated, and moving to two independent +counters later means rewriting every caller. + +Option B: no module-level API at all -- every caller instantiates. +""" + +from __future__ import annotations + +# Option A: the state is just ... lying there. +_count = 0 + + +def increment() -> int: + global _count + _count += 1 + return _count + + +# Option B: callers must build and thread their own instance. +class Counter: + def __init__(self) -> None: + self.count = 0 + + def increment(self) -> int: + self.count += 1 + return self.count + + +def main() -> None: + print(f"loose global: {increment()}, {increment()}") + counter = Counter() # every caller, everywhere, forever + print(f"DIY instance: {counter.increment()}") + + +if __name__ == "__main__": + main() diff --git a/patterns/python/prebound_method/pythonic.py b/patterns/python/prebound_method/pythonic.py new file mode 100644 index 0000000..c99ea67 --- /dev/null +++ b/patterns/python/prebound_method/pythonic.py @@ -0,0 +1,38 @@ +"""The Prebound Method pattern. + +One hidden instance built at import time; its bound methods become the +module's public functions. The class stays public for isolated state. +""" + +from __future__ import annotations + + +class Counter: + """An ordinary class; instantiable by anyone needing isolation.""" + + def __init__(self) -> None: + self.count = 0 + + def increment(self) -> int: + self.count += 1 + return self.count + + def peek(self) -> int: + return self.count + + +_instance = Counter() + +#: The pattern: module-level names bound to one instance's methods. +increment = _instance.increment +peek = _instance.peek + + +def main() -> None: + print(f"module API: {increment()}, {increment()}, peek={peek()}") + isolated = Counter() + print(f"isolated instance unaffected: {isolated.peek()}") + + +if __name__ == "__main__": + main() diff --git a/patterns/python/prebound_method/real_world.py b/patterns/python/prebound_method/real_world.py new file mode 100644 index 0000000..e6aef94 --- /dev/null +++ b/patterns/python/prebound_method/real_world.py @@ -0,0 +1,31 @@ +"""``random``: the stdlib's flagship prebound methods. + +``random.random`` and ``random.seed`` are bound methods of one hidden +``Random`` instance built when the module is imported. +""" + +from __future__ import annotations + +import random + + +def module_functions_share_one_instance() -> bool: + """Both prebound methods carry the same __self__.""" + a = getattr(random.random, "__self__", None) + b = getattr(random.seed, "__self__", None) + return a is not None and a is b and isinstance(a, random.Random) + + +def seeded_sequence(seed: int, n: int) -> list[float]: + """Seeding through one prebound method changes what the other returns.""" + random.seed(seed) + return [random.random() for _ in range(n)] + + +def main() -> None: + print(f"one hidden instance: {module_functions_share_one_instance()}") + print(f"reproducible: {seeded_sequence(42, 2) == seeded_sequence(42, 2)}") + + +if __name__ == "__main__": + main() diff --git a/patterns/python/prebound_method/tests/__init__.py b/patterns/python/prebound_method/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/patterns/python/prebound_method/tests/test_prebound_method.py b/patterns/python/prebound_method/tests/test_prebound_method.py new file mode 100644 index 0000000..9f87f68 --- /dev/null +++ b/patterns/python/prebound_method/tests/test_prebound_method.py @@ -0,0 +1,41 @@ +"""Behavioral tests for all three prebound-method variants.""" + +from typing import Any + +from patterns.python.prebound_method import naive, pythonic, real_world + + +class TestNaive: + def test_loose_global_counts(self) -> None: + start = naive.increment() + assert naive.increment() == start + 1 + + def test_diy_instances_are_isolated(self) -> None: + a, b = naive.Counter(), naive.Counter() + a.increment() + assert b.count == 0 + + +class TestPythonic: + def test_module_functions_share_the_hidden_instance(self) -> None: + before = pythonic.peek() + pythonic.increment() + assert pythonic.peek() == before + 1 + + def test_functions_are_bound_methods_of_one_instance(self) -> None: + increment: Any = pythonic.increment + peek: Any = pythonic.peek + assert increment.__self__ is peek.__self__ + + def test_public_class_gives_isolation(self) -> None: + isolated = pythonic.Counter() + pythonic.increment() + assert isolated.peek() == 0 + + +class TestRealWorld: + def test_random_module_is_prebound(self) -> None: + assert real_world.module_functions_share_one_instance() + + def test_seeding_is_shared_state(self) -> None: + assert real_world.seeded_sequence(7, 3) == real_world.seeded_sequence(7, 3) diff --git a/patterns/python/sentinel_object/README.md b/patterns/python/sentinel_object/README.md new file mode 100644 index 0000000..6c377b2 --- /dev/null +++ b/patterns/python/sentinel_object/README.md @@ -0,0 +1,45 @@ +--- +id: python/sentinel_object +name: Sentinel Object +aliases: [sentinel, missing-marker, null-object] +guide_url: https://python-patterns.guide/python/sentinel-object/ +problem: "Mark 'no value here' unambiguously when None itself is a legitimate value." +symptoms: ["None is a valid value", "distinguish missing from null", "default argument that could be None", "str.find returns -1"] +verdict: pythonic +caveats: + - "A sentinel must be compared with `is`, never `==` — its identity is its meaning." + - "Sentinel *values* like -1 (str.find) live inside the value's own type and eventually collide; a fresh object() cannot." + - "Fowler's Null Object pattern — a do-nothing stand-in with real methods — is the neighboring cure when callers would otherwise be littered with None checks." +stdlib_sightings: [dataclasses.MISSING, iter(callable, sentinel), str.find] +--- + +# Sentinel Object + +## Problem + +A cache stores `None` as a legitimate value; a keyword argument treats `None` +as meaningful. Now "the value is None" and "there is no value" collide, and +`get(...) or default` bugs follow. + +## Naive solution + +`naive.py` shows both classic failures: the in-band sentinel *value* +(`str.find`-style `-1` that arithmetic happily consumes), and `None`-as-missing +in a cache that stores `None`. + +## Pythonic solution + +A fresh `_MISSING = object()` is unforgeable: it lives in no domain, equals +nothing but itself, and is checked by identity. `pythonic.py` uses it for a +cache and a default argument, and includes a small Null Object — a real +do-nothing logger — for the case where callers shouldn't branch at all. + +## In the wild + +`dataclasses.MISSING` distinguishes "no default" from "default is None"; +two-argument `iter(read, b"")` takes an explicit sentinel that terminates +iteration; `str.find`'s `-1` survives as a cautionary in-band sentinel value. + +## Verdict + +**Pythonic.** One module-private `object()` per meaning, compared with `is`. diff --git a/patterns/python/sentinel_object/__init__.py b/patterns/python/sentinel_object/__init__.py new file mode 100644 index 0000000..965bf2f --- /dev/null +++ b/patterns/python/sentinel_object/__init__.py @@ -0,0 +1 @@ +"""Sentinel Object: an unforgeable marker for missing, when None is a real value.""" diff --git a/patterns/python/sentinel_object/naive.py b/patterns/python/sentinel_object/naive.py new file mode 100644 index 0000000..899e61b --- /dev/null +++ b/patterns/python/sentinel_object/naive.py @@ -0,0 +1,48 @@ +"""The failure modes sentinels fix. + +1. The in-band sentinel value: str.find's -1 is a legal integer, so forgetting + the check produces a *plausible* wrong answer instead of an error. +2. None-as-missing: a cache that stores None cannot tell a hit from a miss. +""" + +from __future__ import annotations + + +def last_char_before(text: str, needle: str) -> str: + """BUG (deliberate): when needle is absent, find() returns -1 and the + index silently becomes text[-2] -- plausible garbage, no exception.""" + position = text.find(needle) + return text[position - 1] + + +class NoneCache: + """A cache where storing None is indistinguishable from a miss.""" + + def __init__(self) -> None: + self._data: dict[str, object | None] = {} + + def put(self, key: str, value: object | None) -> None: + self._data[key] = value + + def get_or_compute(self, key: str, compute_calls: list[str]) -> object | None: + value = self._data.get(key) + if value is None: # ... but None might BE the cached value! + compute_calls.append(key) + value = None # pretend we recomputed + self._data[key] = value + return value + + +def main() -> None: + print(f"present: {last_char_before('hello', 'e')!r}") + print(f"absent -- plausible garbage: {last_char_before('hello', 'z')!r}") + cache = NoneCache() + calls: list[str] = [] + cache.put("k", None) + cache.get_or_compute("k", calls) + cache.get_or_compute("k", calls) + print(f"cached None recomputed every time: {calls}") + + +if __name__ == "__main__": + main() diff --git a/patterns/python/sentinel_object/pythonic.py b/patterns/python/sentinel_object/pythonic.py new file mode 100644 index 0000000..e094626 --- /dev/null +++ b/patterns/python/sentinel_object/pythonic.py @@ -0,0 +1,57 @@ +"""Sentinel objects, done right -- plus a small Null Object. + +``_MISSING = object()`` is unforgeable and out-of-band; identity comparison +makes the miss check exact even when None is stored. +""" + +from __future__ import annotations + +from collections.abc import Callable + +_MISSING = object() + + +class Cache: + """A cache where None is an ordinary, cacheable value.""" + + def __init__(self) -> None: + self._data: dict[str, object] = {} + + def put(self, key: str, value: object) -> None: + self._data[key] = value + + def get_or_compute(self, key: str, compute: Callable[[], object]) -> object: + value = self._data.get(key, _MISSING) + if value is _MISSING: # identity: the only correct sentinel check + value = compute() + self._data[key] = value + return value + + +def greet(name: str, greeting: object = _MISSING) -> str: + """Distinguish 'not passed' from 'passed None' in a default argument.""" + if greeting is _MISSING: + return f"hello {name}" + return f"{greeting} {name}" if greeting is not None else name + + +class NullLogger: + """Fowler's Null Object: a real object that intentionally does nothing, + so callers never branch on 'is there a logger?'.""" + + def log(self, message: str) -> None: + pass + + +def main() -> None: + cache = Cache() + cache.put("k", None) + calls: list[str] = [] + cache.get_or_compute("k", lambda: calls.append("computed")) + print(f"cached None respected (no recompute): {calls == []}") + print(greet("ada"), "|", greet("ada", None), "|", greet("ada", "yo")) + NullLogger().log("silently fine") + + +if __name__ == "__main__": + main() diff --git a/patterns/python/sentinel_object/real_world.py b/patterns/python/sentinel_object/real_world.py new file mode 100644 index 0000000..fc2bfbd --- /dev/null +++ b/patterns/python/sentinel_object/real_world.py @@ -0,0 +1,43 @@ +"""Sentinels in the stdlib. + +``dataclasses.MISSING`` separates "no default" from "default is None"; +two-argument ``iter(callable, sentinel)`` stops when the sentinel appears. +""" + +from __future__ import annotations + +import dataclasses +from dataclasses import dataclass, field, fields + + +@dataclass +class Config: + name: str + retries: int | None = None + tags: list[str] = field(default_factory=list) + + +def has_default(field_name: str) -> bool: + """MISSING lets introspection distinguish no-default from None-default.""" + for f in fields(Config): + if f.name == field_name: + return ( + f.default is not dataclasses.MISSING or f.default_factory is not dataclasses.MISSING + ) + raise KeyError(field_name) + + +def read_until_blank(chunks: list[str]) -> list[str]: + """iter(callable, sentinel): the empty string terminates the stream.""" + supply = iter(chunks).__next__ + return list(iter(supply, "")) + + +def main() -> None: + print(f"'name' has default: {has_default('name')}") + print(f"'retries' has default: {has_default('retries')}") + print(f"read until blank: {read_until_blank(['a', 'b', '', 'c'])}") + + +if __name__ == "__main__": + main() diff --git a/patterns/python/sentinel_object/tests/__init__.py b/patterns/python/sentinel_object/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/patterns/python/sentinel_object/tests/test_sentinel_object.py b/patterns/python/sentinel_object/tests/test_sentinel_object.py new file mode 100644 index 0000000..f9fcadc --- /dev/null +++ b/patterns/python/sentinel_object/tests/test_sentinel_object.py @@ -0,0 +1,56 @@ +"""Behavioral tests for all three sentinel-object variants.""" + +from patterns.python.sentinel_object import naive, pythonic, real_world + + +class TestNaive: + def test_in_band_sentinel_produces_plausible_garbage(self) -> None: + # The bug on display: absent needle silently indexes text[-2]. + assert naive.last_char_before("hello", "z") == "l" + + def test_none_cache_cannot_hold_none(self) -> None: + cache = naive.NoneCache() + calls: list[str] = [] + cache.put("k", None) + cache.get_or_compute("k", calls) + cache.get_or_compute("k", calls) + assert calls == ["k", "k"] # recomputed on every access + + +class TestPythonic: + def test_cache_distinguishes_stored_none_from_miss(self) -> None: + cache = pythonic.Cache() + cache.put("k", None) + calls: list[str] = [] + assert cache.get_or_compute("k", lambda: calls.append("x")) is None + assert calls == [] + + def test_miss_computes_once(self) -> None: + cache = pythonic.Cache() + calls: list[str] = [] + + def compute() -> object: + calls.append("x") + return 42 + + assert cache.get_or_compute("k", compute) == 42 + assert cache.get_or_compute("k", compute) == 42 + assert calls == ["x"] + + def test_default_argument_three_ways(self) -> None: + assert pythonic.greet("ada") == "hello ada" + assert pythonic.greet("ada", None) == "ada" + assert pythonic.greet("ada", "yo") == "yo ada" + + def test_null_object_never_raises(self) -> None: + pythonic.NullLogger().log("anything") + + +class TestRealWorld: + def test_missing_separates_no_default_from_none_default(self) -> None: + assert not real_world.has_default("name") + assert real_world.has_default("retries") + assert real_world.has_default("tags") + + def test_iter_with_sentinel_stops_at_blank(self) -> None: + assert real_world.read_until_blank(["a", "b", "", "c"]) == ["a", "b"] diff --git a/patterns/structural/__init__.py b/patterns/structural/__init__.py new file mode 100644 index 0000000..248d229 --- /dev/null +++ b/patterns/structural/__init__.py @@ -0,0 +1 @@ +"""structural patterns.""" diff --git a/patterns/structural/adapter/README.md b/patterns/structural/adapter/README.md new file mode 100644 index 0000000..e83cda4 --- /dev/null +++ b/patterns/structural/adapter/README.md @@ -0,0 +1,44 @@ +--- +id: structural/adapter +name: Adapter +aliases: [wrapper, translator] +guide_url: null +problem: "Make an existing class usable through the interface your code expects, without editing either side." +symptoms: ["third-party API has the wrong shape", "legacy interface mismatch", "make X look like Y", "can't edit the class I'm given"] +verdict: pythonic +caveats: + - "When the target interface is a single method, the adapter is just a function — don't build a class to hold one translation." + - "Duck typing means the adapter only needs the methods your code actually calls, not the adaptee's whole surface." +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. diff --git a/patterns/structural/adapter/__init__.py b/patterns/structural/adapter/__init__.py new file mode 100644 index 0000000..362c561 --- /dev/null +++ b/patterns/structural/adapter/__init__.py @@ -0,0 +1 @@ +"""Adapter: make a given class speak the interface your code expects.""" diff --git a/patterns/structural/adapter/naive.py b/patterns/structural/adapter/naive.py new file mode 100644 index 0000000..9f8b20f --- /dev/null +++ b/patterns/structural/adapter/naive.py @@ -0,0 +1,43 @@ +"""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/pythonic.py b/patterns/structural/adapter/pythonic.py new file mode 100644 index 0000000..d681bd4 --- /dev/null +++ b/patterns/structural/adapter/pythonic.py @@ -0,0 +1,49 @@ +"""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 new file mode 100644 index 0000000..4f177ea --- /dev/null +++ b/patterns/structural/adapter/real_world.py @@ -0,0 +1,24 @@ +"""``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/__init__.py b/patterns/structural/adapter/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/patterns/structural/adapter/tests/test_adapter.py b/patterns/structural/adapter/tests/test_adapter.py new file mode 100644 index 0000000..11e1be4 --- /dev/null +++ b/patterns/structural/adapter/tests/test_adapter.py @@ -0,0 +1,32 @@ +"""Behavioral tests for all three adapter variants.""" + +import io + +from patterns.structural.adapter import naive, pythonic, real_world + + +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 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 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) diff --git a/patterns/structural/bridge/README.md b/patterns/structural/bridge/README.md new file mode 100644 index 0000000..ed16c42 --- /dev/null +++ b/patterns/structural/bridge/README.md @@ -0,0 +1,46 @@ +--- +id: structural/bridge +name: Bridge +aliases: [abstraction-implementor] +guide_url: null +problem: "Let an abstraction and its implementation vary independently, instead of multiplying subclasses across both axes." +symptoms: ["two hierarchies multiplying", "shapes times renderers", "device times remote", "backend swappable under a stable front"] +verdict: prefer-alternative +caveats: + - "In Python the Bridge collapses into ordinary composition with dependency injection — hold the implementor as an attribute, pass it in." + - "The pattern's real lesson survives: name the two axes, give each its own small hierarchy (or set of callables), and connect them with one reference." +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` keeps the +two axes but needs no abstract bases: the renderer is a `Protocol`, shapes +are dataclasses holding one. + +## 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. diff --git a/patterns/structural/bridge/__init__.py b/patterns/structural/bridge/__init__.py new file mode 100644 index 0000000..7676ef1 --- /dev/null +++ b/patterns/structural/bridge/__init__.py @@ -0,0 +1 @@ +"""Bridge: decouple abstraction from implementation. Verdict: it is composition + DI.""" diff --git a/patterns/structural/bridge/naive.py b/patterns/structural/bridge/naive.py new file mode 100644 index 0000000..6080d6a --- /dev/null +++ b/patterns/structural/bridge/naive.py @@ -0,0 +1,54 @@ +"""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/pythonic.py b/patterns/structural/bridge/pythonic.py new file mode 100644 index 0000000..7310f7a --- /dev/null +++ b/patterns/structural/bridge/pythonic.py @@ -0,0 +1,42 @@ +"""The Bridge without ceremony: composition plus an injected dependency. + +A Protocol types the implementor side; shapes are dataclasses holding one. +Nothing here is special -- and that is the point. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Protocol + + +class Renderer(Protocol): + def circle(self, radius: float) -> str: ... + + +class Vector: + def circle(self, radius: float) -> str: + return f"" + + +class Raster: + def circle(self, radius: float) -> str: + return f"pixels for a circle of radius {radius}" + + +@dataclass(frozen=True) +class Circle: + radius: float + renderer: Renderer + + def draw(self) -> str: + return self.renderer.circle(self.radius) + + +def main() -> None: + print(Circle(2.0, Vector()).draw()) + print(Circle(2.0, Raster()).draw()) + + +if __name__ == "__main__": + main() diff --git a/patterns/structural/bridge/real_world.py b/patterns/structural/bridge/real_world.py new file mode 100644 index 0000000..cb7b6c9 --- /dev/null +++ b/patterns/structural/bridge/real_world.py @@ -0,0 +1,39 @@ +"""``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/__init__.py b/patterns/structural/bridge/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/patterns/structural/bridge/tests/test_bridge.py b/patterns/structural/bridge/tests/test_bridge.py new file mode 100644 index 0000000..fb41c1d --- /dev/null +++ b/patterns/structural/bridge/tests/test_bridge.py @@ -0,0 +1,30 @@ +"""Behavioral tests for all three bridge variants.""" + +from patterns.structural.bridge import naive, pythonic, real_world + + +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 TestPythonic: + def test_injected_renderer_decides_output(self) -> None: + assert pythonic.Circle(2.0, pythonic.Vector()).draw() == "" + assert "pixels" in pythonic.Circle(2.0, pythonic.Raster()).draw() + + def test_any_duck_typed_implementor_works(self) -> None: + class Ascii: + def circle(self, radius: float) -> str: + return "o" * int(radius) + + assert pythonic.Circle(3.0, Ascii()).draw() == "ooo" + + +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"] diff --git a/patterns/structural/composite/README.md b/patterns/structural/composite/README.md new file mode 100644 index 0000000..f05657c --- /dev/null +++ b/patterns/structural/composite/README.md @@ -0,0 +1,46 @@ +--- +id: structural/composite +name: Composite +aliases: [tree, part-whole] +guide_url: https://python-patterns.guide/gang-of-four/composite/ +problem: "Let callers treat a single object and a whole tree of objects through one interface." +symptoms: ["tree structure", "files and directories", "nested groups", "recursive totals", "uniform leaf and container API"] +verdict: pythonic +caveats: + - "Don't force leaves to carry child-management methods (add/remove) just to match the container — the guide sides with interface honesty over uniformity." + - "With duck typing you don't need a shared base class at all; share one only when it earns its keep." +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. diff --git a/patterns/structural/composite/__init__.py b/patterns/structural/composite/__init__.py new file mode 100644 index 0000000..5f8a108 --- /dev/null +++ b/patterns/structural/composite/__init__.py @@ -0,0 +1 @@ +"""Composite: one interface for an object and a tree of objects.""" diff --git a/patterns/structural/composite/naive.py b/patterns/structural/composite/naive.py new file mode 100644 index 0000000..de1738d --- /dev/null +++ b/patterns/structural/composite/naive.py @@ -0,0 +1,60 @@ +"""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/pythonic.py b/patterns/structural/composite/pythonic.py new file mode 100644 index 0000000..8434007 --- /dev/null +++ b/patterns/structural/composite/pythonic.py @@ -0,0 +1,53 @@ +"""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 new file mode 100644 index 0000000..ec002cd --- /dev/null +++ b/patterns/structural/composite/real_world.py @@ -0,0 +1,34 @@ +"""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/__init__.py b/patterns/structural/composite/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/patterns/structural/composite/tests/test_composite.py b/patterns/structural/composite/tests/test_composite.py new file mode 100644 index 0000000..4965088 --- /dev/null +++ b/patterns/structural/composite/tests/test_composite.py @@ -0,0 +1,40 @@ +"""Behavioral tests for all three composite variants.""" + +import pytest + +from patterns.structural.composite import naive, pythonic, real_world + + +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_leaf_refuses_children(self) -> None: + with pytest.raises(TypeError): + naive.Circle("sun").add(naive.Circle("moon")) + + +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") + + def test_empty_directory_totals_zero(self) -> None: + assert pythonic.Directory("empty").total_bytes() == 0 + + +class TestRealWorld: + def test_uniform_traversal_counts_all_depths(self) -> None: + assert real_world.count_circles(real_world.build_scene()) == 3 diff --git a/patterns/structural/decorator/README.md b/patterns/structural/decorator/README.md new file mode 100644 index 0000000..9ee0248 --- /dev/null +++ b/patterns/structural/decorator/README.md @@ -0,0 +1,47 @@ +--- +id: structural/decorator +name: Decorator +aliases: [wrapper] +guide_url: https://python-patterns.guide/gang-of-four/decorator-pattern/ +problem: "Add behavior around an object or callable without editing it or subclassing it." +symptoms: ["logging every call", "caching results", "retry wrapper", "timing calls", "add behavior without subclassing"] +verdict: pythonic +caveats: + - "The GoF pattern (wrapping objects) and Python's @decorator syntax (wrapping callables) are cousins, not the same thing — this unit shows both." + - "Always apply functools.wraps to function wrappers, or you destroy the wrapped function's name, docstring, and introspection." + - "The guide's caveat: an object wrapper doesn't survive isinstance checks or identity comparisons — wrapping doesn't actually make you the wrapped thing." +stdlib_sightings: [functools.wraps, functools.lru_cache, contextlib.contextmanager] +--- + +# 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. diff --git a/patterns/structural/decorator/__init__.py b/patterns/structural/decorator/__init__.py new file mode 100644 index 0000000..51ffe68 --- /dev/null +++ b/patterns/structural/decorator/__init__.py @@ -0,0 +1 @@ +"""Decorator: add behavior around objects or callables without editing them.""" diff --git a/patterns/structural/decorator/naive.py b/patterns/structural/decorator/naive.py new file mode 100644 index 0000000..5777edd --- /dev/null +++ b/patterns/structural/decorator/naive.py @@ -0,0 +1,42 @@ +"""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/pythonic.py b/patterns/structural/decorator/pythonic.py new file mode 100644 index 0000000..327b49f --- /dev/null +++ b/patterns/structural/decorator/pythonic.py @@ -0,0 +1,61 @@ +"""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 new file mode 100644 index 0000000..b3bb206 --- /dev/null +++ b/patterns/structural/decorator/real_world.py @@ -0,0 +1,25 @@ +"""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/__init__.py b/patterns/structural/decorator/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/patterns/structural/decorator/tests/test_decorator.py b/patterns/structural/decorator/tests/test_decorator.py new file mode 100644 index 0000000..c3b5db9 --- /dev/null +++ b/patterns/structural/decorator/tests/test_decorator.py @@ -0,0 +1,49 @@ +"""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/facade/README.md b/patterns/structural/facade/README.md new file mode 100644 index 0000000..e9663c8 --- /dev/null +++ b/patterns/structural/facade/README.md @@ -0,0 +1,43 @@ +--- +id: structural/facade +name: Facade +aliases: [front-door, simplified-interface] +guide_url: null +problem: "Give a complicated subsystem one simple entry point for the common case." +symptoms: ["five-step setup for one common task", "callers copy-paste the same subsystem dance", "wrap this messy API"] +verdict: pythonic +caveats: + - "In Python a facade is usually a module-level function — a class with one method is a function wearing a costume." + - "A facade simplifies; it must not imprison. Leave the subsystem importable for callers who need the full controls." +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` wraps a fiddly multi-step text +pipeline behind one call with sensible defaults — full controls still +importable beside it. + +## 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. diff --git a/patterns/structural/facade/__init__.py b/patterns/structural/facade/__init__.py new file mode 100644 index 0000000..6905b54 --- /dev/null +++ b/patterns/structural/facade/__init__.py @@ -0,0 +1 @@ +"""Facade: one simple entry point in front of a subsystem.""" diff --git a/patterns/structural/facade/naive.py b/patterns/structural/facade/naive.py new file mode 100644 index 0000000..8ee26bc --- /dev/null +++ b/patterns/structural/facade/naive.py @@ -0,0 +1,53 @@ +"""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/pythonic.py b/patterns/structural/facade/pythonic.py new file mode 100644 index 0000000..e713a9b --- /dev/null +++ b/patterns/structural/facade/pythonic.py @@ -0,0 +1,40 @@ +"""The pythonic facade: a module-level function with good defaults. + +The subsystem (tokenize / count / rank) stays public for callers who need +the controls; ``top_words`` is the one-call common case. +""" + +from __future__ import annotations + +import re +from collections import Counter + +STOPWORDS = frozenset({"the", "a", "an", "and", "of", "to", "in"}) + + +def tokenize(text: str) -> list[str]: + return re.findall(r"[a-z']+", text.lower()) + + +def count(words: list[str], *, drop_stopwords: bool = True) -> Counter[str]: + kept = [w for w in words if not (drop_stopwords and w in STOPWORDS)] + return Counter(kept) + + +def rank(counts: Counter[str], n: int) -> list[tuple[str, int]]: + return counts.most_common(n) + + +def top_words(text: str, n: int = 3) -> list[tuple[str, int]]: + """The facade: the whole pipeline, one call, sensible defaults.""" + return rank(count(tokenize(text)), n) + + +def main() -> None: + text = "the cat and the hat and the cat in the hat" + print(f"facade: {top_words(text, 2)}") + print(f"full controls: {rank(count(tokenize(text), drop_stopwords=False), 1)}") + + +if __name__ == "__main__": + main() diff --git a/patterns/structural/facade/real_world.py b/patterns/structural/facade/real_world.py new file mode 100644 index 0000000..1d04a09 --- /dev/null +++ b/patterns/structural/facade/real_world.py @@ -0,0 +1,31 @@ +"""``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/__init__.py b/patterns/structural/facade/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/patterns/structural/facade/tests/test_facade.py b/patterns/structural/facade/tests/test_facade.py new file mode 100644 index 0000000..b158189 --- /dev/null +++ b/patterns/structural/facade/tests/test_facade.py @@ -0,0 +1,35 @@ +"""Behavioral tests for all three facade variants.""" + +import tempfile +import zipfile +from pathlib import Path + +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 test_facade_covers_the_common_case(self) -> None: + text = "the cat and the hat and the cat in the hat" + assert pythonic.top_words(text, 2) == [("cat", 2), ("hat", 2)] + + def test_subsystem_stays_available_for_full_control(self) -> None: + counts = pythonic.count(["the", "cat"], drop_stopwords=False) + assert counts["the"] == 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/flyweight/README.md b/patterns/structural/flyweight/README.md new file mode 100644 index 0000000..965d4b0 --- /dev/null +++ b/patterns/structural/flyweight/README.md @@ -0,0 +1,44 @@ +--- +id: structural/flyweight +name: Flyweight +aliases: [interning, shared-instances] +guide_url: https://python-patterns.guide/gang-of-four/flyweight/ +problem: "Support huge numbers of fine-grained objects by sharing immutable instances instead of duplicating them." +symptoms: ["millions of small objects", "memory pressure from duplicates", "interning", "shared immutable state"] +verdict: use-with-care +caveats: + - "Flyweights must be immutable — a mutated shared instance corrupts every holder at once." + - "The guide notes Python's twist: hide the sharing in the constructor via __new__, or expose it as a factory function; the factory is easier to reason about." + - "Measure first: CPython already interns small ints and many strings, so your duplicates may not exist." +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. diff --git a/patterns/structural/flyweight/__init__.py b/patterns/structural/flyweight/__init__.py new file mode 100644 index 0000000..eb8fb36 --- /dev/null +++ b/patterns/structural/flyweight/__init__.py @@ -0,0 +1 @@ +"""Flyweight: share immutable instances rather than duplicating them.""" diff --git a/patterns/structural/flyweight/naive.py b/patterns/structural/flyweight/naive.py new file mode 100644 index 0000000..2304261 --- /dev/null +++ b/patterns/structural/flyweight/naive.py @@ -0,0 +1,46 @@ +"""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/pythonic.py b/patterns/structural/flyweight/pythonic.py new file mode 100644 index 0000000..0e2e55b --- /dev/null +++ b/patterns/structural/flyweight/pythonic.py @@ -0,0 +1,49 @@ +"""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', '♥')``.""" + + _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 new file mode 100644 index 0000000..96dd0ca --- /dev/null +++ b/patterns/structural/flyweight/real_world.py @@ -0,0 +1,32 @@ +"""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/__init__.py b/patterns/structural/flyweight/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/patterns/structural/flyweight/tests/test_flyweight.py b/patterns/structural/flyweight/tests/test_flyweight.py new file mode 100644 index 0000000..3cc24c2 --- /dev/null +++ b/patterns/structural/flyweight/tests/test_flyweight.py @@ -0,0 +1,35 @@ +"""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/proxy/README.md b/patterns/structural/proxy/README.md new file mode 100644 index 0000000..8e5d1e6 --- /dev/null +++ b/patterns/structural/proxy/README.md @@ -0,0 +1,44 @@ +--- +id: structural/proxy +name: Proxy +aliases: [surrogate, virtual-proxy, protection-proxy] +guide_url: null +problem: "Stand in for another object to control access to it — deferring, guarding, or instrumenting the real thing." +symptoms: ["lazy expensive construction", "access control around an object", "remote object stand-in", "count or log attribute access"] +verdict: use-with-care +caveats: + - "A proxy is not the object: isinstance checks, identity comparisons, and dunder lookups (which bypass __getattr__) all see through the disguise." + - "For 'compute this attribute lazily once', functools.cached_property is the pattern at the right size — no proxy class needed." +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). diff --git a/patterns/structural/proxy/__init__.py b/patterns/structural/proxy/__init__.py new file mode 100644 index 0000000..f3f802a --- /dev/null +++ b/patterns/structural/proxy/__init__.py @@ -0,0 +1 @@ +"""Proxy: a stand-in that controls access to the real object.""" diff --git a/patterns/structural/proxy/naive.py b/patterns/structural/proxy/naive.py new file mode 100644 index 0000000..a886ffc --- /dev/null +++ b/patterns/structural/proxy/naive.py @@ -0,0 +1,49 @@ +"""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/pythonic.py b/patterns/structural/proxy/pythonic.py new file mode 100644 index 0000000..2adb36b --- /dev/null +++ b/patterns/structural/proxy/pythonic.py @@ -0,0 +1,60 @@ +"""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 new file mode 100644 index 0000000..c98fd22 --- /dev/null +++ b/patterns/structural/proxy/real_world.py @@ -0,0 +1,40 @@ +"""``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/__init__.py b/patterns/structural/proxy/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/patterns/structural/proxy/tests/test_proxy.py b/patterns/structural/proxy/tests/test_proxy.py new file mode 100644 index 0000000..9875110 --- /dev/null +++ b/patterns/structural/proxy/tests/test_proxy.py @@ -0,0 +1,48 @@ +"""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() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..2ab0d70 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,65 @@ +[project] +name = "python-design-patterns" +version = "0.1.0" +description = "Design patterns in Python: naive, pythonic, and real-world examples, with an MCP server for agents." +readme = "README.md" +license = { file = "LICENSE" } +authors = [{ name = "SuperElectron" }] +requires-python = ">=3.11" +keywords = ["design-patterns", "gang-of-four", "mcp", "python-patterns"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Software Development :: Libraries", + "Topic :: Education", +] +dependencies = [ + "pyyaml>=6.0", +] + +[project.urls] +Homepage = "https://github.com/SuperElectron/python-design-patterns" +Reference = "https://python-patterns.guide/" + +[dependency-groups] +dev = [ + "pytest>=8.0", + "pytest-cov>=5.0", + "ruff>=0.8", + "mypy>=1.13", + "types-pyyaml>=6.0", + "pytest-asyncio>=0.24", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/design_patterns"] + +[tool.ruff] +line-length = 100 +target-version = "py311" +src = ["src", "patterns", "tests"] + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "UP", "B", "SIM", "RUF"] + +[tool.mypy] +strict = true +python_version = "3.11" +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] +skip_empty = true diff --git a/src/design_patterns/__init__.py b/src/design_patterns/__init__.py new file mode 100644 index 0000000..e313c59 --- /dev/null +++ b/src/design_patterns/__init__.py @@ -0,0 +1,3 @@ +"""Design patterns in Python: catalog loader and shared utilities.""" + +__version__ = "0.1.0" diff --git a/src/design_patterns/catalog.py b/src/design_patterns/catalog.py new file mode 100644 index 0000000..273038a --- /dev/null +++ b/src/design_patterns/catalog.py @@ -0,0 +1,168 @@ +"""Load the pattern catalog from ``patterns///README.md`` frontmatter. + +Each unit's README carries a YAML frontmatter block; this module parses and +validates it into typed :class:`Pattern` objects. The MCP server, the +generated README table, and CI's schema check all consume this loader, so a +schema violation here fails loudly rather than propagating bad data. +""" + +from __future__ import annotations + +import json +from dataclasses import asdict, dataclass, field +from pathlib import Path +from typing import Literal, get_args + +Verdict = Literal["pythonic", "use-with-care", "prefer-alternative"] +VariantName = Literal["naive", "pythonic", "real_world"] + +VERDICTS: tuple[str, ...] = get_args(Verdict) +VARIANTS: tuple[str, ...] = get_args(VariantName) + +_REQUIRED_KEYS = frozenset({"id", "name", "guide_url", "problem", "symptoms", "verdict", "caveats"}) + + +class CatalogError(ValueError): + """A pattern unit violates the catalog schema.""" + + +@dataclass(frozen=True) +class Pattern: + """One validated pattern unit.""" + + id: str + name: str + problem: str + verdict: Verdict + aliases: tuple[str, ...] = () + guide_url: str | None = None + symptoms: tuple[str, ...] = () + caveats: tuple[str, ...] = () + stdlib_sightings: tuple[str, ...] = () + prose: str = field(default="", repr=False, compare=False) + path: Path = field(default_factory=Path, repr=False, compare=False) + + @property + def group(self) -> str: + return self.id.split("/", 1)[0] + + @property + def slug(self) -> str: + return self.id.split("/", 1)[1] + + def variants(self) -> dict[str, Path]: + """The example files this unit actually ships.""" + return {v: self.path / f"{v}.py" for v in VARIANTS if (self.path / f"{v}.py").is_file()} + + +def _split_frontmatter(text: str, readme: Path) -> tuple[str, str]: + if not text.startswith("---\n"): + raise CatalogError(f"{readme}: README must start with a '---' frontmatter block") + try: + frontmatter, prose = text[4:].split("\n---\n", 1) + except ValueError as exc: + raise CatalogError(f"{readme}: unterminated frontmatter block") from exc + return frontmatter, prose.strip() + + +def _str_tuple(raw: object, key: str, readme: Path) -> tuple[str, ...]: + if raw is None: + return () + if not isinstance(raw, list): + raise CatalogError(f"{readme}: '{key}' must be a list") + return tuple(str(item) for item in raw) + + +def _parse_pattern(readme: Path, root: Path) -> Pattern: + import yaml + + frontmatter, prose = _split_frontmatter(readme.read_text(encoding="utf-8"), readme) + try: + data = yaml.safe_load(frontmatter) + except yaml.YAMLError as exc: + raise CatalogError(f"{readme}: invalid YAML frontmatter: {exc}") from exc + if not isinstance(data, dict): + raise CatalogError(f"{readme}: frontmatter must be a mapping") + + missing = _REQUIRED_KEYS - data.keys() + if missing: + raise CatalogError(f"{readme}: missing frontmatter keys: {sorted(missing)}") + + unit_dir = readme.parent + expected_id = f"{unit_dir.parent.name}/{unit_dir.name}" + if data["id"] != expected_id: + raise CatalogError(f"{readme}: id {data['id']!r} != directory {expected_id!r}") + + verdict = data["verdict"] + if verdict not in VERDICTS: + raise CatalogError(f"{readme}: verdict {verdict!r} not one of {VERDICTS}") + + guide_url = data["guide_url"] + if guide_url is not None and not str(guide_url).startswith("https://"): + raise CatalogError(f"{readme}: guide_url must be https or null") + + problem = str(data["problem"]).strip() + if not problem: + raise CatalogError(f"{readme}: 'problem' must be a non-empty sentence") + + pattern = Pattern( + id=str(data["id"]), + name=str(data["name"]), + problem=problem, + verdict=verdict, + aliases=_str_tuple(data.get("aliases"), "aliases", readme), + guide_url=None if guide_url is None else str(guide_url), + symptoms=_str_tuple(data["symptoms"], "symptoms", readme), + caveats=_str_tuple(data["caveats"], "caveats", readme), + stdlib_sightings=_str_tuple(data.get("stdlib_sightings"), "stdlib_sightings", readme), + prose=prose, + path=unit_dir, + ) + if not pattern.variants(): + raise CatalogError(f"{readme}: unit ships no naive/pythonic/real_world example") + return pattern + + +@dataclass(frozen=True) +class Catalog: + """All validated pattern units, ordered by id.""" + + patterns: tuple[Pattern, ...] + + def get(self, pattern_id: str) -> Pattern: + for pattern in self.patterns: + if pattern.id == pattern_id: + return pattern + raise KeyError(pattern_id) + + def ids(self) -> tuple[str, ...]: + return tuple(p.id for p in self.patterns) + + def to_json(self) -> str: + """The ``catalog://index`` payload: everything except prose and paths.""" + entries = [] + for p in self.patterns: + entry = asdict(p) + del entry["prose"], entry["path"] + entry["variants"] = sorted(p.variants()) + entries.append(entry) + return json.dumps(entries, indent=2) + + +def find_patterns_root(start: Path | None = None) -> Path: + """Locate the ``patterns/`` directory from a file inside the repo.""" + here = (start or Path(__file__)).resolve() + for parent in [here, *here.parents]: + candidate = parent / "patterns" + if candidate.is_dir(): + return candidate + raise CatalogError(f"no patterns/ directory above {here}") + + +def load_catalog(root: Path | None = None) -> Catalog: + """Parse and validate every unit under ``root`` (default: the repo's patterns/).""" + patterns_root = root if root is not None else find_patterns_root() + readmes = sorted(patterns_root.glob("*/*/README.md")) + if not readmes: + raise CatalogError(f"no pattern units found under {patterns_root}") + return Catalog(patterns=tuple(_parse_pattern(r, patterns_root) for r in readmes)) diff --git a/structural/composite.py b/structural/composite.py deleted file mode 100644 index b333ea2..0000000 --- a/structural/composite.py +++ /dev/null @@ -1,42 +0,0 @@ -""" -* Composite Pattern -source: https://python-patterns.guide/gang-of-four/composite/ -""" - - -class Widget(object): - - def __init__(self, name): - self.name = name - - def children(self): - return [] - - -class Frame(Widget): - def __init__(self, child_widgets): - self.child_widgets = child_widgets - - def children(self): - return self.child_widgets - - -class Label(Widget): - def __init__(self, text): - self.text = text - - -def main(): - spacer = "=" * 20 - print(spacer) - - watch = Widget(name="watch") - frame = Frame(watch) - - print(watch.name) - print(spacer) - print(frame.children()) - - -if __name__ == "__main__": - main() diff --git a/structural/decorator.py b/structural/decorator.py deleted file mode 100644 index a4f770c..0000000 --- a/structural/decorator.py +++ /dev/null @@ -1,65 +0,0 @@ -""" -*What is this pattern about? -The Decorator pattern is used to dynamically add a new feature to an -object without changing its implementation. It differs from -inheritance because the new feature is added only to that particular -object, not to the entire subclass. -*What does this example do? -This example shows a way to add formatting options (boldface and -italic) to a text by appending the corresponding tags ( and -). Also, we can see that decorators can be applied one after the other, -since the original text is passed to the bold wrapper, which in turn -is passed to the italic wrapper. -*Where is the pattern used practically? -The Grok framework uses decorators to add functionalities to methods, -like permissions or subscription to an event: -http://grok.zope.org/doc/current/reference/decorators.html -*References: -https://sourcemaking.com/design_patterns/decorator -*TL;DR -Adds behaviour to object without affecting its class. -""" - - -class TextTag: - """Represents a base text tag""" - - def __init__(self, text): - self._text = text - - def render(self): - return self._text - - -class BoldWrapper(TextTag): - """Wraps a tag in """ - - def __init__(self, wrapped): - self._wrapped = wrapped - - def render(self): - return "{}".format(self._wrapped.render()) - - -class ItalicWrapper(TextTag): - """Wraps a tag in """ - - def __init__(self, wrapped): - self._wrapped = wrapped - - def render(self): - return "{}".format(self._wrapped.render()) - - -if __name__ == '__main__': - simple_hello = TextTag("hello, world!") - special_hello = ItalicWrapper(BoldWrapper(simple_hello)) - print("before:", simple_hello.render()) - print("after:", special_hello.render()) - - -""" -OUTPUT -before: hello, world! -after: hello, world! -""" diff --git a/structural/decorator_1.py b/structural/decorator_1.py deleted file mode 100644 index bd532a2..0000000 --- a/structural/decorator_1.py +++ /dev/null @@ -1,37 +0,0 @@ -# Fluent Python by Luciano Ramalho -# When python executes decorators -# Import time versus run time - -registry = [] - - -def register(func): - print('import time: running register(%s)' % func) - registry.append(func) - return func - - -@register -def f1(): - print('running f1()') - - -@register -def f2(): - print('running f2()') - - -def f3(): - print('running f3()') - - -def main(): - print('runtime: running main()') - print('registry.append(func) was imported twice: registry ->', registry) - f1() - f2() - f3() - - -if __name__ == '__main__': - main() diff --git a/structural/decorator_2.py b/structural/decorator_2.py deleted file mode 100644 index 3541dee..0000000 --- a/structural/decorator_2.py +++ /dev/null @@ -1,114 +0,0 @@ -# #1 goodClock() uses the functools.wraps decorator to copy the relevant attributes from func to clocked -# #2 @clock() has a parametized registration decorator -import time -import functools - - -def okClock(func): - """ this CANNOT accept keyword arguments """ - """ this DOES mask __doc__ and __name__ of decorated function """ - def clocked(*args): - t0 = time.perf_counter() - result = func(*args) - elapsed = time.perf_counter() - t0 - name = func.__name__ - arg_str = ', '.join(repr(arg) for arg in args) - print('[%0.8fs] %s(%s) -> %r' % (elapsed, name, arg_str, result)) - return result - return clocked - - -def goodClock(func): - """ this CAN accept keyword arguments """ - """ this DOES NOT mask __doc__ and __name__ of decorated function """ - @functools.wraps(func) - def clocked(*args, **kwargs): - t0 = time.time() - result = func(*args, **kwargs) - elapsed = time.time() - t0 - name = func.__name__ - arg_lst = [] - if args: - arg_lst.append(', '.join(repr(arg) for arg in args)) - if kwargs: - pairs = ['%s=%r' % (k, w) for k, w in sorted(kwargs.items())] - arg_lst.append(', '.join(pairs)) - - arg_str = ', '.join(arg_lst) - print('[%0.8fs] %s(%s) -> %r ' % (elapsed, name, arg_str, result)) - return result - return clocked - - -@okClock -def fake(): - return 10 - - -@okClock -def factorial(n): - return 1 if n < 2 else n * factorial(n - 1) - - -@goodClock -def goodFactorial(n): - return 1 if n < 2 else n * factorial(n - 1) - - -@goodClock -def goodFake(): - return 10 - - -# @clock() is a parametized registration decorator -DEFAULT_FMT = '[{elapsed:0.8f}s] {name}({args}) -> {result}' - - -def clock(fmt=DEFAULT_FMT): - def decorate(func): - def clocked(*_args): - t0 = time.time() - _result = func(*_args) - elapsed = time.time() - t0 - name = func.__name__ - args = ', '.join(repr(arg) for arg in _args) - result = repr(_result) - print(fmt.format(**locals())) - return _result - return clocked - return decorate - - -@clock() -def snooze1(seconds): - """ IS NOT making use of parameterization """ - time.sleep(seconds) - - -@clock('{name}: {elapsed}s') -def snooze2(seconds): - """ IS making use of parameterization """ - time.sleep(seconds) - - -if __name__ == '__main__': - print('*' * 10) - print('okClock usage: basic setup') - fake() - print('*' * 10) - factorial(6) - print('*' * 10) - print('goodClock usage: @functools.wraps(func) used around inner function') - print('*' * 10) - goodFake() - print('*' * 10) - goodFactorial(6) - print('*' * 10) - print('clock usage: no parameterization') - print('*' * 10) - for i in range(3): - snooze1(.123) - print('*' * 10) - print('clock usage: with parameterization') - for i in range(3): - snooze2(.123) diff --git a/structural/decorator_3.py b/structural/decorator_3.py deleted file mode 100644 index 0aa33e8..0000000 --- a/structural/decorator_3.py +++ /dev/null @@ -1,40 +0,0 @@ -# Borrowed from: Fluent Python by Luciano Ramalho - -from functools import singledispatch -from collections import abc -import numbers -import html - -# making use of @singledispatch decorator to handle different input types -@singledispatch -def htmlize(obj): - content = html.escape(repr(obj)) - return '
{}
'.format(content) - - -@htmlize.register(str) -def _(text): - content = html.escape(text).replace('\n', '
\n') - return '

{0}

'.format(content) - - -@htmlize.register(numbers.Integral) -def _(n): - return '
{0} (0x{0:x})
'.format(n) - - -@htmlize.register(tuple) -@htmlize.register(abc.MutableSequence) -def _(seq): - inner = '\n
  • '.join(htmlize(item) for item in seq) - return '
      \n
    • ' + inner + '
    • \n
    ' - - -if __name__ == '__main__': - print('making use of @singledispatch decorator to handle different input types') - print('*' * 20) - print(htmlize('hello world')) - print('*' * 20) - print(htmlize(55)) - print('*' * 20) - print(htmlize((1, 'pickle', 'hello world'))) diff --git a/structural/flyweight.py b/structural/flyweight.py deleted file mode 100644 index 5488ea1..0000000 --- a/structural/flyweight.py +++ /dev/null @@ -1,74 +0,0 @@ -""" -*What is this pattern about? -This pattern aims to minimise the number of objects that are needed by -a program at run-time. A Flyweight is an object shared by multiple -contexts, and is indistinguishable from an object that is not shared. -The state of a Flyweight should not be affected by it's context, this -is known as its intrinsic state. The decoupling of the objects state -from the object's context, allows the Flyweight to be shared. -*What does this example do? -The example below sets-up an 'object pool' which stores initialised -objects. When a 'Card' is created it first checks to see if it already -exists instead of creating a new one. This aims to reduce the number of -objects initialised by the program. -*References: -http://codesnipers.com/?q=python-flyweights -https://python-patterns.guide/gang-of-four/flyweight/ -*Examples in Python ecosystem: -https://docs.python.org/3/library/sys.html#sys.intern -*TL;DR -Minimizes memory usage by sharing data with other similar objects. -""" - -import weakref - - -class Card: - """The Flyweight""" - - # Could be a simple dict. - # With WeakValueDictionary garbage collection can reclaim the object - # when there are no other references to it. - _pool = weakref.WeakValueDictionary() - - def __new__(cls, value, suit): - # If the object exists in the pool - just return it - obj = cls._pool.get(value + suit) - # otherwise - create new one (and add it to the pool) - if obj is None: - obj = object.__new__(Card) - cls._pool[value + suit] = obj - # This row does the part we usually see in `__init__` - obj.value, obj.suit = value, suit - return obj - - # If you uncomment `__init__` and comment-out `__new__` - - # Card becomes normal (non-flyweight). - # def __init__(self, value, suit): - # self.value, self.suit = value, suit - - def __repr__(self): - return "".format(self.value, self.suit) - - -def main(): - c1 = Card('9', 'h') - c2 = Card('9', 'h') - c1, c2 - # (, ) - c1 == c2 - # True - c1 is c2 - # True - c1.new_attr = 'temp' - c3 = Card('9', 'h') - print(hasattr(c3, 'new_attr')) - # True - Card._pool.clear() - c4 = Card('9', 'h') - print(hasattr(c4, 'new_attr')) - # False - - -if __name__ == "__main__": - main() diff --git a/tests/test_catalog.py b/tests/test_catalog.py new file mode 100644 index 0000000..cfc3aef --- /dev/null +++ b/tests/test_catalog.py @@ -0,0 +1,103 @@ +"""Catalog loader: round-trips the real catalog and fails loudly on bad units.""" + +import json +from pathlib import Path + +import pytest + +from design_patterns.catalog import ( + VERDICTS, + CatalogError, + find_patterns_root, + load_catalog, +) + + +class TestRealCatalog: + def test_loads_all_units(self) -> None: + catalog = load_catalog() + assert len(catalog.patterns) == 32 + assert "structural/decorator" in catalog.ids() + + def test_every_unit_ships_all_three_variants(self) -> None: + for pattern in load_catalog().patterns: + assert sorted(pattern.variants()) == ["naive", "pythonic", "real_world"], pattern.id + + def test_verdicts_are_from_the_vocabulary(self) -> None: + for pattern in load_catalog().patterns: + assert pattern.verdict in VERDICTS + + def test_get_and_group_slug(self) -> None: + pattern = load_catalog().get("creational/singleton") + assert (pattern.group, pattern.slug) == ("creational", "singleton") + assert pattern.guide_url is not None and pattern.guide_url.startswith("https://") + + def test_get_unknown_id_raises(self) -> None: + with pytest.raises(KeyError): + load_catalog().get("nope/nothing") + + def test_index_json_round_trips(self) -> None: + entries = json.loads(load_catalog().to_json()) + 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) + + +def _write_unit(root: Path, group: str, slug: str, frontmatter: str, body: str = "# x") -> None: + unit = root / group / slug + unit.mkdir(parents=True) + (unit / "README.md").write_text(f"---\n{frontmatter}\n---\n\n{body}\n") + (unit / "pythonic.py").write_text("def main() -> None: ...\n") + + +GOOD = """\ +id: creational/thing +name: Thing +guide_url: null +problem: "Build a thing." +symptoms: ["thing needed"] +verdict: pythonic +caveats: []""" + + +class TestValidation: + def test_minimal_valid_unit_loads(self, tmp_path: Path) -> None: + _write_unit(tmp_path, "creational", "thing", GOOD) + catalog = load_catalog(tmp_path) + assert catalog.get("creational/thing").verdict == "pythonic" + + def test_missing_key_fails(self, tmp_path: Path) -> None: + _write_unit(tmp_path, "creational", "thing", GOOD.replace('problem: "Build a thing."', "")) + with pytest.raises(CatalogError, match="missing frontmatter keys"): + load_catalog(tmp_path) + + def test_id_directory_mismatch_fails(self, tmp_path: Path) -> None: + _write_unit(tmp_path, "creational", "other", GOOD) + with pytest.raises(CatalogError, match="!= directory"): + load_catalog(tmp_path) + + def test_unknown_verdict_fails(self, tmp_path: Path) -> None: + _write_unit(tmp_path, "creational", "thing", GOOD.replace("pythonic", "amazing")) + with pytest.raises(CatalogError, match="verdict"): + load_catalog(tmp_path) + + def test_no_frontmatter_fails(self, tmp_path: Path) -> None: + unit = tmp_path / "creational" / "thing" + unit.mkdir(parents=True) + (unit / "README.md").write_text("# just prose\n") + with pytest.raises(CatalogError, match="frontmatter"): + load_catalog(tmp_path) + + def test_unit_without_examples_fails(self, tmp_path: Path) -> None: + _write_unit(tmp_path, "creational", "thing", GOOD) + (tmp_path / "creational" / "thing" / "pythonic.py").unlink() + with pytest.raises(CatalogError, match="ships no"): + load_catalog(tmp_path) + + def test_empty_tree_fails(self, tmp_path: Path) -> None: + with pytest.raises(CatalogError, match="no pattern units"): + load_catalog(tmp_path) + + +def test_find_patterns_root_from_repo() -> None: + assert find_patterns_root().name == "patterns" diff --git a/tests/test_scaffold.py b/tests/test_scaffold.py new file mode 100644 index 0000000..a5943f6 --- /dev/null +++ b/tests/test_scaffold.py @@ -0,0 +1,7 @@ +"""Smoke test: the package imports and reports a version.""" + +import design_patterns + + +def test_version() -> None: + assert design_patterns.__version__