Coverage for src/rhiza_hooks/_version.py: 100%
20 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-15 06:14 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-15 06:14 +0000
1#!/usr/bin/env python3
2"""Shared dotted-numeric version parsing and comparison.
4Rust and Go both express toolchain pins and minimum-supported versions as plain
5dotted numbers (``1.75``, ``1.75.0``, ``1.22.3``), occasionally with a
6pre-release suffix (``1.21rc1``) or a named channel (``stable``). Comparing them
7is the same operation in both hooks: take the leading numeric components,
8zero-pad the shorter side, and compare the resulting tuples — so ``1.22`` and
9``1.22.0`` are the same version, and ``1.9`` is below ``1.10``.
11Unlike Python's PEP 440 specifiers, neither ecosystem writes comparison
12operators into these fields, so there is no specifier grammar to parse here.
13"""
15from __future__ import annotations
17import re
19# Anchored at the start: a leading dotted-numeric run is the version, and any
20# trailing suffix (``rc1``, ``-beta``) is ignored. Strings that do not start
21# with a digit (``stable``, ``nightly-2024-01-01``, ``default``) yield no match.
22_LEADING_NUMERIC = re.compile(r"\d+(?:\.\d+)*")
25def parse_version(text: str) -> tuple[int, ...] | None:
26 """Parse the leading dotted-numeric components of a version string.
28 Args:
29 text: Raw version text, e.g. ``"1.75.0"``, ``"1.21rc1"`` or ``"stable"``.
31 Returns:
32 Tuple of integer components, or None when *text* does not begin with a
33 dotted-numeric version.
35 >>> parse_version("1.75.0")
36 (1, 75, 0)
37 >>> parse_version("1.21rc1")
38 (1, 21)
39 >>> parse_version("stable") is None
40 True
41 """
42 match = _LEADING_NUMERIC.match(text.strip())
43 if match is None:
44 return None
45 return tuple(int(part) for part in match.group(0).split("."))
48def _padded(version: tuple[int, ...], length: int) -> tuple[int, ...]:
49 """Zero-extend *version* to exactly *length* components."""
50 return version + (0,) * (length - len(version))
53def version_at_least(version: tuple[int, ...], minimum: tuple[int, ...]) -> bool:
54 """Check whether *version* is greater than or equal to *minimum*.
56 Args:
57 version: Parsed version components.
58 minimum: Parsed components of the lower bound.
60 Returns:
61 True if *version* is at least *minimum*, comparing component-wise after
62 zero-padding the shorter tuple.
64 >>> version_at_least((1, 22), (1, 22, 0))
65 True
66 >>> version_at_least((1, 9), (1, 10))
67 False
68 """
69 length = max(len(version), len(minimum))
70 return _padded(version, length) >= _padded(minimum, length)
73def same_version(left: str, right: str) -> bool:
74 """Check whether two raw version strings denote the same version.
76 Falls back to an exact string comparison when either side is not
77 dotted-numeric, so named channels such as ``stable`` still compare sensibly.
79 Args:
80 left: First raw version string.
81 right: Second raw version string.
83 Returns:
84 True if both denote the same version.
86 >>> same_version("1.22", "1.22.0")
87 True
88 >>> same_version("stable", "stable")
89 True
90 """
91 left_parsed = parse_version(left)
92 right_parsed = parse_version(right)
93 if left_parsed is None or right_parsed is None:
94 return left == right
95 length = max(len(left_parsed), len(right_parsed))
96 return _padded(left_parsed, length) == _padded(right_parsed, length)