Coverage for src / mafw / devtools / release / versioning.py: 91%

139 statements  

« prev     ^ index     » next       coverage.py v7.14.0, created at 2026-06-28 13:34 +0000

1# Copyright 2026 European Union 

2# Author: Bulgheroni Antonio (antonio.bulgheroni@ec.europa.eu) 

3# SPDX-License-Identifier: EUPL-1.2 

4""" 

5Version parsing, classification, and bumping utilities for MAFw releases. 

6 

7This module contains the business logic for version string parsing, 

8classification, validation, and the Hatch-based version bumping workflow. 

9""" 

10 

11from __future__ import annotations 

12 

13import re 

14from pathlib import Path 

15from typing import Any, Final, Literal 

16 

17from rich.prompt import InvalidResponse, Prompt 

18 

19from mafw.__about__ import __doc_target_version__ as DEFAULT_DOC_TARGET_VERSION 

20from mafw.devtools import DevtoolsError 

21from mafw.tools.shell_tools import CONSOLE, run_stdout 

22from mafw.tools.shell_tools import run as cmd 

23 

24VALID_HATCH_SEGMENTS: Final[tuple[str, ...]] = ('major', 'minor', 'micro', 'rc', 'alpha', 'beta', 'release') 

25"""Allowed Hatch version segments supported by this script.""" 

26 

27VERSION_PATTERN = re.compile( 

28 r'^(?P<major>\d+)\.(?P<minor>\d+)\.(?P<micro>\d+)(?:(?P<suffix>rc|a|b)(?P<suffix_num>\d+))?$' 

29) 

30"""Regular expression used to parse local version strings (stable/alpha/beta/rc).""" 

31 

32STABLE_TAG_PATTERN = re.compile(r'^v(?P<major>\d+)\.(?P<minor>\d+)\.(?P<micro>\d+)$') 

33"""Regular expression used to identify stable git tags in the form ``vX.Y.Z``.""" 

34 

35ABOUT_FILE = Path('src/mafw/__about__.py') 

36"""Path to the version source file managed by ``hatch version``.""" 

37 

38NOTICE_FILE = Path('NOTICE.txt') 

39"""Path to the notice file containing the public project version.""" 

40 

41NOTICE_VERSION_PATTERN = re.compile( 

42 r"""MAFw - Modular Analysis Framework\n\nversion:\s*V[0-9]+\.[0-9]+\.[0-9]+(?:[-a-zA-Z0-9\.\-_]+)?""", 

43 re.MULTILINE, 

44) 

45"""Pattern used to update the version block in ``NOTICE.txt``.""" 

46 

47DOC_TARGET_VERSION_PATTERN = re.compile(r'^\d+\.\d+$') 

48"""Pattern used to validate documentation target version strings.""" 

49 

50VersionKind = Literal['stable', 'rc', 'alpha', 'beta'] 

51"""Supported release kinds used to drive changelog and release-note behavior.""" 

52 

53 

54def parse_version(version: str) -> tuple[int, int, int, int | None]: 

55 """ 

56 Parse a version string in the form ``X.Y.Z`` or with a pre-release suffix. 

57 

58 Supported suffixes follow Hatch/PEP 440 conventions: 

59 

60 - Release candidates: ``X.Y.ZrcN`` 

61 - Alpha releases: ``X.Y.ZaN`` 

62 - Beta releases: ``X.Y.ZbN`` 

63 

64 :param version: Version string to parse. 

65 :type version: str 

66 :return: Parsed major, minor, micro, and optional pre-release index. 

67 :rtype: tuple[int, int, int, int | None] 

68 :raises DevtoolsError: If the version format is unsupported. 

69 """ 

70 match = VERSION_PATTERN.fullmatch(version.strip()) 

71 if match is None: 

72 raise DevtoolsError(f'Unsupported version format "{version}". Expected X.Y.Z, X.Y.ZrcN, X.Y.ZaN or X.Y.ZbN.') 

73 suffix_group = match.group('suffix_num') 

74 return ( 

75 int(match.group('major')), 

76 int(match.group('minor')), 

77 int(match.group('micro')), 

78 int(suffix_group) if suffix_group is not None else None, 

79 ) 

80 

81 

82def read_current_version() -> str: 

83 """ 

84 Read the project version using ``hatch version``. 

85 

86 :return: Current project version string. 

87 :rtype: str 

88 :raises DevtoolsError: If the version cannot be extracted. 

89 """ 

90 version = run_stdout(['hatch', 'version']) 

91 if not version: 

92 raise DevtoolsError('Unable to determine the current version from hatch.') 

93 return version 

94 

95 

96def normalize_hatch_segments(segments: str) -> str: 

97 """ 

98 Normalize the user-provided segment selector to a Hatch-compatible token list. 

99 

100 The selector supports comma-separated segments and follows Hatch semantics. 

101 Examples: 

102 

103 - ``minor,rc``: bump minor and create/reset an RC suffix. 

104 - ``rc``: increment the release-candidate counter only. 

105 - ``alpha`` / ``beta``: create alpha/beta pre-release suffix. 

106 - ``release``: remove any pre-release suffix (stable release). 

107 

108 :param segments: Raw selector as passed on the command line. 

109 :type segments: str 

110 :return: Normalized Hatch selector (comma-separated, lowercase, no whitespace). 

111 :rtype: str 

112 :raises DevtoolsError: If the selector is empty or contains unsupported segments. 

113 """ 

114 normalized = [token.strip().lower() for token in segments.split(',') if token.strip()] 

115 if not normalized: 

116 raise DevtoolsError('Missing version segment selector. Example: "minor,rc" or "rc".') 

117 

118 invalid = sorted({token for token in normalized if token not in VALID_HATCH_SEGMENTS}) 

119 if invalid: 

120 raise DevtoolsError( 

121 f'Invalid version segment(s): {", ".join(invalid)}. ' 

122 f'Use one or more of: {", ".join(VALID_HATCH_SEGMENTS)} (comma-separated).' 

123 ) 

124 

125 # assure that there are not duplicated segments in the token list 

126 if len(set(normalized)) != len(normalized): 

127 raise DevtoolsError('Duplicate version segments are not allowed.') 

128 

129 # assure that there is only one segment among major, minor and micro 

130 core_segments = {'major', 'minor', 'micro'} 

131 if sum(token in core_segments for token in normalized) > 1: 

132 raise DevtoolsError('Use at most one of: major, minor, micro.') 

133 

134 # assure that there is only one pre-release segment 

135 prerelease_segments = {'rc', 'alpha', 'beta'} 

136 if sum(token in prerelease_segments for token in normalized) > 1: 

137 raise DevtoolsError('Use at most one of: rc, alpha, beta.') 

138 

139 return ','.join(normalized) 

140 

141 

142def classify_version(version: str) -> VersionKind: 

143 """ 

144 Classify a version string into stable/rc/alpha/beta. 

145 

146 :param version: Version string to classify. 

147 :type version: str 

148 :return: Classified version kind. 

149 :rtype: str 

150 :raises DevtoolsError: If the version cannot be parsed. 

151 """ 

152 match = VERSION_PATTERN.fullmatch(version.strip()) 

153 if match is None: 

154 raise DevtoolsError(f'Unsupported version format "{version}".') 

155 

156 suffix = match.group('suffix') 

157 if suffix is None: 

158 return 'stable' 

159 if suffix == 'rc': 

160 return 'rc' 

161 if suffix == 'a': 

162 return 'alpha' 

163 if suffix == 'b': 163 ↛ 165line 163 didn't jump to line 165 because the condition on line 163 was always true

164 return 'beta' 

165 raise DevtoolsError(f'Unsupported pre-release suffix "{suffix}" in version "{version}".') 

166 

167 

168class DocTargetVersionPrompt(Prompt): 

169 """Prompt that validates documentation target versions in ``major.minor`` form.""" 

170 

171 validate_error_message = 'Please enter a version in major.minor form, for example 2.4.' 

172 """Message shown when the entered documentation target version is invalid.""" 

173 

174 def __init__(self, *args: Any, min_version: str | None = None, **kwargs: Any) -> None: 

175 """ 

176 Initialize the prompt with an optional minimum allowed version. 

177 

178 :param min_version: Lowest allowed documentation target version in ``major.minor`` form. 

179 :type min_version: str | None 

180 """ 

181 super().__init__(*args, **kwargs) 

182 self._min_version = min_version 

183 

184 def process_response(self, value: str) -> str: 

185 """ 

186 Validate and normalize the documentation target version entered by the user. 

187 

188 :param value: Raw user input. 

189 :type value: str 

190 :return: Normalized version string. 

191 :rtype: str 

192 :raises InvalidResponse: If the value does not match ``major.minor`` or is below the minimum. 

193 """ 

194 normalized = value.strip() 

195 if DOC_TARGET_VERSION_PATTERN.fullmatch(normalized) is None: 

196 raise InvalidResponse(self.validate_error_message) 

197 

198 if self._min_version is not None: 

199 min_major, min_minor = _parse_major_minor(self._min_version) 

200 major, minor = _parse_major_minor(normalized) 

201 if (major, minor) < (min_major, min_minor): 

202 raise InvalidResponse( 

203 f'Please enter a version in major.minor form that is not lower than {self._min_version}.' 

204 ) 

205 

206 return normalized 

207 

208 

209def _parse_major_minor(version: str) -> tuple[int, int]: 

210 """ 

211 Parse a version string in ``major.minor`` form. 

212 

213 :param version: Version string to parse. 

214 :type version: str 

215 :return: Parsed major and minor components. 

216 :rtype: tuple[int, int] 

217 :raises DevtoolsError: If the version format is invalid. 

218 """ 

219 match = re.fullmatch(r'(\d+)\.(\d+)', version.strip()) 

220 if match is None: 

221 raise DevtoolsError(f'Invalid version "{version}". Expected format like 2.4.') 

222 return int(match.group(1)), int(match.group(2)) 

223 

224 

225def validate_doc_target_version(version: str) -> str: 

226 """ 

227 Validate a documentation target version string. 

228 

229 :param version: Version string to validate. 

230 :type version: str 

231 :return: Normalized version string. 

232 :rtype: str 

233 :raises DevtoolsError: If the version format is invalid. 

234 """ 

235 normalized = version.strip() 

236 if DOC_TARGET_VERSION_PATTERN.fullmatch(normalized) is None: 

237 raise DevtoolsError(f'Invalid documentation target version "{version}". Expected format like 2.4.') 

238 return normalized 

239 

240 

241def next_minor_version(version: str) -> str: 

242 """ 

243 Compute the next ``major.minor`` target from a release version. 

244 

245 :param version: Release version in ``major.minor.micro`` form. 

246 :type version: str 

247 :return: Next documentation target version. 

248 :rtype: str 

249 """ 

250 major, minor, _, _ = parse_version(version) 

251 return f'{major}.{minor + 1}' 

252 

253 

254def compute_doc_target_version(version: str, segments: str, override: str | None = None) -> str: 

255 """ 

256 Determine the new documentation target version for a release. 

257 

258 :param version: Bumped release version. 

259 :type version: str 

260 :param segments: Normalized Hatch selector used for the release. 

261 :type segments: str 

262 :param override: Optional explicit documentation target override. 

263 :type override: str | None 

264 :return: Documentation target version in ``major.minor`` form. 

265 :rtype: str 

266 """ 

267 if override is not None: 

268 return validate_doc_target_version(override) 

269 

270 segment_set = set(segments.split(',')) 

271 if any(segment in segment_set for segment in {'rc', 'alpha', 'beta', 'micro'}): 

272 return DEFAULT_DOC_TARGET_VERSION 

273 return next_minor_version(version) 

274 

275 

276def update_doc_target_version(version: str, dry_run: bool) -> None: 

277 """ 

278 Update the documentation target version in ``src/mafw/__about__.py``. 

279 

280 :param version: Target documentation version in ``major.minor`` form. 

281 :type version: str 

282 :param dry_run: Whether filesystem changes are disabled. 

283 :type dry_run: bool 

284 """ 

285 content = ABOUT_FILE.read_text(encoding='utf-8') 

286 updated, replacements = re.subn( 

287 r'(__doc_target_version__\s*=\s*[\"\'])([^\"\']+)([\"\'])', 

288 rf'\g<1>{version}\g<3>', 

289 content, 

290 count=1, 

291 ) 

292 if replacements != 1: 292 ↛ 293line 292 didn't jump to line 293 because the condition on line 292 was never true

293 raise DevtoolsError(f'Unable to update __doc_target_version__ in {ABOUT_FILE}.') 

294 if dry_run: 

295 CONSOLE.print(f'Documentation target version planned: {version}') 

296 return 

297 ABOUT_FILE.write_text(updated, encoding='utf-8') 

298 CONSOLE.print(f'Updated documentation target version to {version}.') 

299 

300 

301def bump_version(segment: str, *, dry_run: bool) -> str: 

302 """ 

303 Compute or execute the version bump depending on dry-run mode. 

304 

305 Hatch is used as single source of truth for the resolved target version. 

306 In dry-run mode, the ``__about__.py`` file is temporarily rewritten by 

307 Hatch and restored afterwards so the git worktree remains unchanged. 

308 

309 :param segment: Hatch selector segments (comma-separated). 

310 :type segment: str 

311 :param dry_run: Whether command execution is disabled. 

312 :type dry_run: bool 

313 :return: New version string. 

314 :rtype: str 

315 """ 

316 hatch_selector = normalize_hatch_segments(segment) 

317 

318 original_about = ABOUT_FILE.read_text(encoding='utf-8') 

319 try: 

320 cmd(['hatch', 'version', hatch_selector]) 

321 version = run_stdout(['hatch', 'version']) 

322 if not version: 322 ↛ 323line 322 didn't jump to line 323 because the condition on line 322 was never true

323 raise DevtoolsError('Unable to determine the new version from hatch.') 

324 finally: 

325 if dry_run: 

326 ABOUT_FILE.write_text(original_about, encoding='utf-8') 

327 

328 if dry_run: 

329 CONSOLE.print(f'Computed version (dry-run): {version}') 

330 else: 

331 CONSOLE.print(f'New version: {version}') 

332 return version 

333 

334 

335def update_notice_version(version: str, dry_run: bool) -> None: 

336 """ 

337 Update the version in ``NOTICE.txt`` to match the release version. 

338 

339 :param version: Target release version. 

340 :type version: str 

341 :param dry_run: Whether filesystem changes are disabled. 

342 :type dry_run: bool 

343 :raises DevtoolsError: If NOTICE.txt is missing or has unexpected format. 

344 """ 

345 if not NOTICE_FILE.exists(): 

346 raise DevtoolsError(f'Unable to find {NOTICE_FILE}.') 

347 

348 replacement = f'MAFw - Modular Analysis Framework\\n\\nversion: V{version}' 

349 content = NOTICE_FILE.read_text(encoding='utf-8') 

350 updated, replacements = NOTICE_VERSION_PATTERN.subn(replacement, content) 

351 if replacements != 1: 351 ↛ 352line 351 didn't jump to line 352 because the condition on line 351 was never true

352 raise DevtoolsError( 

353 f'Unable to update version in {NOTICE_FILE}: expected one matching version block, found {replacements}.' 

354 ) 

355 

356 if dry_run: 356 ↛ 360line 356 didn't jump to line 360 because the condition on line 356 was always true

357 CONSOLE.print(f'NOTICE update planned for version V{version}.') 

358 return 

359 

360 NOTICE_FILE.write_text(updated, encoding='utf-8') 

361 CONSOLE.print(f'Updated {NOTICE_FILE} to version V{version}.')