Skip to content

← Modelling Data with Dataclasses step 12 of 28

Hard Primitives

Sort Semantic Versions with Prerelease Labels

The earlier exercise Sort Version Numbers with a Dataclass sorted versions such as 1.2.0 and 1.10.0 with order=True. Real releases also carry labels. Under Semantic Versioning (SemVer), the major.minor.patch scheme most packages use, 1.0.0-rc.1 is a release candidate that comes before 1.0.0. A + part such as 1.0.0+build.5 records how the release was built and does not change the order.

order=True gets these labels wrong. A plain release has an empty prerelease tuple, which sorts first, so 1.0.0 would come before 1.0.0-alpha. And "11" sorts before "2" as a string, so beta.11 would come before beta.2. Here you write the comparison yourself.

solve(["1.0.0", "1.0.0-beta.11", "1.0.0-beta.2", "1.0.0-alpha"])["sorted"]
# ["1.0.0-alpha", "1.0.0-beta.2", "1.0.0-beta.11", "1.0.0"]

Parse a version

Fill in Version.parse(text). It returns a Version with the int fields major, minor and patch, and two tuples of strings, prerelease and build. With no - part, prerelease is (). With no + part, build is (). So 1.2.3-beta.1+build.005 parses to Version(1, 2, 3, ("beta", "1"), ("build", "005")).

The supplied VERSION_RE checks the format. It accepts three numbers with no leading zeros, then an optional - part and an optional + part. Each part holds one or more identifiers, joined by dots and made of ASCII letters, digits and -.

  • Match with VERSION_RE.fullmatch(text). The $ in the pattern also matches before a final newline, so match would accept "1.0.0\n".
  • Check one more rule yourself. A prerelease identifier made only of digits must not start with 0 unless it is exactly 0, so 1.2.3-01 is invalid. Build identifiers may start with 0.
  • For any invalid text, raise ValueError(f"not a semantic version: {text!r}").

Compare two versions

Write __lt__(self, other: object) -> bool by hand, and leave order=True off the decorator. Compare (major, minor, patch) as numbers first. When those are equal:

  1. If exactly one of the two versions has a prerelease, that one is lower: 1.0.0-rc.1 < 1.0.0.
  2. Otherwise compare the prerelease identifiers from left to right, and stop at the first pair that differs. An identifier is numeric when it is all digits. Two numeric identifiers compare as integers, so beta.2 < beta.11. A numeric identifier is lower than a non-numeric one. Two non-numeric identifiers, such as alpha and rc-2, compare as strings in ASCII order.
  3. If every pair matches, the shorter tuple is lower: alpha < alpha.1.
  4. Build metadata never changes the order. 1.0.0+a and 1.0.0+b are equal in precedence.

If other is not a Version, return the constant NotImplemented, not the exception NotImplementedError. The constant tells Python to try the other object's method, and < raises TypeError when neither can compare the pair. sorted uses only <, so you do not need __le__, __gt__ or __ge__.

Make equality ignore build metadata

Give build the option compare=False, as in the earlier exercise Hide Secret Fields When You Print a Record. The generated __eq__ then ignores it, so Version.parse("1.0.0+a") == Version.parse("1.0.0+b") is True. The generated __hash__ leaves build out too, so equal versions still have equal hashes.

What solve returns

solve(versions) takes a list of valid version strings, which may be empty, and returns four keys:

Key Value
"sorted" sorted(versions, key=Version.parse), the original strings in SemVer order
"eq_build_ignored" Version.parse("1.0.0+a") == Version.parse("1.0.0+b"), which is True
"eq_other_type" whether a parsed 1.0.0 equals the string "1.0.0", which is False
"lt_other_type" "TypeError", the exception that < raises between a version and a string

The supplied helpers _eq_other_type and _lt_other_type compute the last two keys.

Edge cases

  • sorted is stable, so versions of equal precedence keep their input order. ["1.0.0+b", "1.0.0-alpha", "1.0.0+a"] sorts to ["1.0.0-alpha", "1.0.0+b", "1.0.0+a"].
  • Return the original strings. Do not rewrite them, remove duplicates or change the input list.
  • An empty list gives "sorted": []. The other three keys do not depend on the input.

The full rules are in the Semantic Versioning 2.0.0 specification.