Skip to content

← Data Modelling and Invariants step 24 of 25

Hard Primitives

order=True, the 3.13 __eq__ change, and orderings that are not tuples

bisect, heapq, sorted‘s stability guarantee, every low <= x <= high range check and every cache-invalidation rule assume that == and <= tell a consistent story. When they disagree, none of those tools is wrong — your type is. And the disagreement is invisible in review.

order=True builds tuples; since 3.13 __eq__ does not

@dataclass(order=True) generates comparisons as

def __lt__(self, other):
    if other.__class__ is self.__class__:
        return (self.a, self.b) < (other.a, other.b)
    return NotImplemented

Before 3.13, __eq__ was generated the same way — a tuple comparison. In 3.13 __eq__ changed to compare fields individually, which is faster and, for one input, differently wrong. With a NaN field:

  • 3.13+: x == y is False (NaN != NaN), while x <= y and x >= y are both True (because tuple comparison short-circuits on is identity for the NaN element before falling back to ==).
  • 3.12: both were True.

So the same class, same data, gives a different answer to x == y depending on the interpreter. If you carry floats in an ordered dataclass, that is a “works on my machine” waiting to happen.

Three more properties of order=True worth internalising:

  • It is not polymorphic. other.__class__ is self.__class__ means comparing a Version to a TaggedVersion raises TypeError, even though one is a subclass of the other.
  • Heterogeneous types blow up lazily. A (int, str) pair only reaches the str comparison when the ints tie, so the TypeError appears on the day your data happens to contain a duplicate.
  • It compares every comparing field, in declaration order. That is almost never the sort key you meant. It is a lexicographic tuple order, and real domain orderings — versions, priorities, intervals, money with currencies — usually are not.

SemVer is the canonical example of “not tuple order”

Per semver.org §11:

  1. Compare major, minor, patch numerically.
  2. A version with a prerelease has lower precedence than the same version without one. 1.0.0-alpha < 1.0.0. Plain tuple order gets this exactly backwards, because ("alpha",) > ().
  3. Compare prerelease identifiers left to right. Identifiers made only of digits compare numerically (so beta.2 < beta.11, which string order reverses); identifiers with letters compare in ASCII order; a numeric identifier always has lower precedence than an alphanumeric one; and if all shared identifiers are equal, the version with more identifiers wins.
  4. Build metadata is ignored entirely. 1.0.0+001 and 1.0.0+002 have equal precedence.

Your task

Complete the frozen Version:

  • Version.parse(text) fills major, minor, patch, prerelease (a tuple of identifier strings, empty when absent) and build, and raises ValueError(f"not a semantic version: {text!r}") on a non-match;
  • build must be excluded from equality — one field() argument, no custom __eq__;
  • implement __lt__ by hand, following the four rules above. Return NotImplemented when other is not a Version, so Python falls back rather than raising from inside your method.

Then solve(versions) returns:

  • "sorted" — the input strings, ordered by precedence. Use sorted(versions, key=Version.parse); sorted only ever calls <, and it is stable, so versions of equal precedence keep their input order.
  • "eq_build_ignored"Version.parse("1.0.0+a") == Version.parse("1.0.0+b").
  • "eq_other_type" — comparing a Version to a plain str. The generated __eq__ returns NotImplemented, so Python falls back to identity and this is False — it must not raise. (The helper routes through operator.eq because --strict-equality, which --strict turns on, rejects the literal Version(...) == "1.0.0" as a comparison-overlap error. That is mypy being right: the comparison is always False, which is usually a bug.)
  • "lt_other_type""TypeError", because ordering has no fallback: when both sides return NotImplemented, Python raises.

Types. order=True, eq=False is rejected statically as well as at runtime. And mypy accepts return NotImplemented from a comparison dunder annotated -> bool — it special-cases exactly this idiom, so you do not need a # type: ignore.