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
« 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.
7This module contains the business logic for version string parsing,
8classification, validation, and the Hatch-based version bumping workflow.
9"""
11from __future__ import annotations
13import re
14from pathlib import Path
15from typing import Any, Final, Literal
17from rich.prompt import InvalidResponse, Prompt
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
24VALID_HATCH_SEGMENTS: Final[tuple[str, ...]] = ('major', 'minor', 'micro', 'rc', 'alpha', 'beta', 'release')
25"""Allowed Hatch version segments supported by this script."""
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)."""
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``."""
35ABOUT_FILE = Path('src/mafw/__about__.py')
36"""Path to the version source file managed by ``hatch version``."""
38NOTICE_FILE = Path('NOTICE.txt')
39"""Path to the notice file containing the public project version."""
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``."""
47DOC_TARGET_VERSION_PATTERN = re.compile(r'^\d+\.\d+$')
48"""Pattern used to validate documentation target version strings."""
50VersionKind = Literal['stable', 'rc', 'alpha', 'beta']
51"""Supported release kinds used to drive changelog and release-note behavior."""
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.
58 Supported suffixes follow Hatch/PEP 440 conventions:
60 - Release candidates: ``X.Y.ZrcN``
61 - Alpha releases: ``X.Y.ZaN``
62 - Beta releases: ``X.Y.ZbN``
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 )
82def read_current_version() -> str:
83 """
84 Read the project version using ``hatch version``.
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
96def normalize_hatch_segments(segments: str) -> str:
97 """
98 Normalize the user-provided segment selector to a Hatch-compatible token list.
100 The selector supports comma-separated segments and follows Hatch semantics.
101 Examples:
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).
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".')
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 )
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.')
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.')
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.')
139 return ','.join(normalized)
142def classify_version(version: str) -> VersionKind:
143 """
144 Classify a version string into stable/rc/alpha/beta.
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}".')
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}".')
168class DocTargetVersionPrompt(Prompt):
169 """Prompt that validates documentation target versions in ``major.minor`` form."""
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."""
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.
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
184 def process_response(self, value: str) -> str:
185 """
186 Validate and normalize the documentation target version entered by the user.
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)
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 )
206 return normalized
209def _parse_major_minor(version: str) -> tuple[int, int]:
210 """
211 Parse a version string in ``major.minor`` form.
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))
225def validate_doc_target_version(version: str) -> str:
226 """
227 Validate a documentation target version string.
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
241def next_minor_version(version: str) -> str:
242 """
243 Compute the next ``major.minor`` target from a release version.
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}'
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.
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)
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)
276def update_doc_target_version(version: str, dry_run: bool) -> None:
277 """
278 Update the documentation target version in ``src/mafw/__about__.py``.
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}.')
301def bump_version(segment: str, *, dry_run: bool) -> str:
302 """
303 Compute or execute the version bump depending on dry-run mode.
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.
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)
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')
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
335def update_notice_version(version: str, dry_run: bool) -> None:
336 """
337 Update the version in ``NOTICE.txt`` to match the release version.
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}.')
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 )
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
360 NOTICE_FILE.write_text(updated, encoding='utf-8')
361 CONSOLE.print(f'Updated {NOTICE_FILE} to version V{version}.')