---
name: commandments-python-role-vocabulary
description: "Writing a Python class that holds a dict of things by key and hands them out, one that collects things to ask whether it holds one, or a chain of `if`s that picks the first handler that matches — or naming a class `*Registry`, `*Set` or `*Resolver`. Read this before you write `def get(self, key) -> X | None` on a store, and when a role-vocabulary finding points here."
---

# Python role vocabulary — a Registry, a Set, a Resolver, and the contract each name promises

> 🔱 **Load `fix-at-the-source` first — the rule above all.** Every sin is a symptom; trace the value to where it is BORN and fix it there, never where it surfaces. This skill serves that one.

> Three shapes recur everywhere: a keyed store, a membership set, a first-match dispatcher. Each has a name
> and a contract. Name the class for the role and keep the contract — a `*Registry` whose `get` returns `None`,
> or a `*Resolver` that does not dispatch, is a lie every caller pays for.

## The principle

The relationship runs **both ways**:

- **Shape → name.** A class hand-rolling one of these shapes — a dict plus `register` plus a lookup, an
  add-and-ask collection, an `if` chain returning the first handler that matches — is named for the role.
- **Name → shape.** A class *named* `*Registry`, `*Set` or `*Resolver` behaves like one. The suffix is a
  promise about the contract; breaking it misleads every reader.

And one rule across all three: **a role class does one job.** A registry that also resolves, queries or
assembles is hosting a second engine — move it out.

### Registry — a keyed store

```python
class HandlerRegistry:
    def __init__(self) -> None:
        self._handlers: dict[str, Handler] = {}

    def register(self, kind: str, handler: Handler) -> None:
        self._handlers[kind] = handler

    def get(self, kind: str) -> Handler:
        try:
            return self._handlers[kind]
        except KeyError as missing:
            raise UnknownHandler.for_kind(kind) from missing

    def __contains__(self, kind: str) -> bool:
        return kind in self._handlers
```

- **`get` returns the item or raises** — never `-> Handler | None`. A miss on a registry is a broken
  invariant (nothing registered what the code relies on), not a value for every caller to branch on. Ask
  `kind in registry` first where a miss is genuinely expected.
- **It stays a store** — no resolving, querying or building inside it.

### Set — membership, unkeyed

`add(item)`, `__contains__(item) -> bool`, `__iter__`. Nothing is looked up by key: if you want an item
*by key*, you wanted a Registry.

### Resolver — first-match dispatch

A sequence of `(predicate, handler)` pairs walked in order, the first predicate that matches deciding the
answer — not an `if`/`elif` ladder re-testing one value, and not a dict lookup dressed up as one (that is
a Registry). A class named `*Resolver` that does not dispatch is renamed.

### Classify by type, not a name list

When a role decides "is this one of mine?", it asks the type — a base class, a `Protocol`, an
`isinstance` against something the code declares — never a hardcoded list of class names that rots as
classes are added and renamed.

## Rules

- [ ] A store's lookup returns the item or raises a named exception; where a miss is genuinely expected, callers ask `key in store` first.
      _Index the dict and turn the `KeyError` into a named exception (`raise UnknownHandler.for_kind(kind) from missing`), and give the class a `__contains__` for the callers that expect misses._

## Worked example

### python-nullable-registry-lookup

a keyed store handing back `None` for a key it lacks — `return self._handlers.get(kind)` — so every caller decides what a miss means

```py
----------[ Bad ]----------

def get(self, till: str) -> ReceiptPrinter | None:
    return self._printers.get(till)

----------[ Good ]----------

# in printer_pool.py
def get(self, till: str) -> ReceiptPrinter:
    try:
        return self._printers[till]
    except KeyError as missing:
        raise UnknownTill.for_name(till) from missing

# in printer_pool.py
def __contains__(self, till: str) -> bool:
    return till in self._printers
```

## Commands

- `vendor/bin/commandments judge --skill=python/role-vocabulary` — find every one of these in the codebase.
- `vendor/bin/commandments info <sin>` — what one rule flags, why it is a sin, and the fix. The sins here: `python-nullable-registry-lookup`.
- `vendor/bin/commandments report --detector=<Detector> --reason="…" --ref=path:line` — the flagged code is CORRECT under the architecture and the rule is wrong. That is the only thing a report claims: a finding you agree with is yours to fix, however far the fix cascades.

## Reference

- [What fires, and why](reference/detectors.md) — the symptom each detector flags, for when you are holding a finding.

## Related skills

- [`backend/role-vocabulary`](../../backend/role-vocabulary/SKILL.md) — the same roles in PHP, with scaffolded bases.
- [`python/absence`](../absence/SKILL.md) — a registry `get` raises on a miss — the same "a must-exist thing that is missing raises" rule.
- [`python/exceptions`](../exceptions/SKILL.md) — the named exception a registry raises on a miss, built by a classmethod factory.
