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

1#!/usr/bin/env python3 

2"""Shared dotted-numeric version parsing and comparison. 

3 

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``. 

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""" 

14 

15from __future__ import annotations 

16 

17import re 

18 

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+)*") 

23 

24 

25def parse_version(text: str) -> tuple[int, ...] | None: 

26 """Parse the leading dotted-numeric components of a version string. 

27 

28 Args: 

29 text: Raw version text, e.g. ``"1.75.0"``, ``"1.21rc1"`` or ``"stable"``. 

30 

31 Returns: 

32 Tuple of integer components, or None when *text* does not begin with a 

33 dotted-numeric version. 

34 

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(".")) 

46 

47 

48def _padded(version: tuple[int, ...], length: int) -> tuple[int, ...]: 

49 """Zero-extend *version* to exactly *length* components.""" 

50 return version + (0,) * (length - len(version)) 

51 

52 

53def version_at_least(version: tuple[int, ...], minimum: tuple[int, ...]) -> bool: 

54 """Check whether *version* is greater than or equal to *minimum*. 

55 

56 Args: 

57 version: Parsed version components. 

58 minimum: Parsed components of the lower bound. 

59 

60 Returns: 

61 True if *version* is at least *minimum*, comparing component-wise after 

62 zero-padding the shorter tuple. 

63 

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) 

71 

72 

73def same_version(left: str, right: str) -> bool: 

74 """Check whether two raw version strings denote the same version. 

75 

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. 

78 

79 Args: 

80 left: First raw version string. 

81 right: Second raw version string. 

82 

83 Returns: 

84 True if both denote the same version. 

85 

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)