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
42 changes: 14 additions & 28 deletions patterns/structural/adapter/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,31 +14,17 @@ 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.
Make a class you can't edit speak the interface your code expects — translate
what differs, forward the rest. **Verdict: pythonic** — the honest way to
reconcile interfaces you don't control.

| Where | What |
|---|---|
| [`pattern/`](pattern/) | The importable code: `DelegatingAdapter` |
| [`docs/`](docs/) | [Fundamentals](docs/fundamentals.md) · [Implementation guide](docs/implementation.md) · [External examples](docs/examples.md) |
| [`examples/payment_gateways/`](examples/payment_gateways/) | Mini-project: one checkout over two mismatched vendor SDKs |
| [`tests/`](tests/) | Behavioral tests for the pattern and the mini-project |

```bash
uv run python -m patterns.structural.adapter.examples.payment_gateways
```
9 changes: 8 additions & 1 deletion patterns/structural/adapter/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,8 @@
"""Adapter: make a given class speak the interface your code expects."""
"""Adapter — public API.

>>> from patterns.structural.adapter import DelegatingAdapter
"""

from patterns.structural.adapter.pattern import DelegatingAdapter

__all__ = ["DelegatingAdapter"]
36 changes: 36 additions & 0 deletions patterns/structural/adapter/docs/examples.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Adapter — where it lives outside this repo

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

## Python standard library

- **`io.TextIOWrapper`** — the stdlib's flagship adapter: wraps a binary
stream and exposes the text-file interface; your code reads `str` while
bytes flow underneath.
[docs.python.org/3/library/io.html#io.TextIOWrapper](https://docs.python.org/3/library/io.html#io.TextIOWrapper)
- **`functools.cmp_to_key`** — adapts an old-style two-argument comparator to
the one-argument `key=` interface; an entire adapter in function form.
[docs.python.org/3/library/functools.html#functools.cmp_to_key](https://docs.python.org/3/library/functools.html#functools.cmp_to_key)
- **`socket.makefile()`** — adapts a socket to a file-like object so
file-consuming code can speak to the network.
[docs.python.org/3/library/socket.html#socket.socket.makefile](https://docs.python.org/3/library/socket.html#socket.socket.makefile)

## Major ecosystems

- **`requests` transport adapters.** `HTTPAdapter` adapts urllib3's
connection machinery to the `Session` API, and users mount custom adapters
per URL prefix — the pattern offered as a public extension point.
[requests.readthedocs.io/en/latest/user/advanced/#transport-adapters](https://requests.readthedocs.io/en/latest/user/advanced/#transport-adapters)
- **SQLAlchemy dialects.** Each dialect adapts one DBAPI driver's quirks
(paramstyles, type handling) to a single Core interface, which is why one
query API spans many databases.
[docs.sqlalchemy.org/en/20/dialects/](https://docs.sqlalchemy.org/en/20/dialects/)

## What to notice across all of them

Every production adapter translates *conventions*, not just method names:
`cmp_to_key` bridges calling conventions, `TextIOWrapper` bridges data
models (bytes vs text), dialects bridge error hierarchies. When reviewing an
adapter, ask what happens to the adaptee's failure modes — an adapter that
only renames methods has usually left the hard mismatch in the client.
69 changes: 69 additions & 0 deletions patterns/structural/adapter/docs/fundamentals.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# Adapter — fundamentals

## Intent

Convert the interface of a class into the interface clients expect, so
classes that could not otherwise work together can — without editing either
side. You control neither the caller's shape nor the callee's; the adapter is
the one piece you do control.

## Participants

| Role | Classic (GoF) form | Python form |
|---|---|---|
| Target | Abstract class the client is written against | A `Protocol` (or just the duck-typed calls the client makes) |
| Adaptee | The class with the wrong interface | Same — the vendor SDK, legacy module, stdlib object you can't edit |
| Adapter | A class implementing Target, holding the Adaptee | A plain function when one method differs; a small class (see [`pattern/adapter.py`](../pattern/adapter.py)) for wider surfaces |
| Client | Calls Target methods only | Same — and never imports the adaptee |

## Mechanism

1. Write the target interface from the *client's* needs — only the calls it
actually makes.
2. The adapter holds the adaptee and translates each target call: units,
argument shapes, naming, and failure conventions.
3. The client is constructed with any adapter; swapping adaptees is now a
wiring change, not an edit.

## The classic form, and what Python absorbs

The textbook object adapter builds a full class triangle even for one method:

```python
class Thermometer(ABC): # Target, as an abstract class
@abstractmethod
def celsius(self) -> float: ...


class SensorAdapter(Thermometer): # Adapter subclasses the Target
def __init__(self, sensor: FahrenheitSensor) -> None:
self._sensor = sensor

def celsius(self) -> float:
return (self._sensor.get_fahrenheit() - 32) * 5 / 9
```

Python absorbs most of that ceremony. Duck typing means there is no Target
class to subclass — the adapter only needs the methods the client calls. A
one-method mismatch collapses to a function returning a closure. And for wide
surfaces, `__getattr__` forwarding (what `DelegatingAdapter` packages) means
the adapter lists only the *differences*, never the whole interface.

## When to use it

- A third-party or legacy interface has the wrong shape and you can't (or
shouldn't) edit it.
- Two vendors do the same job differently and the rest of the system should
not know which one is wired in.

## When not to use it

- You own both sides — change one of them instead of adding a layer.
- The "adapter" starts adding behavior (retries, caching, validation) — that
is Decorator or Proxy territory; keep translation pure.

## Verdict: pythonic

The honest way to reconcile interfaces you don't control. Size it to the
mismatch: function for one method, `DelegatingAdapter` subclass for a few,
and stop before it becomes a facade over many objects.
78 changes: 78 additions & 0 deletions patterns/structural/adapter/docs/implementation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# Adapter — putting it into a system

## The smell it fixes

Vendor-specific shapes leaking through code that shouldn't care:

```python
def checkout(order, vendor, client):
if vendor == "stripe":
outcome = client.create_charge(order.total_cents, "usd")
paid = outcome["status"] == "succeeded"
elif vendor == "paypal":
try:
ref = client.submit_payment(f"{order.total_cents / 100:.2f}", "USD")
paid = True
except ValueError:
paid = False
...
```

Every vendor difference — units, naming, error convention — is re-decided at
every call site. The adapter moves each vendor's translation into one class,
and the call sites shrink to a single target interface.

## Steps

1. **Define the target from the client's needs.** List the calls the client
actually makes; type them as a `Protocol`. Do not copy either vendor's
surface — the target belongs to *your* domain.
2. **Write one adapter per adaptee.** Translate units and argument shapes,
and — the step most often missed — translate **failure conventions**
(status dict vs exception) into one result type.
3. **Pick the adapter's size.** One method → a plain function or tiny class.
A few methods over a wide surface → subclass
`DelegatingAdapter` and define only what differs; the rest forwards.
4. **Construct at the edge.** Adapters are wired where the app is assembled
(config, DI, factory) — client modules import the target type only.
5. **Test through the target.** One test suite, parameterized over every
adapter, pins that all vendors behave identically from the client's seat.

```python
from patterns.structural.adapter.pattern import DelegatingAdapter


class StripeAdapter(DelegatingAdapter[StripeLikeClient]):
def charge(self, amount_cents: int, currency: str) -> PaymentResult:
outcome = self.adaptee.create_charge(amount_cents, currency.lower())
...
```

## Python idioms that keep it small

- **`Protocol` for the target** — the client gets type-checked without any
runtime base class, and adapters satisfy it structurally.
- **A closure as the whole adapter** when the target is one callable:
`lambda: (sensor.get_fahrenheit() - 32) * 5 / 9`.
- **`__getattr__` forwarding** for pass-through surfaces — never re-list
methods you aren't translating.

## Pitfalls

- **Adapting the whole surface** instead of what the client calls — you end
up maintaining a second copy of the vendor's API.
- **Leaking adaptee types** through the adapter's returns (a vendor result
dict escaping to the client re-couples everything the adapter decoupled).
- **Unifying calls but not failures.** If one vendor raises and the other
returns an error status, the client is still vendor-aware. Normalize both.
- **Translation with opinions.** Retry, cache, or validation logic hiding in
an adapter belongs in a Decorator/Proxy where it is visible and reusable.

## Worked example

[`examples/payment_gateways/`](../examples/payment_gateways/) integrates two
mismatched fake vendor SDKs behind one `PaymentProcessor` — run it with:

```bash
uv run python -m patterns.structural.adapter.examples.payment_gateways
```
1 change: 1 addition & 0 deletions patterns/structural/adapter/examples/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Mini-projects demonstrating the Adapter in practice."""
27 changes: 27 additions & 0 deletions patterns/structural/adapter/examples/payment_gateways/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"""Payment checkout over mismatched vendor SDKs, built on the Adapter.

Run it: ``uv run python -m patterns.structural.adapter.examples.payment_gateways``
"""

from patterns.structural.adapter.examples.payment_gateways.adapters import (
PaymentProcessor,
PaymentResult,
PayPalAdapter,
StripeAdapter,
)
from patterns.structural.adapter.examples.payment_gateways.checkout import Receipt, checkout
from patterns.structural.adapter.examples.payment_gateways.vendors import (
PayPalLikeGateway,
StripeLikeClient,
)

__all__ = [
"PayPalAdapter",
"PayPalLikeGateway",
"PaymentProcessor",
"PaymentResult",
"Receipt",
"StripeAdapter",
"StripeLikeClient",
"checkout",
]
30 changes: 30 additions & 0 deletions patterns/structural/adapter/examples/payment_gateways/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"""Demo: the same checkout code through two mismatched vendor SDKs."""

from __future__ import annotations

from patterns.structural.adapter.examples.payment_gateways.adapters import (
PaymentProcessor,
PayPalAdapter,
StripeAdapter,
)
from patterns.structural.adapter.examples.payment_gateways.checkout import checkout
from patterns.structural.adapter.examples.payment_gateways.vendors import (
PayPalLikeGateway,
StripeLikeClient,
)


def main() -> None:
processors: dict[str, PaymentProcessor] = {
"stripe-like": StripeAdapter(StripeLikeClient()),
"paypal-like": PayPalAdapter(PayPalLikeGateway()),
}
for vendor, processor in processors.items():
ok = checkout("A-1", 2_499, processor)
declined = checkout("A-2", 999_999, processor)
print(f"{vendor}: A-1 paid={ok.paid} ({ok.reference})")
print(f"{vendor}: A-2 paid={declined.paid} ({declined.note})")


if __name__ == "__main__":
main()
57 changes: 57 additions & 0 deletions patterns/structural/adapter/examples/payment_gateways/adapters.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"""One ``PaymentProcessor`` target; one adapter per vendor shape.

The target interface is defined by what *checkout* needs — not by either
vendor. Each adapter translates amounts, currencies, and (crucially) the
vendors' different failure conventions into one ``PaymentResult``.
"""

from __future__ import annotations

from dataclasses import dataclass
from typing import Protocol

from patterns.structural.adapter.examples.payment_gateways.vendors import (
PayPalLikeGateway,
StripeLikeClient,
)
from patterns.structural.adapter.pattern import DelegatingAdapter


@dataclass(frozen=True)
class PaymentResult:
"""The one result shape checkout understands."""

ok: bool
reference: str
reason: str = ""


class PaymentProcessor(Protocol):
"""The target interface — everything checkout will ever call."""

def charge(self, amount_cents: int, currency: str) -> PaymentResult: ...


class StripeAdapter(DelegatingAdapter[StripeLikeClient]):
"""Translate ``charge``; the vendor's extras still reachable by forwarding."""

def charge(self, amount_cents: int, currency: str) -> PaymentResult:
outcome = self.adaptee.create_charge(amount_cents, currency.lower())
if outcome["status"] == "succeeded":
return PaymentResult(ok=True, reference=outcome["id"])
return PaymentResult(ok=False, reference=outcome["id"], reason=outcome["status"])


class PayPalAdapter:
"""A hand-rolled adapter: cents -> decimal string, exception -> result."""

def __init__(self, gateway: PayPalLikeGateway) -> None:
self._gateway = gateway

def charge(self, amount_cents: int, currency: str) -> PaymentResult:
amount = f"{amount_cents / 100:.2f}"
try:
confirmation = self._gateway.submit_payment(amount, currency.upper())
except ValueError as refusal:
return PaymentResult(ok=False, reference="", reason=str(refusal))
return PaymentResult(ok=True, reference=confirmation)
23 changes: 23 additions & 0 deletions patterns/structural/adapter/examples/payment_gateways/checkout.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
"""The client code: written once against the target, never per vendor."""

from __future__ import annotations

from dataclasses import dataclass

from patterns.structural.adapter.examples.payment_gateways.adapters import PaymentProcessor


@dataclass(frozen=True)
class Receipt:
order_id: str
paid: bool
reference: str
note: str = ""


def checkout(order_id: str, total_cents: int, processor: PaymentProcessor) -> Receipt:
"""Charge an order through whichever vendor the adapter hides."""
result = processor.charge(total_cents, "usd")
if result.ok:
return Receipt(order_id, paid=True, reference=result.reference)
return Receipt(order_id, paid=False, reference=result.reference, note=result.reason)
Loading
Loading