Skip to content

← Modern Syntax and Modernisation step 16 of 18

Medium Primitives

Positional-only `/` and keyword-only `*`

The moment a parameter name is reachable by keyword, it is public API. Renaming it is a breaking change — even when the rename is a pure clarity improvement, even when nobody in your repo uses it by keyword, because you do not control every caller. PEP 570 gave pure-Python functions the marker that C functions always had.

def clamp(value: float, /, *, low: float = 0.0, high: float = 1.0) -> float:

Everything before / is positional-only. Everything after * is keyword-only. The middle is both.

The four motivations, and the one you will actually feel

  1. Rename freedom. value above can become x in the next release without breaking anyone.
  2. `kwargscollision avoidance.** This is why the stdlib writesdict.update(self, /, other, **kwargs): without the/, a caller could never store a key calledselforother`.
  3. C-accelerator parity. A Python fallback with keyword-capable parameters accepts calls the C version rejects — a difference that only shows up on the platform without the accelerator.
  4. Speed. Marginal, and not a reason on its own.

The * side solves a different problem: the boolean trap. send(msg, True, False) is unreadable and unreviewable; send(msg, urgent=True, retry=False) is not. Making flags keyword-only is the cheapest readability win in API design, and unlike a lint rule it is enforced by the interpreter.

Protocols: why / is not optional

class Serialiser(Protocol):
    def dumps(self, payload: str, /) -> str: ...

Without the /, a conforming class must name its parameter payload — structural typing would be quietly enforcing a naming convention. With it, both of these conform:

class Upper:
    def dumps(self, text: str, /) -> str: ...
class Reversed:
    def dumps(self, chunk: str, /) -> str: ...

The same asymmetry is why Callable[[int, str], None] describes positional-only parameters. A function with named parameters is not assignable to it in the strictest reading — and it is why most dunder overrides (__eq__, __format__, __contains__, __getitem__) need a / to satisfy --strict.

What you are building

  • Upper and Reversed, both satisfying the given Serialiser Protocol while naming their parameter differently. Assigning them to a list[Serialiser] is what proves it under --strict.
  • clamp(value, /, *, low=0.0, high=1.0)max(low, min(high, value)).
  • Registry.register(self, name, /, **attrs: str) and snapshot().
  • call_outcome(fn, *args, **kwargs) -> str"TypeError" if the call raises TypeError, else "ok". Note the parameter type: Callable[..., object].
  • solve(...) returning clamped, positional (the outcome of clamp(value, low, high)), keyword (the outcome of clamp(value=value)), serialised (one list per serialiser) and registry.

Both probes must report "TypeError". If either says "ok", your signature is wrong — and the test data includes an attribute literally named name, which only works because register‘s first parameter is unreachable by keyword.

Why the probes need Callable[..., object]

clamp(value, low, high) written literally is a static error — mypy stops you before the runtime ever sees it, which is the good outcome and also makes it untestable. Routing the call through a Callable[..., object] erases the parameter list, so the call compiles and the TypeError is observable. That erasure is not a trick invented for this exercise; it is exactly what happens every time you store a handler in a dict[str, Callable[..., Any]], and it is worth knowing that you have turned the gate off when you do.