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
6 changes: 4 additions & 2 deletions patterns/behavioral/mediator/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,10 @@ 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.
`notify` callable and hold zero rules. `pythonic.py` scales the idea to a
checkout form whose rules genuinely tangle (country restricts shipping,
shipping gates payment and changes the total): one `_recheck` method holds
every rule, and a country change cascades through the dependent fields.

## In the wild

Expand Down
82 changes: 60 additions & 22 deletions patterns/behavioral/mediator/pythonic.py
Original file line number Diff line number Diff line change
@@ -1,43 +1,81 @@
"""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.
A checkout form with enough interdependent rules to *justify* a mediator:
country restricts shipping methods, shipping method gates payment options
and recomputes the total, and submit is enabled only when the whole set is
coherent. Widgets know none of it -- every rule lives in one method.
"""

from __future__ import annotations

from collections.abc import Callable
from dataclasses import dataclass, field

SHIPPING_BY_COUNTRY = {
"CA": {"standard": 900, "express": 2400},
"US": {"standard": 700, "express": 1900},
"DE": {"standard": 1100}, # no express lane
}
#: cash-on-delivery is only offered on express shipments
PAYMENTS_BY_SHIPPING: dict[str, tuple[str, ...]] = {
"standard": ("card",),
"express": ("card", "cod"),
}

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()
@dataclass
class Field:
"""A dumb widget: holds a value, reports changes. No rules."""

notify: Callable[[], None]
value: str = ""

class SignupForm:
"""The mediator: rules in one readable method."""
def set(self, value: str) -> None:
self.value = value
self.notify()

def __init__(self) -> None:
self.username = TextField(self._recheck)
self.password = TextField(self._recheck)
self.submit_enabled = False

@dataclass
class CheckoutForm:
"""The mediator: every cross-field rule, in one readable place."""

cart_cents: int
country: Field = field(init=False)
shipping: Field = field(init=False)
payment: Field = field(init=False)
shipping_options: tuple[str, ...] = ()
payment_options: tuple[str, ...] = ()
total_cents: int = 0
submit_enabled: bool = False

def __post_init__(self) -> None:
self.country = Field(self._recheck)
self.shipping = Field(self._recheck)
self.payment = Field(self._recheck)
self._recheck()

def _recheck(self) -> None:
self.submit_enabled = bool(self.username.text) and len(self.password.text) >= 8
lanes = SHIPPING_BY_COUNTRY.get(self.country.value, {})
self.shipping_options = tuple(lanes)
if self.shipping.value not in lanes:
self.shipping.value = "" # country change invalidated the lane
self.payment_options = PAYMENTS_BY_SHIPPING.get(self.shipping.value, ())
if self.payment.value not in self.payment_options:
self.payment.value = ""
self.total_cents = self.cart_cents + lanes.get(self.shipping.value, 0)
self.submit_enabled = bool(
self.country.value and self.shipping.value and self.payment.value
)


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}")
form = CheckoutForm(cart_cents=5000)
form.country.set("CA")
form.shipping.set("express")
form.payment.set("cod")
print(f"total {form.total_cents}, submit={form.submit_enabled}")
form.country.set("DE") # express vanishes; dependent fields reset
print(f"after DE: shipping={form.shipping.value!r}, submit={form.submit_enabled}")


if __name__ == "__main__":
Expand Down
41 changes: 31 additions & 10 deletions patterns/behavioral/mediator/tests/test_mediator.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,18 +19,39 @@ def test_weak_password_keeps_submit_disabled(self) -> None:


class TestPythonic:
def test_form_coordination(self) -> None:
form = pythonic.SignupForm()
form.username.type_text("ada")
form.password.type_text("correcthorse")
def test_happy_path_enables_submit_and_totals(self) -> None:
form = pythonic.CheckoutForm(cart_cents=5000)
form.country.set("CA")
form.shipping.set("express")
form.payment.set("cod")
assert form.submit_enabled

def test_widgets_know_no_rules(self) -> None:
# A TextField is reusable with any notify callable -- no form coupling.
assert form.total_cents == 5000 + 2400

def test_country_change_cascades_through_dependent_fields(self) -> None:
form = pythonic.CheckoutForm(cart_cents=5000)
form.country.set("US")
form.shipping.set("express")
form.payment.set("cod")
form.country.set("DE") # DE has no express -> shipping and payment reset
assert form.shipping.value == "" and form.payment.value == ""
assert not form.submit_enabled
assert form.shipping_options == ("standard",)

def test_payment_options_follow_shipping_method(self) -> None:
form = pythonic.CheckoutForm(cart_cents=1000)
form.country.set("CA")
form.shipping.set("standard")
standard_options: tuple[str, ...] = form.payment_options
assert standard_options == ("card",)
form.shipping.set("express")
express_options: tuple[str, ...] = form.payment_options
assert express_options == ("card", "cod")

def test_widgets_hold_no_rules(self) -> None:
pings: list[str] = []
field = pythonic.TextField(lambda: pings.append("changed"))
field.type_text("x")
assert pings == ["changed"]
widget = pythonic.Field(notify=lambda: pings.append("changed"))
widget.set("anything")
assert pings == ["changed"] # reusable with any coordinator


class TestRealWorld:
Expand Down
7 changes: 4 additions & 3 deletions patterns/creational/abstract_factory/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,10 @@ 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.
*callables*. `pythonic.py` renders one sales report through interchangeable
document families (HTML for the web app, Markdown for the CLI) — each family
a dataclass of builder callables that belong together, no abstract base
required.

## In the wild

Expand Down
65 changes: 47 additions & 18 deletions patterns/creational/abstract_factory/pythonic.py
Original file line number Diff line number Diff line change
@@ -1,41 +1,70 @@
"""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.
The real shape: a report renderer that must emit HTML for the web app and
Markdown for the CLI -- three builders that must stay consistent with each
other (heading, table, callout). Each family is a dataclass of callables;
the renderer never names a concrete format.
"""

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 DocumentFamily:
"""The 'complete' abstract factory: builders that belong together."""

heading: Callable[[str], str]
table: Callable[[list[str], list[list[str]]], str]
callout: Callable[[str], str]

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

number: Callable[[str], object]
sequence: Callable[[list[object]], object]
def _html_table(headers: list[str], rows: list[list[str]]) -> str:
head = "".join(f"<th>{h}</th>" for h in headers)
body = "".join("<tr>" + "".join(f"<td>{c}</td>" for c in row) + "</tr>" for row in rows)
return f"<table><tr>{head}</tr>{body}</table>"


def _md_table(headers: list[str], rows: list[list[str]]) -> str:
lines = [
"| " + " | ".join(headers) + " |",
"|" + "---|" * len(headers),
*("| " + " | ".join(row) + " |" for row in rows),
]
return "\n".join(lines)


HTML = DocumentFamily(
heading=lambda text: f"<h2>{text}</h2>",
table=_html_table,
callout=lambda text: f'<div class="callout">{text}</div>',
)

PYTHON_FAMILY = Family(number=float, sequence=list)
EXACT_FAMILY = Family(number=Decimal, sequence=tuple)
MARKDOWN = DocumentFamily(
heading=lambda text: f"## {text}",
table=_md_table,
callout=lambda text: f"> {text}",
)


def parse(texts: list[str], family: Family = PYTHON_FAMILY) -> object:
return family.sequence([family.number(t) for t in texts])
def render_sales_report(family: DocumentFamily, rows: list[list[str]]) -> str:
"""The client: builds a whole document without naming a format."""
return "\n".join(
[
family.heading("Sales by region"),
family.table(["region", "revenue"], rows),
family.callout("Figures exclude refunds."),
]
)


def main() -> None:
print(parse_numbers(["1.1"], Decimal))
print(parse(["1.1", "2.2"], EXACT_FAMILY))
rows = [["west", "$12k"], ["east", "$9k"]]
print(render_sales_report(MARKDOWN, rows))
print()
print(render_sales_report(HTML, rows))


if __name__ == "__main__":
Expand Down
34 changes: 24 additions & 10 deletions patterns/creational/abstract_factory/tests/test_abstract_factory.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Behavioral tests for all three abstract-factory variants."""

from decimal import Decimal
from typing import ClassVar

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

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


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]
ROWS: ClassVar[list[list[str]]] = [["west", "$12k"], ["east", "$9k"]]

def test_markdown_family_renders_consistently(self) -> None:
doc = pythonic.render_sales_report(pythonic.MARKDOWN, self.ROWS)
assert doc.startswith("## Sales by region")
assert "| west | $12k |" in doc
assert doc.endswith("> Figures exclude refunds.")

def test_html_family_renders_consistently(self) -> None:
doc = pythonic.render_sales_report(pythonic.HTML, self.ROWS)
assert "<h2>Sales by region</h2>" in doc
assert "<td>west</td>" in doc
assert '<div class="callout">' in doc

def test_client_is_format_blind(self) -> None:
# A brand-new family works without touching the renderer.
plain = pythonic.DocumentFamily(
heading=str.upper,
table=lambda headers, rows: "; ".join(",".join(r) for r in rows),
callout=lambda text: f"NB: {text}",
)
doc = pythonic.render_sales_report(plain, self.ROWS)
assert doc.splitlines()[0] == "SALES BY REGION"


class TestRealWorld:
Expand Down
10 changes: 5 additions & 5 deletions patterns/creational/prototype/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,11 @@ 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.
Python doesn't need the interface, because *callables* are the interface.
`pythonic.py` shows the guide's recommendation on a real shape — a scheduler
stamping out report jobs from `functools.partial` templates, with per-run
tweaks via `dataclasses.replace` on the frozen product. No `clone()`
anywhere.

## In the wild

Expand Down
Loading
Loading