Skip to content

Commit bcf2e2c

Browse files
feat: realistic domains for the six thinnest pythonic examples (#14)
Rewrites pythonic.py (and tests) where the toy domain didn't answer "what does this look like in a real system": - facade: place_order() over inventory/payment/shipping/notification, with the payment-failure rollback tested - dependency_injection: overdue-invoice reminders with clock, source, and mail seams — deterministic tests, no monkeypatching - bridge: alert/digest notifiers bridged over email/Slack/SMS transports - prototype: report-job templates via partial + dataclasses.replace - mediator: checkout form with cascading country/shipping/payment rules - abstract_factory: one report rendered through HTML/Markdown document families naive.py toys kept as-is (the diff against them is the teaching device). READMEs aligned. 236 tests; ruff/mypy --strict clean. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 6f18db7 commit bcf2e2c

18 files changed

Lines changed: 598 additions & 191 deletions

File tree

patterns/behavioral/mediator/README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,10 @@ and *only* the mediator decides who reacts.
2929
## Pythonic solution
3030

3131
The mediator doesn't need a Colleague base class — widgets accept a
32-
`notify` callable, and the mediator is a small coordinator holding the
33-
interaction rules in one readable place.
32+
`notify` callable and hold zero rules. `pythonic.py` scales the idea to a
33+
checkout form whose rules genuinely tangle (country restricts shipping,
34+
shipping gates payment and changes the total): one `_recheck` method holds
35+
every rule, and a country change cascades through the dependent fields.
3436

3537
## In the wild
3638

patterns/behavioral/mediator/pythonic.py

Lines changed: 60 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,81 @@
11
"""The mediator without a Colleague hierarchy.
22
3-
Widgets take a ``notify`` callable; the coordinator holds every interaction
4-
rule in one place and the widgets hold none.
3+
A checkout form with enough interdependent rules to *justify* a mediator:
4+
country restricts shipping methods, shipping method gates payment options
5+
and recomputes the total, and submit is enabled only when the whole set is
6+
coherent. Widgets know none of it -- every rule lives in one method.
57
"""
68

79
from __future__ import annotations
810

911
from collections.abc import Callable
12+
from dataclasses import dataclass, field
1013

14+
SHIPPING_BY_COUNTRY = {
15+
"CA": {"standard": 900, "express": 2400},
16+
"US": {"standard": 700, "express": 1900},
17+
"DE": {"standard": 1100}, # no express lane
18+
}
19+
#: cash-on-delivery is only offered on express shipments
20+
PAYMENTS_BY_SHIPPING: dict[str, tuple[str, ...]] = {
21+
"standard": ("card",),
22+
"express": ("card", "cod"),
23+
}
1124

12-
class TextField:
13-
def __init__(self, notify: Callable[[], None]) -> None:
14-
self.text = ""
15-
self._notify = notify
1625

17-
def type_text(self, text: str) -> None:
18-
self.text = text
19-
self._notify()
26+
@dataclass
27+
class Field:
28+
"""A dumb widget: holds a value, reports changes. No rules."""
2029

30+
notify: Callable[[], None]
31+
value: str = ""
2132

22-
class SignupForm:
23-
"""The mediator: rules in one readable method."""
33+
def set(self, value: str) -> None:
34+
self.value = value
35+
self.notify()
2436

25-
def __init__(self) -> None:
26-
self.username = TextField(self._recheck)
27-
self.password = TextField(self._recheck)
28-
self.submit_enabled = False
37+
38+
@dataclass
39+
class CheckoutForm:
40+
"""The mediator: every cross-field rule, in one readable place."""
41+
42+
cart_cents: int
43+
country: Field = field(init=False)
44+
shipping: Field = field(init=False)
45+
payment: Field = field(init=False)
46+
shipping_options: tuple[str, ...] = ()
47+
payment_options: tuple[str, ...] = ()
48+
total_cents: int = 0
49+
submit_enabled: bool = False
50+
51+
def __post_init__(self) -> None:
52+
self.country = Field(self._recheck)
53+
self.shipping = Field(self._recheck)
54+
self.payment = Field(self._recheck)
55+
self._recheck()
2956

3057
def _recheck(self) -> None:
31-
self.submit_enabled = bool(self.username.text) and len(self.password.text) >= 8
58+
lanes = SHIPPING_BY_COUNTRY.get(self.country.value, {})
59+
self.shipping_options = tuple(lanes)
60+
if self.shipping.value not in lanes:
61+
self.shipping.value = "" # country change invalidated the lane
62+
self.payment_options = PAYMENTS_BY_SHIPPING.get(self.shipping.value, ())
63+
if self.payment.value not in self.payment_options:
64+
self.payment.value = ""
65+
self.total_cents = self.cart_cents + lanes.get(self.shipping.value, 0)
66+
self.submit_enabled = bool(
67+
self.country.value and self.shipping.value and self.payment.value
68+
)
3269

3370

3471
def main() -> None:
35-
form = SignupForm()
36-
form.username.type_text("ada")
37-
form.password.type_text("short")
38-
print(f"weak password: {form.submit_enabled}")
39-
form.password.type_text("correcthorse")
40-
print(f"valid form: {form.submit_enabled}")
72+
form = CheckoutForm(cart_cents=5000)
73+
form.country.set("CA")
74+
form.shipping.set("express")
75+
form.payment.set("cod")
76+
print(f"total {form.total_cents}, submit={form.submit_enabled}")
77+
form.country.set("DE") # express vanishes; dependent fields reset
78+
print(f"after DE: shipping={form.shipping.value!r}, submit={form.submit_enabled}")
4179

4280

4381
if __name__ == "__main__":

patterns/behavioral/mediator/tests/test_mediator.py

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,18 +19,39 @@ def test_weak_password_keeps_submit_disabled(self) -> None:
1919

2020

2121
class TestPythonic:
22-
def test_form_coordination(self) -> None:
23-
form = pythonic.SignupForm()
24-
form.username.type_text("ada")
25-
form.password.type_text("correcthorse")
22+
def test_happy_path_enables_submit_and_totals(self) -> None:
23+
form = pythonic.CheckoutForm(cart_cents=5000)
24+
form.country.set("CA")
25+
form.shipping.set("express")
26+
form.payment.set("cod")
2627
assert form.submit_enabled
27-
28-
def test_widgets_know_no_rules(self) -> None:
29-
# A TextField is reusable with any notify callable -- no form coupling.
28+
assert form.total_cents == 5000 + 2400
29+
30+
def test_country_change_cascades_through_dependent_fields(self) -> None:
31+
form = pythonic.CheckoutForm(cart_cents=5000)
32+
form.country.set("US")
33+
form.shipping.set("express")
34+
form.payment.set("cod")
35+
form.country.set("DE") # DE has no express -> shipping and payment reset
36+
assert form.shipping.value == "" and form.payment.value == ""
37+
assert not form.submit_enabled
38+
assert form.shipping_options == ("standard",)
39+
40+
def test_payment_options_follow_shipping_method(self) -> None:
41+
form = pythonic.CheckoutForm(cart_cents=1000)
42+
form.country.set("CA")
43+
form.shipping.set("standard")
44+
standard_options: tuple[str, ...] = form.payment_options
45+
assert standard_options == ("card",)
46+
form.shipping.set("express")
47+
express_options: tuple[str, ...] = form.payment_options
48+
assert express_options == ("card", "cod")
49+
50+
def test_widgets_hold_no_rules(self) -> None:
3051
pings: list[str] = []
31-
field = pythonic.TextField(lambda: pings.append("changed"))
32-
field.type_text("x")
33-
assert pings == ["changed"]
52+
widget = pythonic.Field(notify=lambda: pings.append("changed"))
53+
widget.set("anything")
54+
assert pings == ["changed"] # reusable with any coordinator
3455

3556

3657
class TestRealWorld:

patterns/creational/abstract_factory/README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,10 @@ factory per family, and client code programmed against the interface.
2929
## Pythonic solution
3030

3131
Classes and functions are first-class, so the guide's advice is: accept
32-
*callables*. `pythonic.py` passes `Decimal` itself as the number factory; the
33-
"complete" factory bundling several builders is just a small dataclass of
34-
callables — no abstract base required.
32+
*callables*. `pythonic.py` renders one sales report through interchangeable
33+
document families (HTML for the web app, Markdown for the CLI) — each family
34+
a dataclass of builder callables that belong together, no abstract base
35+
required.
3536

3637
## In the wild
3738

patterns/creational/abstract_factory/pythonic.py

Lines changed: 47 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,70 @@
11
"""What to write instead: factories are callables, families are dataclasses.
22
3-
``Decimal`` itself is already a factory -- pass it. When several builders
4-
travel together, bundle them in a plain dataclass; swapping the family is
5-
constructing a different bundle.
3+
The real shape: a report renderer that must emit HTML for the web app and
4+
Markdown for the CLI -- three builders that must stay consistent with each
5+
other (heading, table, callout). Each family is a dataclass of callables;
6+
the renderer never names a concrete format.
67
"""
78

89
from __future__ import annotations
910

1011
from collections.abc import Callable
1112
from dataclasses import dataclass
12-
from decimal import Decimal
1313

1414

15-
def parse_numbers(texts: list[str], build: Callable[[str], object] = float) -> list[object]:
16-
"""A factory is just an argument with a sensible default."""
17-
return [build(t) for t in texts]
15+
@dataclass(frozen=True)
16+
class DocumentFamily:
17+
"""The 'complete' abstract factory: builders that belong together."""
1818

19+
heading: Callable[[str], str]
20+
table: Callable[[list[str], list[list[str]]], str]
21+
callout: Callable[[str], str]
1922

20-
@dataclass(frozen=True)
21-
class Family:
22-
"""The 'complete' abstract factory: a bundle of callables."""
2323

24-
number: Callable[[str], object]
25-
sequence: Callable[[list[object]], object]
24+
def _html_table(headers: list[str], rows: list[list[str]]) -> str:
25+
head = "".join(f"<th>{h}</th>" for h in headers)
26+
body = "".join("<tr>" + "".join(f"<td>{c}</td>" for c in row) + "</tr>" for row in rows)
27+
return f"<table><tr>{head}</tr>{body}</table>"
28+
29+
30+
def _md_table(headers: list[str], rows: list[list[str]]) -> str:
31+
lines = [
32+
"| " + " | ".join(headers) + " |",
33+
"|" + "---|" * len(headers),
34+
*("| " + " | ".join(row) + " |" for row in rows),
35+
]
36+
return "\n".join(lines)
37+
2638

39+
HTML = DocumentFamily(
40+
heading=lambda text: f"<h2>{text}</h2>",
41+
table=_html_table,
42+
callout=lambda text: f'<div class="callout">{text}</div>',
43+
)
2744

28-
PYTHON_FAMILY = Family(number=float, sequence=list)
29-
EXACT_FAMILY = Family(number=Decimal, sequence=tuple)
45+
MARKDOWN = DocumentFamily(
46+
heading=lambda text: f"## {text}",
47+
table=_md_table,
48+
callout=lambda text: f"> {text}",
49+
)
3050

3151

32-
def parse(texts: list[str], family: Family = PYTHON_FAMILY) -> object:
33-
return family.sequence([family.number(t) for t in texts])
52+
def render_sales_report(family: DocumentFamily, rows: list[list[str]]) -> str:
53+
"""The client: builds a whole document without naming a format."""
54+
return "\n".join(
55+
[
56+
family.heading("Sales by region"),
57+
family.table(["region", "revenue"], rows),
58+
family.callout("Figures exclude refunds."),
59+
]
60+
)
3461

3562

3663
def main() -> None:
37-
print(parse_numbers(["1.1"], Decimal))
38-
print(parse(["1.1", "2.2"], EXACT_FAMILY))
64+
rows = [["west", "$12k"], ["east", "$9k"]]
65+
print(render_sales_report(MARKDOWN, rows))
66+
print()
67+
print(render_sales_report(HTML, rows))
3968

4069

4170
if __name__ == "__main__":

patterns/creational/abstract_factory/tests/test_abstract_factory.py

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Behavioral tests for all three abstract-factory variants."""
22

33
from decimal import Decimal
4+
from typing import ClassVar
45

56
from patterns.creational.abstract_factory import naive, pythonic, real_world
67

@@ -14,16 +15,29 @@ def test_client_builds_through_the_interface(self) -> None:
1415

1516

1617
class TestPythonic:
17-
def test_callable_is_the_factory(self) -> None:
18-
assert pythonic.parse_numbers(["2.5"], Decimal) == [Decimal("2.5")]
19-
20-
def test_default_factory(self) -> None:
21-
assert pythonic.parse_numbers(["2.5"]) == [2.5]
22-
23-
def test_family_bundle_swaps_every_member(self) -> None:
24-
result = pythonic.parse(["1.1"], pythonic.EXACT_FAMILY)
25-
assert result == (Decimal("1.1"),)
26-
assert pythonic.parse(["1.1"]) == [1.1]
18+
ROWS: ClassVar[list[list[str]]] = [["west", "$12k"], ["east", "$9k"]]
19+
20+
def test_markdown_family_renders_consistently(self) -> None:
21+
doc = pythonic.render_sales_report(pythonic.MARKDOWN, self.ROWS)
22+
assert doc.startswith("## Sales by region")
23+
assert "| west | $12k |" in doc
24+
assert doc.endswith("> Figures exclude refunds.")
25+
26+
def test_html_family_renders_consistently(self) -> None:
27+
doc = pythonic.render_sales_report(pythonic.HTML, self.ROWS)
28+
assert "<h2>Sales by region</h2>" in doc
29+
assert "<td>west</td>" in doc
30+
assert '<div class="callout">' in doc
31+
32+
def test_client_is_format_blind(self) -> None:
33+
# A brand-new family works without touching the renderer.
34+
plain = pythonic.DocumentFamily(
35+
heading=str.upper,
36+
table=lambda headers, rows: "; ".join(",".join(r) for r in rows),
37+
callout=lambda text: f"NB: {text}",
38+
)
39+
doc = pythonic.render_sales_report(plain, self.ROWS)
40+
assert doc.splitlines()[0] == "SALES BY REGION"
2741

2842

2943
class TestRealWorld:

patterns/creational/prototype/README.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,11 @@ and a registry mapping names to exemplars that get cloned on demand.
2727

2828
## Pythonic solution
2929

30-
Python doesn't need the interface, because *callables* are the interface. A
31-
registry can hold classes, `functools.partial` objects pre-loading the
32-
arguments, or bound methods — anything you can call to get a fresh instance.
33-
`pythonic.py` shows the guide's recommendation: a registry of zero-argument
34-
factories.
30+
Python doesn't need the interface, because *callables* are the interface.
31+
`pythonic.py` shows the guide's recommendation on a real shape — a scheduler
32+
stamping out report jobs from `functools.partial` templates, with per-run
33+
tweaks via `dataclasses.replace` on the frozen product. No `clone()`
34+
anywhere.
3535

3636
## In the wild
3737

0 commit comments

Comments
 (0)