The comparison rule you need is not something you have to write — it is how tuples already behave. Read the first two lines side by side.
print('1.10.0' < '1.9.0') # -> True wrong: text, not numbers
print((1, 10, 0) < (1, 9, 0)) # -> False right: 10 is not less than 9
# Tuples compare left to right and stop at the first difference:
print((1, 2, 3) < (1, 2, 4)) # -> True only the last part differs
print((2, 0, 0) < (1, 99, 99)) # -> False the first part decides it
- Text comparison is character by character
'1.10.0' and '1.9.0' agree on '1' and '.', then meet '1' against '9' — and '1' sorts first, so the whole string does. It is the leaderboard bug again: values that arrive as text get compared as text unless somebody converts them.
- Tuples compare element by element, and stop early
(2, 0, 0) beats (1, 99, 99) on the first element and the rest is never looked at. That is exactly the rule versions follow, which means storing the parts as a tuple of integers gives you the entire comparison for free — you are not implementing an ordering, you are borrowing one.
- Six comparisons, two methods
functools.total_ordering fills in <=, >, >= and != once you have written __eq__ and __lt__. Writing all six by hand is four more chances to get an inequality backwards, and they are the kind of bug that only shows up on the boundary.
Do not write a comparison rule. Find a type that already compares the way you need, store the parts in one, and let it do the work.
Split on dots, convert each part with int(), and keep them as a tuple. Then __eq__ and __lt__ are one line each — compare the tuples. functools.total_ordering supplies the other four operators.
In __init__: split, check there are exactly three parts, convert them inside a try so a non-numeric part becomes a ValueError naming the version, and keep both the tuple and the original text. Decorate the class with @total_ordering, write __eq__ and __lt__ returning NotImplemented for non-Versions, and __str__ returning the stored text.
from functools import total_ordering @____ class Version: def __init__(self, text): parts = text.split('____') if len(parts) != ____: raise ValueError(...) try: self.parts = ____(int(part) for part in parts) except ValueError: raise ValueError(...) from None self.text = text def __eq__(self, other): if not isinstance(other, Version): return ____ return self.parts == other.parts def __lt__(self, other): ... def __str__(self): return self.text
from functools import total_ordering
@total_ordering
class Version:
"""A three-part version number."""
def __init__(self, text):
parts = text.split(".")
if len(parts) != 3:
raise ValueError(f"expected three parts, got {text!r}")
try:
self.parts = tuple(int(part) for part in parts)
except ValueError:
raise ValueError(f"version parts must be numbers: {text!r}") from None
self.text = text
def __eq__(self, other):
if not isinstance(other, Version):
return NotImplemented
return self.parts == other.parts
def __lt__(self, other):
if not isinstance(other, Version):
return NotImplemented
return self.parts < other.parts
def __str__(self):
return self.text
self.parts < other.parts is the whole comparison. Tuples already compare left to right and stop at the first difference, which is precisely the rule for version numbers. Recognising that a built-in type already has the semantics you need is worth more than any amount of careful hand-rolled comparison — there is nothing here to get backwards.
It derives <=, >, >= and != from the two you wrote. The derived versions are marginally slower because they may make two calls instead of one, which matters only in a hot sort of millions of items. In exchange you cannot get one of them subtly inverted, and a reader can see there is no hidden asymmetry.
Without it, the ValueError from int('x') is chained underneath yours and the reader sees 'during handling of the above exception, another occurred' — pointing at an int conversion they did not write. `from None` suppresses that and leaves only the message that names the bad version. Use it when the original exception is an implementation detail; keep the chain when it is a genuine cause worth seeing.
Do not write this for real dependency handling. Semantic versioning has pre-release and build metadata — 1.0.0-rc.1 is *older* than 1.0.0, which no naive tuple comparison will tell you — and Python packaging has its own scheme again, including epochs and post-releases. packaging.version.Version implements PEP 440 correctly and is what pip itself uses. Write your own only for a private, strictly three-part scheme you control, and know that the moment somebody tags an -rc you have outgrown it.
- Allow a missing patch — treat '1.2' as 1.2.0 — and decide whether str() should then return what was typed or the normalised form.
- Add support for a pre-release suffix so 1.0.0-rc.1 sorts before 1.0.0, and notice that the tuple trick needs a careful extra element to keep working.
- Add __hash__ so versions can be dict keys, and check it agrees with __eq__ for two objects built from the same text.