Skip to content

← Stdlib Mastery step 47 of 55

Medium Primitives

StrEnum: migrate a module of string constants without breaking callers

Replace a module of bare string constants with a StrEnum, and prove that every existing use site still works.

@unique
class Status(StrEnum):
    PENDING = "pending"
    ACTIVE = "active"
    DONE = "done"
    FAILED = "failed"


class Legacy(StrEnum):
    OLD = "same"
    NEW = "same"          # deliberately an alias -- no @unique here


PRIORITY: dict[str, int] = {"pending": 0, "active": 1, "done": 2, "failed": 3}

def parse_status(raw: str) -> Status: ...
def matches_raw(status: Status, raw: str) -> bool: ...
def solve(raw: list[str]) -> dict[str, object]: ...

solve returns:

key value
"values" [str(m) for m in Status] — declaration order
"json" json.dumps({"status": Status.ACTIVE, "history": [Status.PENDING, Status.ACTIVE]})
"eq_raw" matches_raw(Status.PENDING, "pending")
"formatted" f"{Status.PENDING}"
"lookup" PRIORITY[Status.DONE] — indexing a dict[str, int] with the member
"parsed" for each input, parse_status(text).name or "ValueError"
"duplicates" "ValueError" if unique(Legacy) raises, else "accepted"
"aliased" Legacy.NEW.name

Why StrEnum and not a plain Enum. A StrEnum member is a str, so every existing comparison, dict lookup, log line and json.dumps keeps working unchanged — which is what makes the migration affordable. A plain Enum with the same members gives 'Status.PENDING' from str(), False from == "pending", a KeyError on the dict lookup, and a TypeError from json.dumps. Migrating to that means touching every call site.

What Legacy demonstrates. Without @unique, a repeated value creates an alias: Legacy.NEW is Legacy.OLD, Legacy.NEW.name is "OLD", and iteration yields one member. Occasionally that is what you want for a deprecated spelling; far more often it is a copy-paste bug whose symptom is a log line reporting a name nobody wrote. unique() turns it into a ValueError at class-creation time.

A --strict detail you will hit. Status.PENDING == "pending" written literally is a comparison-overlap error: with two literal operands mypy decides the comparison is non-overlapping. Route it through a function taking raw: str, which is also where such a comparison belongs in real code — at the boundary where an untyped string arrives.

Verified checker split, worth knowing. mypy rejects a StrEnum member where a matching Literal is expected; pyright and ty accept it, and mypy issue #19243 was closed as not planned. So “StrEnum or Literal?” is partly a question of which checker your team runs — do not try to mix them at an API boundary.