Skip to content

← Stdlib Mastery step 33 of 55

Medium Primitives

total_ordering: SemVer, where pre-release sorts before release

Build a comparable SemVer whose ordering is deliberately not field-lexicographic, so @dataclass(order=True) cannot do it for you.

@total_ordering
class SemVer:
    def __init__(self, major: int, minor: int, patch: int, pre: str = "") -> None: ...
    @classmethod
    def parse(cls, text: str) -> "SemVer": ...
    def __str__(self) -> str: ...
  • parse("1.2.3") gives pre == ""; parse("1.2.3-rc.1") gives pre == "rc.1".
  • __str__ round-trips: "1.2.3" or "1.2.3-rc.1".
  • Ordering is by (major, minor, patch), and then a pre-release sorts before its release: 1.2.3-alpha < 1.2.3. Among pre-releases of the same version, compare the tag as a plain string.
  • SemVer must be hashable, and equal versions must hash equally.
def solve(versions: list[str], pairs: list[list[str]]) -> dict[str, object]:

Returns:

{"sorted": [str(v) for v in sorted(parsed)],
 "comparisons": [[lt, le, gt, ge, eq, ne] for each pair],
 "eq_with_str": SemVer(1, 0, 0) == "1.0.0",          # False
 "lt_with_str": "TypeError" if SemVer(1, 0, 0) < "1.0.0" raises else "compared",
 "distinct": len(set(parsed))}

Why not @dataclass(order=True). It compares the fields as a tuple in declaration order, so (1, 2, 3, "") sorts after (1, 2, 3, "alpha") because the empty string is lexicographically smallest. That is backwards, and no arrangement of fields fixes it — the ordering needs a computed key. That is exactly the case total_ordering exists for. (Where the ordering is field-lexicographic, @dataclass(order=True) is the better tool: no wrapper overhead, and field(compare=False) to exclude a field.)

Return NotImplemented, not False. NotImplemented tells the interpreter to try the reflected operation on the other operand. Returning False unilaterally declares inequality, and if some other version class knows how to compare itself with yours you end up with a == b being False while b == a is True — an asymmetric == that breaks in, dict lookups, assertEqual and every set operation, depending on which side of the operator the value landed on. Done right, SemVer(1,0,0) == "1.0.0" is False and SemVer(1,0,0) < "1.0.0" raises TypeError: equality between unrelated types is a meaningful “no”, ordering between them is a bug.

Typing. __eq__ must take other: object — it is inherited from object with that signature and narrowing it violates Liskov, which --strict reports. Returning NotImplemented from a -> bool method is fine: typeshed types it as a subclass of Any precisely so the standard dunder idiom type-checks. And defining __eq__ sets __hash__ to None, so you must define __hash__ yourself.

Two costs total_ordering is documented to have: the derived methods are slower than hand-written ones, and they produce more complex stack traces. Neither matters here; both matter when you sort a million objects.

Good news on the tooling: mypy 2.3 understands the decorator, so “it confuses type checkers” is stale advice.

Loading visualization…