You want to sort release versions so that 1.2.3-rc.1 comes before 1.2.3.
To sort your objects and compare them with <, Python needs methods such as
__lt__ on the class. Write __eq__ and __lt__, and the
functools.total_ordering decorator adds __le__, __gt__ and __ge__ from
them:
from dataclasses import dataclass
from functools import total_ordering
@total_ordering
@dataclass(frozen=True, eq=False)
class Version:
major: int
minor: int
patch: int
pre: str = ""
def _key(self) -> tuple[int, int, int, int, str]:
return (self.major, self.minor, self.patch, 0 if self.pre else 1, self.pre)
def __eq__(self, other: object) -> bool:
if not isinstance(other, Version):
return NotImplemented
return self._key() == other._key()
def __lt__(self, other: object) -> bool:
if not isinstance(other, Version):
return NotImplemented
return self._key() < other._key()
def __hash__(self) -> int:
return hash(self._key())
preview = Version(1, 2, 3, "rc.1")
release = Version(1, 2, 3)
print(preview < release, release >= preview) # True True
print(release == Version(1, 2, 3)) # True
print(len({release, Version(1, 2, 3)})) # 1
Put the order in one key method
The _key() method returns a tuple, and Python compares tuples item by item.
The fourth item is 0 for a pre-release and 1 for a release, so with the same
numbers, a pre-release sorts first. Without that item, the empty tag of a
release would sort before "rc.1", which gives the opposite order.
The eq=False option stops the dataclass from writing its own __eq__, which
would compare the raw fields. The frozen=True option stops anyone from
changing the fields after construction.
The total_ordering decorator does not write __eq__, and it does not check
that your rules make sense. You get != from __eq__ without writing it.
This is a simplified version format, not full Semantic Versioning. It compares
tags as plain strings, so "rc.10" sorts before "rc.2". Real Semantic
Versioning compares the numeric parts of a tag as numbers.
Return NotImplemented for other types
When other is not a Version, return NotImplemented. That tells Python
this method cannot handle the pair, so Python tries the other object's method.
If neither can, == falls back to checking identity and gives False, and
< raises TypeError:
print(release == "1.2.3") # False
try:
release < "1.2.3"
except TypeError as error:
print(error) # '<' not supported between instances of 'Version' and 'str'
Returning False instead would stop Python from asking the other object. That
matters when the other object knows how to compare itself with yours.
Check your understanding
Why is other annotated as object and not Version?
Show answer
Hide answer
Python can call __eq__ with any object, as in release == "1.2.3". The base
class object declares __eq__(self, other: object), and mypy rejects an
override that accepts less. Keep -> bool as the return type. As a special
case, mypy accepts NotImplemented there.
Hash with the same key as equality
When a class defines __eq__ but not __hash__, Python sets its __hash__
to None. Its instances then cannot go in a set or be dictionary keys. Add a __hash__ that
hashes the same key that __eq__ compares. Equal objects then have equal
hashes, which sets and dictionaries rely on.
The hash must also stay the same while the object is in a set. So the fields
in the key must not change, and that is what frozen=True is for.
Choose the simplest design
- If comparing the fields in the order you declared them is right, use
@dataclass(order=True)and write no comparison methods. - If you sort in only one place, pass a key function, as in
sorted(versions, key=version_key). - Use
total_orderingwith a key method when the type has its own rule and many places compare it.
Going further
The Semantic Versioning specification gives the full rules for comparing versions.