Python functools.singledispatch and register
One entry function. Many implementations. Dispatch on the type of the first argument.
<!-- hal:authoritative:yaml -->
One entry function. Many implementations. Dispatch on the type of the first argument.
§I — Frame
Ops prints SQS redrive rows. Dev needs a formatter that grows when a new row shape appears, without a module-local if/elif tower. Stay in pure language depth. Leave dataclass order (09-19), Protocol plugins (09-10), and match/case (08-14) on their shelves.
Ramalho, printed p.324, opens Single Dispatch Generic Functions with htmlize: one public name, specialized bodies for str, int, containers, and so on. The point is extension by registration, not Java-style method overloading inside one class (printed p.329 soapbox).
§II — Language idiom: generic function, single argument
from functools import singledispatch
from numbers import Integral
@singledispatch
def render(obj) -> str:
return f"<pre>{obj!r}</pre>"
@render.register
def _(text: str) -> str:
return f"<p>{text}</p>"
@render.register
def _(n: Integral) -> str:
return f"<em>{n}</em>"
@render.register(tuple)
def _(items: tuple) -> str:
inner = "".join(f"<li>{render(x)}</li>" for x in items)
return f"<ol>{inner}</ol>"
@singledispatch marks the base function (object fallback). Each @render.register (or @render.register(SomeType)) adds a specialized implementation selected by the runtime type of the first argument. That is single dispatch. Multiple arguments would be multiple dispatch; Python stdlib does not ship that here.
Ramalho stresses ABCs and typing.Protocol as good register targets: you register once against Integral instead of separately against int and bool quirks, and you let the MRO walk find the best match. bool is a subclass of int; singledispatch seeks the most specific registered type, so an explicit bool register (if you add one) wins over Integral for True/False.
§III — Worked example against the census row
from dataclasses import dataclass
from functools import singledispatch
from typing import Optional
@dataclass(frozen=True)
class RedriveRow:
queue_arn: str
max_receive: Optional[int]
dlq_arn: Optional[str]
dlq_visible: Optional[int]
@dataclass(frozen=True)
class BareQueue:
queue_arn: str
visible: int
@singledispatch
def line_for(row) -> str:
return f"UNKNOWN {row!r}"
@line_for.register
def _(row: RedriveRow) -> str:
hot = (row.dlq_visible or 0) > 0
flag = "HOT" if hot else "armed"
return (
f"{flag} {row.queue_arn} maxReceive={row.max_receive} "
f"dlq={row.dlq_arn} dlq_vis={row.dlq_visible}"
)
@line_for.register
def _(row: BareQueue) -> str:
return f"BARE {row.queue_arn} visible={row.visible}"
rows = [
BareQueue("arn:aws:sqs:us-east-1:1:orders", 3),
RedriveRow("arn:aws:sqs:us-east-1:1:payments", 5, "arn:aws:sqs:us-east-1:1:payments-dlq", 12),
RedriveRow("arn:aws:sqs:us-east-1:1:notify", 3, "arn:aws:sqs:us-east-1:1:notify-dlq", 0),
]
for row in rows:
print(line_for(row))
Prints:
BARE arn:aws:sqs:us-east-1:1:orders visible=3
HOT arn:aws:sqs:us-east-1:1:payments maxReceive=5 dlq=arn:aws:sqs:us-east-1:1:payments-dlq dlq_vis=12
armed arn:aws:sqs:us-east-1:1:notify maxReceive=3 dlq=arn:aws:sqs:us-east-1:1:notify-dlq dlq_vis=0
Three rules fall out.
Rule one. Register implementations next to the types that own them. A later module can @line_for.register a new row class without editing the base. That is the extensibility Ramalho contrasts with a growing if/elif or match/case dispatcher.
Rule two. Dispatch key is the first positional argument only. Keyword-only first args and later parameters do not select the implementation.
Rule three. Prefer ABC or Protocol registers for families. Registering on Integral covers the numeric tower you intend; registering only on int surprises you when a subclass arrives.
render.registry (and render.dispatch(type)) let you inspect which function wins for a type. Use that in tests when a new row class silently hits the base.
§IV — What not to do
Do not stuff ten overloads into one class and call it singledispatch (Ramalho soapbox, printed p.329). Do not rebuild the same tower with match/case inside the base and then also register. Do not register mutable default traps. Do not pull boto3 into this lesson: Ops owns the census client; Dev owns the dispatch grammar. Do not confuse @singledispatchmethod (for methods, where self is skipped) with @singledispatch on a free function.
§V — Close instruction
Type the line_for example. Confirm BareQueue and RedriveRow hit different registers. Add a third dataclass and register it in a second module import. Pair: Ops supplies the census; Cert is Direct Connect resiliency, not a Python topic.