Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,6 @@ uv.lock

# oh-my-claudecode runtime state
.omc/

# local working files (plans, research briefs)
.cache/
17 changes: 13 additions & 4 deletions docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,16 +29,24 @@ python-design-patterns-mcp --http --host 127.0.0.1 --port 8734
| Tool | What it does |
|---|---|
| `list_patterns(group?, verdict?)` | Catalog listing, filterable |
| `get_pattern(pattern_id, variant?)` | Full prose + example source (`naive`/`pythonic`/`real_world`/`all`) |
| `get_pattern(pattern_id, variant?)` | Full prose (+ legacy variant source files: `naive.py` / `pythonic.py` / `real_world.py`, or `all`) |
| `search_patterns(query, limit?)` | BM25 full-text search over names, aliases, problems, symptoms, prose |
| `run_example(pattern_id, variant)` | Executes the vendored example in a sandboxed subprocess; returns real stdout |
| `get_pattern_docs(pattern_id, doc)` | A migrated pattern's teaching doc: `fundamentals`, `implementation`, or `examples` |
| `list_examples(pattern_id)` | A migrated pattern's runnable mini-projects |
| `run_example(pattern_id, variant?/example?)` | Executes a vendored example (legacy `variant` or migrated `example`) in a sandboxed subprocess; returns real stdout |
| `read_source(pattern_id)` | A migrated pattern's own implementation (`pattern/` package) |
| `recommend_pattern(problem_statement, limit?)` | Ranked candidates with caveats; `prefer-alternative` verdicts tell you what to write instead |

Migrated (module-shape) patterns follow three access levels: scan docs
(`get_pattern_docs`) → run a use case (`list_examples` + `run_example`) →
read the source (`read_source`).

## Resources

- `catalog://index` — the whole catalog as JSON
- `pattern://<group>/<slug>` — one pattern's prose
- `pattern://<group>/<slug>/<variant>` — one example's source
- `pattern://<group>/<slug>/<variant>` — one legacy example's source
- `pattern://<group>/<slug>/docs/<doc>` — one migrated pattern's teaching doc

## Prompts

Expand All @@ -47,6 +55,7 @@ python-design-patterns-mcp --http --host 127.0.0.1 --port 8734
## Sandbox contract

`run_example` executes only files resolved from the catalog index — the
`(id, variant)` pair is a dictionary lookup, never joined into a path. The
`(id, variant)` / `(id, example)` pair is a dictionary lookup, never joined
into a path. The
subprocess runs `python -I` in a temp cwd with a scrubbed environment, a 10s
timeout, and 64KB output caps. There is no arbitrary-code-execution tool.
41 changes: 14 additions & 27 deletions patterns/behavioral/chain_of_responsibility/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,30 +14,17 @@ 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.
Pass a request along an ordered line of handlers until one takes it — without
the sender knowing which. **Verdict: prefer an alternative** — in Python the
chain is callables in a list, not objects with successor pointers.

| Where | What |
|---|---|
| [`pattern/`](pattern/) | The importable code: `Chain`, `Handler`, `UnhandledRequestError` |
| [`docs/`](docs/) | [Fundamentals](docs/fundamentals.md) · [Implementation guide](docs/implementation.md) · [External examples](docs/examples.md) |
| [`examples/ticket_escalation/`](examples/ticket_escalation/) | Mini-project: support-ticket routing built on `pattern/` |
| [`tests/`](tests/) | Behavioral tests for the pattern and the mini-project |

```bash
uv run python -m patterns.behavioral.chain_of_responsibility.examples.ticket_escalation
```
13 changes: 12 additions & 1 deletion patterns/behavioral/chain_of_responsibility/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,12 @@
"""Chain of Responsibility: first handler that can, does. Verdict: a list and a loop."""
"""Chain of Responsibility — public API.

>>> from patterns.behavioral.chain_of_responsibility import Chain
"""

from patterns.behavioral.chain_of_responsibility.pattern import (
Chain,
Handler,
UnhandledRequestError,
)

__all__ = ["Chain", "Handler", "UnhandledRequestError"]
38 changes: 38 additions & 0 deletions patterns/behavioral/chain_of_responsibility/docs/examples.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Chain of Responsibility — where it lives outside this repo

Cited, real implementations to study (or point an agent at) when designing or
reviewing chain-shaped code.

## Python standard library

- **`logging` propagation.** A record emitted on a child logger climbs the
dot-separated logger hierarchy, offered to each ancestor's handlers until
`propagate` stops it — a chain wired by naming convention.
[docs.python.org/3/library/logging.html#logging.Logger.propagate](https://docs.python.org/3/library/logging.html#logging.Logger.propagate)
- **`urllib.request.OpenerDirector`.** Openers hold an ordered list of
`BaseHandler`s; each protocol method is tried on each handler in order until
one returns a non-`None` response — decline-by-`None`, exactly this module's
contract. [docs.python.org/3/library/urllib.request.html#urllib.request.OpenerDirector](https://docs.python.org/3/library/urllib.request.html#urllib.request.OpenerDirector)

## Major ecosystems

- **Django middleware.** Requests descend an ordered middleware stack; any
layer may short-circuit by returning a response, otherwise it delegates
inward. Ordering is explicit configuration (`MIDDLEWARE`), and the docs
discuss it as policy.
[docs.djangoproject.com/en/stable/topics/http/middleware/](https://docs.djangoproject.com/en/stable/topics/http/middleware/)
- **pluggy `firstresult` hooks** (the engine under pytest). Hook
implementations run in registration order until the first non-`None` result
wins — Chain of Responsibility offered as a library feature flag.
[pluggy.readthedocs.io/en/stable/#first-result-only](https://pluggy.readthedocs.io/en/stable/#first-result-only)
- **WSGI middleware (PEP 3333).** Applications wrap applications; each layer
answers or passes inward. The chain here is built by function composition
rather than a list.
[peps.python.org/pep-3333/](https://peps.python.org/pep-3333/)

## What to notice across all of them

Every production example makes two decisions the GoF text leaves open: the
**decline convention** (`None`, `propagate=False`, "call the next app") and
the **unhandled policy** (logging's `lastResort` handler, urllib raising
`URLError`, Django's 404). When reviewing chain code, check both are explicit.
87 changes: 87 additions & 0 deletions patterns/behavioral/chain_of_responsibility/docs/fundamentals.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# Chain of Responsibility — fundamentals

## Intent

Avoid coupling the sender of a request to its receiver by giving more than one
handler a chance to act. The request travels an ordered chain until one handler
takes it; the sender never knows — and never needs to know — which one will.

## Participants

| Role | Classic (GoF) form | Python form |
|---|---|---|
| Handler contract | Abstract class with a successor pointer | Any callable `(request) -> answer \| None` — `None` means "decline, try the next" |
| Concrete handlers | Subclasses overriding an `_attempt` hook | Plain functions (or any callable) |
| The chain itself | Implicit in the successor links | An explicit ordered collection — `Chain` in [`pattern/chain.py`](../pattern/chain.py) |
| Client | Talks to the head of the chain | Calls `chain.handle(request)` |

## Mechanism

1. Handlers are placed in a deliberate order.
2. A request is offered to each handler in turn.
3. A handler either returns an answer (the chain stops) or declines by
returning `None` (the chain continues).
4. If every handler declines, the *caller's* chosen policy applies — raise
(`handle`) or fall back to a default (`handle_or`). GoF leaves this case
undefined; making it explicit is the one improvement you should always add.

## The classic form, and what Python absorbs

The textbook implementation threads a successor pointer through handler
*objects* — each one both does its work and forwards to the next:

```python
class Handler(ABC):
def __init__(self, successor: Handler | None = None) -> None:
self.successor = successor # every handler carries the wiring

def handle(self, severity: int) -> str:
answer = self._attempt(severity)
if answer is not None:
return answer
if self.successor is None:
return "unhandled" # the fall-off-the-end case, buried
return self.successor.handle(severity)

@abstractmethod
def _attempt(self, severity: int) -> str | None: ...


class Helpdesk(Handler): ...


class Engineer(Handler): ...


class Management(Handler): ...


chain = Helpdesk(Engineer(Management())) # order hidden in nesting
```

Three classes, an ABC, and pointer bookkeeping — because 1994 languages had no
first-class functions. In Python the same design collapses: handlers are
functions, the chain is a list, dispatch is a loop. That collapse *is* the
pattern's Python lesson: what survives is not the class diagram but the two
ideas — **decline by convention** and **order as policy**.

## When to use it

- Several handlers could serve a request and the right one is known only at
runtime (escalation tiers, fallback strategies, middleware).
- You want to add, remove, or reorder handling policies without touching the
sender.

## When not to use it

- Exactly one receiver is ever right → a plain function call or a dict lookup.
- Every handler must see the request (notification, not handling) → that is
Observer, not a chain.
- The dispatch key is a simple value → `dict[key, handler]` beats scanning.

## Verdict: prefer an alternative

A list of callables and a loop is the whole pattern (this module's `Chain` is
that loop with a name and an explicit unhandled policy). Reach for
successor-pointer objects only when handlers are already stateful objects that
own their forwarding decision.
79 changes: 79 additions & 0 deletions patterns/behavioral/chain_of_responsibility/docs/implementation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# Chain of Responsibility — putting it into a system

## The smell it fixes

An `if/elif` ladder that keeps growing, where each arm is really a policy
owned by a different concern:

```python
def route(ticket):
if is_faq(ticket):
...
elif ticket.severity >= 5:
...
elif ticket.severity <= 2:
...
else:
...
```

Every new policy edits this one function. The chain inverts that: each policy
becomes a handler that owns its own "is this mine?" test, and the router
becomes data — an ordered list you configure.

## Steps

1. **Define the request and answer types.** Small frozen dataclasses work
well; the types make `mypy` police the handler contract for you.
2. **Extract each ladder arm into a handler** `(request) -> answer | None`.
The arm's condition becomes the handler's decline test (`return None`).
3. **Choose the order deliberately.** Order is policy: put short-circuiting
handlers (cache hits, emergencies) before general ones — `chain.insert(0, h)`
and `chain.remove(h)` edit that policy at runtime. Write a test that
pins the order's observable behavior.
4. **Decide the unhandled policy at the call site.** `chain.handle(req)`
raises `UnhandledRequestError`; `chain.handle_or(req, default)` substitutes
a fallback. Never let "no handler" pass silently.
5. **Assemble the chain in one place** (a `build_*_chain()` factory), so the
whole routing policy is readable — and swappable in tests.

```python
from patterns.behavioral.chain_of_responsibility import Chain

chain: Chain[Ticket, Resolution] = Chain([auto_responder, incident_commander, helpdesk])
chain.register(on_call) # or grow it later / use as decorator
resolution = chain.handle_or(ticket, triage(ticket))
```

## Python idioms that keep it small

- Handlers are **plain functions** until they need state; then any callable
object or `functools.partial(handler, config)` slots in unchanged.
- `chain.register` as a **decorator** turns registration into a one-liner at
definition site — the same move `singledispatch` and Flask routes use.
- Parameterize, don't subclass: `partial(severity_gate, max_severity=2)`
replaces a class hierarchy of near-identical handlers.

## Pitfalls

- **Silent fall-off-the-end** — the GoF form's biggest trap; the unhandled
case must be a visible decision (step 4).
- **`None` as a real answer.** The decline convention reserves `None`; if your
domain needs "the answer is nothing", wrap answers or use a sentinel.
- **Order coupling nobody wrote down.** If swapping two handlers changes
behavior, a test must fail. Test the chain's routing table, not just each
handler.
- **Handlers that mutate the request** turn a dispatch chain into a pipeline —
a different pattern with different guarantees. Keep requests immutable.
- **Overlapping predicates** make the first match arbitrary; keep each
handler's claim test exclusive enough that order expresses priority, not
accident.

## Worked example

[`examples/ticket_escalation/`](../examples/ticket_escalation/) applies every
step above to support-ticket routing — run it with:

```bash
uv run python -m patterns.behavioral.chain_of_responsibility.examples.ticket_escalation
```
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Mini-projects demonstrating the Chain of Responsibility in practice."""
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
"""Support-ticket escalation built on the Chain of Responsibility.

Run it: ``uv run python -m patterns.behavioral.chain_of_responsibility.examples.ticket_escalation``
"""

from patterns.behavioral.chain_of_responsibility.examples.ticket_escalation.handlers import (
build_escalation_chain,
route,
)
from patterns.behavioral.chain_of_responsibility.examples.ticket_escalation.models import (
Resolution,
Ticket,
)

__all__ = ["Resolution", "Ticket", "build_escalation_chain", "route"]
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
"""Demo: a morning's tickets through the escalation chain."""

from __future__ import annotations

from patterns.behavioral.chain_of_responsibility.examples.ticket_escalation.handlers import route
from patterns.behavioral.chain_of_responsibility.examples.ticket_escalation.models import Ticket


def main() -> None:
inbox = [
Ticket("T-1", "Can't log in", 1, frozenset({"password-reset"})),
Ticket("T-2", "Wrong charge on invoice", 2, frozenset({"billing"})),
Ticket("T-3", "Export breaks on large files", 4, frozenset({"bug"})),
Ticket("T-4", "API returning 500s for everyone", 3, frozenset({"outage"})),
Ticket("T-5", "Feature idea: dark mode", 0, frozenset()),
]
for ticket in inbox:
resolution = route(ticket)
print(f"{ticket.id} [{ticket.subject}] -> {resolution.team}: {resolution.action}")


if __name__ == "__main__":
main()
Loading
Loading