Source code for mafw.devtools.release.versioning

#  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}.')