Skip to content

← Modules, Packaging and Tooling step 28 of 41

Medium Primitives

Check a Version Against a Specifier

Write the check an installer makes before it picks a release. Given a version and a specifier, return True when the version satisfies every clause.

satisfies("1.10", ">1.9")         # True, 10 is greater than 9
satisfies("1.0", "==1.0.0")       # True, missing segments count as zero
satisfies("1.5.0", "~=1.4.2")     # False, the prefix 1.4 does not match
satisfies("1.5", ">=1.0, <1.5")   # False, the second clause fails

The starter compares versions as strings and ignores ~=, so it gets the first three wrong.

Input format

  • A version is one or more non-negative whole numbers joined by dots, such as "1.4.2".
  • A specifier is one or more clauses separated by commas, such as ">=1.2, <2". A clause is an operator followed by a release.
  • Ignore whitespace around the version, around each clause, and between an operator and its release.
  • Support the operators ==, !=, >=, <=, >, < and ~=. The inputs use no other syntax. There are no wildcards such as ==1.4.*, and no prereleases, post-releases, development releases, epochs or local versions.

Comparison rules

  • Compare releases one segment at a time, as numbers. Pad the shorter one with zeros first, so 1.10 > 1.9 and 1.0 == 1.0.0.
  • ~=V needs two things. The version must be at least V, and it must start with the prefix you get by dropping V's last segment. Pad with zeros for the prefix too, so 1 satisfies ~=1.0.0. For example, ~=1.4.2 accepts 1.4.9 and rejects 1.5.0, and ~=1.4 accepts both.
  • Return True only when every clause matches.
  • A ~= clause needs at least two segments. Raise ValueError for ~=1. Parse and check every clause before you match any of them, so satisfies("2", "<1, ~=1") raises instead of returning False at the first clause.

How to structure it

  • Parse each clause into a frozen dataclass with two fields. operator has the type Literal["==", "!=", ">=", "<=", "~=", ">", "<"], and release has the type tuple[int, ...].
  • Choose the comparison with match on the operator, and end with case unreachable: assert_never(unreachable). If someone later adds an operator to the Literal and forgets its branch, mypy reports an error. At runtime, assert_never only raises AssertionError if a value reaches it. Your parser is what turns text into a valid operator.
  • Your code must pass mypy --strict.