# Copyright 2026 European Union
# Author: Bulgheroni Antonio (antonio.bulgheroni@ec.europa.eu)
# SPDX-License-Identifier: EUPL-1.2
"""
Version parsing, classification, and bumping utilities for MAFw releases.
This module contains the business logic for version string parsing,
classification, validation, and the Hatch-based version bumping workflow.
"""
from __future__ import annotations
import re
from pathlib import Path
from typing import Any, Final, Literal
from rich.prompt import InvalidResponse, Prompt
from mafw.__about__ import __doc_target_version__ as DEFAULT_DOC_TARGET_VERSION
from mafw.devtools import DevtoolsError
from mafw.tools.shell_tools import CONSOLE, run_stdout
from mafw.tools.shell_tools import run as cmd
VALID_HATCH_SEGMENTS: Final[tuple[str, ...]] = ('major', 'minor', 'micro', 'rc', 'alpha', 'beta', 'release')
"""Allowed Hatch version segments supported by this script."""
VERSION_PATTERN = re.compile(
r'^(?P<major>\d+)\.(?P<minor>\d+)\.(?P<micro>\d+)(?:(?P<suffix>rc|a|b)(?P<suffix_num>\d+))?$'
)
"""Regular expression used to parse local version strings (stable/alpha/beta/rc)."""
STABLE_TAG_PATTERN = re.compile(r'^v(?P<major>\d+)\.(?P<minor>\d+)\.(?P<micro>\d+)$')
"""Regular expression used to identify stable git tags in the form ``vX.Y.Z``."""
ABOUT_FILE = Path('src/mafw/__about__.py')
"""Path to the version source file managed by ``hatch version``."""
NOTICE_FILE = Path('NOTICE.txt')
"""Path to the notice file containing the public project version."""
NOTICE_VERSION_PATTERN = re.compile(
r"""MAFw - Modular Analysis Framework\n\nversion:\s*V[0-9]+\.[0-9]+\.[0-9]+(?:[-a-zA-Z0-9\.\-_]+)?""",
re.MULTILINE,
)
"""Pattern used to update the version block in ``NOTICE.txt``."""
DOC_TARGET_VERSION_PATTERN = re.compile(r'^\d+\.\d+$')
"""Pattern used to validate documentation target version strings."""
VersionKind = Literal['stable', 'rc', 'alpha', 'beta']
"""Supported release kinds used to drive changelog and release-note behavior."""
def parse_version(version: str) -> tuple[int, int, int, int | None]:
"""
Parse a version string in the form ``X.Y.Z`` or with a pre-release suffix.
Supported suffixes follow Hatch/PEP 440 conventions:
- Release candidates: ``X.Y.ZrcN``
- Alpha releases: ``X.Y.ZaN``
- Beta releases: ``X.Y.ZbN``
:param version: Version string to parse.
:type version: str
:return: Parsed major, minor, micro, and optional pre-release index.
:rtype: tuple[int, int, int, int | None]
:raises DevtoolsError: If the version format is unsupported.
"""
match = VERSION_PATTERN.fullmatch(version.strip())
if match is None:
raise DevtoolsError(f'Unsupported version format "{version}". Expected X.Y.Z, X.Y.ZrcN, X.Y.ZaN or X.Y.ZbN.')
suffix_group = match.group('suffix_num')
return (
int(match.group('major')),
int(match.group('minor')),
int(match.group('micro')),
int(suffix_group) if suffix_group is not None else None,
)
def read_current_version() -> str:
"""
Read the project version using ``hatch version``.
:return: Current project version string.
:rtype: str
:raises DevtoolsError: If the version cannot be extracted.
"""
version = run_stdout(['hatch', 'version'])
if not version:
raise DevtoolsError('Unable to determine the current version from hatch.')
return version
def normalize_hatch_segments(segments: str) -> str:
"""
Normalize the user-provided segment selector to a Hatch-compatible token list.
The selector supports comma-separated segments and follows Hatch semantics.
Examples:
- ``minor,rc``: bump minor and create/reset an RC suffix.
- ``rc``: increment the release-candidate counter only.
- ``alpha`` / ``beta``: create alpha/beta pre-release suffix.
- ``release``: remove any pre-release suffix (stable release).
:param segments: Raw selector as passed on the command line.
:type segments: str
:return: Normalized Hatch selector (comma-separated, lowercase, no whitespace).
:rtype: str
:raises DevtoolsError: If the selector is empty or contains unsupported segments.
"""
normalized = [token.strip().lower() for token in segments.split(',') if token.strip()]
if not normalized:
raise DevtoolsError('Missing version segment selector. Example: "minor,rc" or "rc".')
invalid = sorted({token for token in normalized if token not in VALID_HATCH_SEGMENTS})
if invalid:
raise DevtoolsError(
f'Invalid version segment(s): {", ".join(invalid)}. '
f'Use one or more of: {", ".join(VALID_HATCH_SEGMENTS)} (comma-separated).'
)
# assure that there are not duplicated segments in the token list
if len(set(normalized)) != len(normalized):
raise DevtoolsError('Duplicate version segments are not allowed.')
# assure that there is only one segment among major, minor and micro
core_segments = {'major', 'minor', 'micro'}
if sum(token in core_segments for token in normalized) > 1:
raise DevtoolsError('Use at most one of: major, minor, micro.')
# assure that there is only one pre-release segment
prerelease_segments = {'rc', 'alpha', 'beta'}
if sum(token in prerelease_segments for token in normalized) > 1:
raise DevtoolsError('Use at most one of: rc, alpha, beta.')
return ','.join(normalized)
def classify_version(version: str) -> VersionKind:
"""
Classify a version string into stable/rc/alpha/beta.
:param version: Version string to classify.
:type version: str
:return: Classified version kind.
:rtype: str
:raises DevtoolsError: If the version cannot be parsed.
"""
match = VERSION_PATTERN.fullmatch(version.strip())
if match is None:
raise DevtoolsError(f'Unsupported version format "{version}".')
suffix = match.group('suffix')
if suffix is None:
return 'stable'
if suffix == 'rc':
return 'rc'
if suffix == 'a':
return 'alpha'
if suffix == 'b':
return 'beta'
raise DevtoolsError(f'Unsupported pre-release suffix "{suffix}" in version "{version}".')
class DocTargetVersionPrompt(Prompt):
"""Prompt that validates documentation target versions in ``major.minor`` form."""
validate_error_message = 'Please enter a version in major.minor form, for example 2.4.'
"""Message shown when the entered documentation target version is invalid."""
def __init__(self, *args: Any, min_version: str | None = None, **kwargs: Any) -> None:
"""
Initialize the prompt with an optional minimum allowed version.
:param min_version: Lowest allowed documentation target version in ``major.minor`` form.
:type min_version: str | None
"""
super().__init__(*args, **kwargs)
self._min_version = min_version
[docs]
def process_response(self, value: str) -> str:
"""
Validate and normalize the documentation target version entered by the user.
:param value: Raw user input.
:type value: str
:return: Normalized version string.
:rtype: str
:raises InvalidResponse: If the value does not match ``major.minor`` or is below the minimum.
"""
normalized = value.strip()
if DOC_TARGET_VERSION_PATTERN.fullmatch(normalized) is None:
raise InvalidResponse(self.validate_error_message)
if self._min_version is not None:
min_major, min_minor = _parse_major_minor(self._min_version)
major, minor = _parse_major_minor(normalized)
if (major, minor) < (min_major, min_minor):
raise InvalidResponse(
f'Please enter a version in major.minor form that is not lower than {self._min_version}.'
)
return normalized
[docs]
def _parse_major_minor(version: str) -> tuple[int, int]:
"""
Parse a version string in ``major.minor`` form.
:param version: Version string to parse.
:type version: str
:return: Parsed major and minor components.
:rtype: tuple[int, int]
:raises DevtoolsError: If the version format is invalid.
"""
match = re.fullmatch(r'(\d+)\.(\d+)', version.strip())
if match is None:
raise DevtoolsError(f'Invalid version "{version}". Expected format like 2.4.')
return int(match.group(1)), int(match.group(2))
[docs]
def validate_doc_target_version(version: str) -> str:
"""
Validate a documentation target version string.
:param version: Version string to validate.
:type version: str
:return: Normalized version string.
:rtype: str
:raises DevtoolsError: If the version format is invalid.
"""
normalized = version.strip()
if DOC_TARGET_VERSION_PATTERN.fullmatch(normalized) is None:
raise DevtoolsError(f'Invalid documentation target version "{version}". Expected format like 2.4.')
return normalized
[docs]
def next_minor_version(version: str) -> str:
"""
Compute the next ``major.minor`` target from a release version.
:param version: Release version in ``major.minor.micro`` form.
:type version: str
:return: Next documentation target version.
:rtype: str
"""
major, minor, _, _ = parse_version(version)
return f'{major}.{minor + 1}'
[docs]
def compute_doc_target_version(version: str, segments: str, override: str | None = None) -> str:
"""
Determine the new documentation target version for a release.
:param version: Bumped release version.
:type version: str
:param segments: Normalized Hatch selector used for the release.
:type segments: str
:param override: Optional explicit documentation target override.
:type override: str | None
:return: Documentation target version in ``major.minor`` form.
:rtype: str
"""
if override is not None:
return validate_doc_target_version(override)
segment_set = set(segments.split(','))
if any(segment in segment_set for segment in {'rc', 'alpha', 'beta', 'micro'}):
return DEFAULT_DOC_TARGET_VERSION
return next_minor_version(version)
def update_doc_target_version(version: str, dry_run: bool) -> None:
"""
Update the documentation target version in ``src/mafw/__about__.py``.
:param version: Target documentation version in ``major.minor`` form.
:type version: str
:param dry_run: Whether filesystem changes are disabled.
:type dry_run: bool
"""
content = ABOUT_FILE.read_text(encoding='utf-8')
updated, replacements = re.subn(
r'(__doc_target_version__\s*=\s*[\"\'])([^\"\']+)([\"\'])',
rf'\g<1>{version}\g<3>',
content,
count=1,
)
if replacements != 1:
raise DevtoolsError(f'Unable to update __doc_target_version__ in {ABOUT_FILE}.')
if dry_run:
CONSOLE.print(f'Documentation target version planned: {version}')
return
ABOUT_FILE.write_text(updated, encoding='utf-8')
CONSOLE.print(f'Updated documentation target version to {version}.')
def bump_version(segment: str, *, dry_run: bool) -> str:
"""
Compute or execute the version bump depending on dry-run mode.
Hatch is used as single source of truth for the resolved target version.
In dry-run mode, the ``__about__.py`` file is temporarily rewritten by
Hatch and restored afterwards so the git worktree remains unchanged.
:param segment: Hatch selector segments (comma-separated).
:type segment: str
:param dry_run: Whether command execution is disabled.
:type dry_run: bool
:return: New version string.
:rtype: str
"""
hatch_selector = normalize_hatch_segments(segment)
original_about = ABOUT_FILE.read_text(encoding='utf-8')
try:
cmd(['hatch', 'version', hatch_selector])
version = run_stdout(['hatch', 'version'])
if not version:
raise DevtoolsError('Unable to determine the new version from hatch.')
finally:
if dry_run:
ABOUT_FILE.write_text(original_about, encoding='utf-8')
if dry_run:
CONSOLE.print(f'Computed version (dry-run): {version}')
else:
CONSOLE.print(f'New version: {version}')
return version
def update_notice_version(version: str, dry_run: bool) -> None:
"""
Update the version in ``NOTICE.txt`` to match the release version.
:param version: Target release version.
:type version: str
:param dry_run: Whether filesystem changes are disabled.
:type dry_run: bool
:raises DevtoolsError: If NOTICE.txt is missing or has unexpected format.
"""
if not NOTICE_FILE.exists():
raise DevtoolsError(f'Unable to find {NOTICE_FILE}.')
replacement = f'MAFw - Modular Analysis Framework\\n\\nversion: V{version}'
content = NOTICE_FILE.read_text(encoding='utf-8')
updated, replacements = NOTICE_VERSION_PATTERN.subn(replacement, content)
if replacements != 1:
raise DevtoolsError(
f'Unable to update version in {NOTICE_FILE}: expected one matching version block, found {replacements}.'
)
if dry_run:
CONSOLE.print(f'NOTICE update planned for version V{version}.')
return
NOTICE_FILE.write_text(updated, encoding='utf-8')
CONSOLE.print(f'Updated {NOTICE_FILE} to version V{version}.')