Source code for mafw.scripts.release_mgt

#!/usr/bin/env python3
#  Copyright 2026 European Union
#  Author: Bulgheroni Antonio (antonio.bulgheroni@ec.europa.eu)
#  SPDX-License-Identifier: EUPL-1.2
"""
Development tool for the management of MAFw releases.

This module provides a Click-based command line interface for release
maintenance tasks. It currently exposes:

* ``release create`` to prepare a new MAFw release by bumping the version,
  updating ``NOTICE.txt``, regenerating the changelog, optionally generating
  release notes, committing tracked release artifacts, creating the git tag,
  and optionally pushing to the remote repository.
* ``deps latest check`` to validate dependency compatibility across a Python
  version range on CI.
* ``deps oldest check`` as a placeholder for the oldest-supported-dependency
  workflow.
* ``deps freeze`` and ``deps unfreeze`` to add and remove temporary upper
  bounds in ``pyproject.toml``.

:author: Bulgheroni Antonio (antonio.bulgheroni@ec.europa.eu)
"""

from __future__ import annotations

import datetime
import itertools
import json
import re
import shlex
import subprocess
import sys
import tempfile
from pathlib import Path
from typing import Any, Callable, Final, Literal

import click
import tomlkit
from rich.prompt import InvalidResponse, Prompt
from tomlkit.exceptions import TOMLKitError

from mafw.__about__ import __doc_target_version__ as DEFAULT_DOC_TARGET_VERSION
from mafw.__about__ import __version__ as MAFW_VERSION
from mafw.scripts.click_groups import AbbreviateGroup
from mafw.scripts.doc_versioning import REQUIREMENTS_GROUPS
from mafw.tools.script_completion_tools import (
    _completion_script_path,
    _completion_source_script,
    _install_completion,
    _resolve_completion_shell,
    _uninstall_completion_files,
    check_ci_completion_guard,
    is_script_already_installed,
)
from mafw.tools.shell_tools import CONSOLE, run_stdout
from mafw.tools.shell_tools import run as cmd

try:
    from packaging.requirements import Requirement
    from packaging.specifiers import Specifier, SpecifierSet
    from packaging.version import Version

    from mafw.tools.gitlab_tools import (
        GitlabAPIConfiguration,
        build_gitlab_api_configuration,
        delete_generic_package_version,
        download_generic_file,
        iter_local_pylock_reference_files,
        normalize_dependency_registry_item,
        normalize_mafw_version,
        resolve_package_ids_by_version,
        upload_generic_file,
    )
except ImportError as e:
    raise click.ClickException(
        'Unable to import optional development dependencies ("packaging" or "gitlab_tools"). '
        'This usually means you are running outside the MAFw development environment. '
        'Install MAFw with the optional [dev] feature, or invoke the helper via Hatch '
        '(e.g. "hatch run dev.py3.14:release --help").'
    ) from e

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

CHANGELOG_FILE = Path('CHANGELOG.md')
"""Path to the changelog file that is regenerated for each release."""

NOTICE_FILE = Path('NOTICE.txt')
"""Path to the notice file containing the public project version."""

PYPROJECT_FILE = Path('pyproject.toml')
"""Path to the TOML file containing the project dependencies."""

DEPS_REGISTRY_PACKAGE_NAME: Final[str] = 'mafw-deps'
"""Generic package name used for dependency reference uploads."""

PYLOCK_REFERENCE_PATTERN = re.compile(r'^pylock\.py(?P<python_version>3\.\d+)_ref\.toml$')
"""Regular expression used to validate dependency reference lock filenames."""

RELEASE_TEMPLATE_FILE = Path('.gitlab/release_templates/Default.md')
"""Path to the markdown template used to build release notes."""

RELEASE_SECTION_HEADERS = {
    'new_features': '## 🚀 New Features',
    'bug_fixes': '## 🐛 Bug Fixes',
    'refactorings': '## ♻️ Refactorings',
    'removed': '## 🗑️ Removals',
    'deprecated': '## ⚠️ Deprecated',
    'security': '## 🔒 Security',
    'other_changes': '## ️*️⃣    Other Changes',
}
"""Release note section headers used in the markdown template."""

CONTEXT_SETTINGS = {'help_option_names': ['-h', '--help']}
"""Click context settings for command line help aliases."""

DEFAULT_TEST_FILES: Final[list[str]] = [
    'tests/test_full_integration.py',
]
"""List of test files executed by default during dependency verification."""


# The update-notice hook rewrites this exact section; keep pattern aligned.
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``."""

_FROZEN_OPERATORS: Final[set[str]] = {'<', '<=', '~=', '==', '==='}
"""Operators that already constrain the maximum compatible version and should not be auto-frozen."""


[docs] 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 click.ClickException: If the version format is unsupported. """ match = VERSION_PATTERN.fullmatch(version.strip()) if match is None: raise click.ClickException( 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, )
[docs] def read_current_version() -> str: """ Read the project version using ``hatch version``. :return: Current project version string. :rtype: str :raises click.ClickException: If the version cannot be extracted. """ version = run_stdout(['hatch', 'version']) if not version: raise click.ClickException('Unable to determine the current version from hatch.') return version
VersionKind = Literal['stable', 'rc', 'alpha', 'beta'] """Supported release kinds used to drive changelog and release-note behavior.""" _PYTHON_VERSION_RE: Final[re.Pattern[str]] = re.compile(r'^\d+\.\d+$') """Pattern used to identify supported CPython version selectors.""" _FREEZE_PYLOCK_PATTERN: Final[re.Pattern[str]] = re.compile(r'^pylock\.py(?P<python_version>\d+\.\d+)\.toml$') """Pattern used to identify compiled dependency lockfiles produced by ``uv pip compile``.""" DEFAULT_FREEZE_EXTRAS: Final[tuple[str, ...]] = ('seaborn', 'all-db', 'steering-gui') """Extras used when compiling dependency lockfiles for release freezing and compatibility checks.""" DOC_TARGET_VERSION_PATTERN = re.compile(r'^\d+\.\d+$') """Pattern used to validate documentation target version strings."""
[docs] 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 click.ClickException: If the version format is invalid. """ match = re.fullmatch(r'(\d+)\.(\d+)', version.strip()) if match is None: raise click.ClickException(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 click.ClickException: If the version format is invalid. """ normalized = version.strip() if DOC_TARGET_VERSION_PATTERN.fullmatch(normalized) is None: raise click.ClickException(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)
[docs] 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 click.ClickException(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}.')
[docs] def compute_upper_bound(lower_bound: str) -> str: """ Compute an upper bound for a dependency based on PEP 440 compatible-release philosophy. The rule implemented here is purposely conservative and mirrors the intent of compatible release clauses while remaining explicit: - For major-versioned releases (``X.*`` with ``X > 0``), freeze to ``<(X + 1)``. - For ``0.*`` releases, freeze to ``<0.(minor + 1)`` (rolling compatibility during pre-1.0). :param lower_bound: Version string used as the starting point for the freeze rule. :type lower_bound: str :return: Upper-bound version string without operator. :rtype: str :raises click.ClickException: If the version cannot be parsed. """ try: version = Version(lower_bound) except Exception as exc: # pragma: no cover raise click.ClickException( f'Unable to parse version "{lower_bound}" while computing dependency upper bound.' ) from exc if version.major > 0: return str(version.major + 1) return f'0.{version.minor + 1}'
[docs] def _project_python_versions_from_doc(doc: tomlkit.TOMLDocument) -> list[str]: """ Extract supported CPython versions from a parsed ``pyproject.toml`` document. :param doc: Parsed TOML document. :type doc: tomlkit.TOMLDocument :return: Sorted list of supported CPython versions. :rtype: list[str] :raises click.ClickException: If the ``tool.mafw.supported-python`` field is invalid. """ tool = doc.get('tool') mafw = tool.get('mafw') if tool is not None else None if mafw is None: raise click.ClickException(f'Missing [tool.mafw] table in {PYPROJECT_FILE}.') supported_python = mafw.get('supported-python') if not isinstance(supported_python, list) or not supported_python: raise click.ClickException(f'Missing tool.mafw.supported-python in {PYPROJECT_FILE}.') validated: list[tuple[int, int, str]] = [] for item in supported_python: if not isinstance(item, str): raise click.ClickException('tool.mafw.supported-python must contain only strings.') match = re.fullmatch(r'(\d+)\.(\d+)', item.strip()) if match is None: raise click.ClickException( f'Invalid Python version in tool.mafw.supported-python: {item}. Expected major.minor, e.g. 3.14.' ) major = int(match.group(1)) minor = int(match.group(2)) if major != 3: raise click.ClickException( f'Unsupported Python version in tool.mafw.supported-python: {item}. Only Python 3.x is supported.' ) validated.append((major, minor, f'{major}.{minor}')) validated.sort() return [item[2] for item in dict.fromkeys(validated)]
[docs] def _project_python_versions() -> list[str]: """ Read the supported CPython versions from ``tool.mafw.supported-python`` list in pyproject.toml. The field is expected to be a list of strings representing CPython major.minor versions. The helper validates each entry and returns a sorted, de-duplicated list so downstream callers have deterministic ordering. :return: Sorted list of supported CPython versions. :rtype: list[str] :raises click.ClickException: If ``pyproject.toml`` cannot be parsed or the field contains unsupported values. """ if not PYPROJECT_FILE.exists(): raise click.ClickException(f'Unable to find {PYPROJECT_FILE}.') doc = _load_pyproject_doc(PYPROJECT_FILE.read_text(encoding='utf-8')) return _project_python_versions_from_doc(doc)
[docs] def _compile_python_selector(python_version: str) -> str: """ Build the Python selector used by ``uv`` for dependency compilation. For Python 3.14 and newer, request the GIL-enabled variant explicitly so free-threaded interpreters do not leak into dependency resolution. This distinction is still needed because of the psycopg :param python_version: Base Python version in ``major.minor`` form. :type python_version: str :return: Python selector string passed to ``uv``. :rtype: str """ major, minor = _parse_python_version(python_version) if (major, minor) >= (3, 14): return f'{python_version}+gil' return python_version
[docs] def _compile_dependency_lockfile( python_version: str, pylock_file: Path, extras: list[str], resolution: str | None = None, output_format: str | None = None, with_hashes: bool = False, ) -> None: """ Compile a dependency lockfile for a specific CPython version. :param python_version: CPython version used for the ``uv pip compile`` run. :type python_version: str :param pylock_file: Output path for the generated lockfile. :type pylock_file: Path :param extras: Project extras requested during compilation. :type extras: list[str] :param resolution: Optional UV resolution strategy (e.g. 'lowest-direct', 'highest'). :type resolution: str | None :param output_format: Optional output format (e.g. 'requirements.txt'). :type output_format: str | None :param with_hashes: Whether to generate hashes for the compiled requirements. :type with_hashes: bool """ cmd_parts = [ 'uv', 'pip', 'compile', 'pyproject.toml', '--python', _compile_python_selector(python_version), '--no-annotate', '-o', str(pylock_file), '-q', ] if resolution is not None: cmd_parts.extend(['--resolution', resolution]) if output_format is not None: cmd_parts.extend(['--format', output_format]) if with_hashes: cmd_parts.append('--generate-hashes') for extra in extras: cmd_parts.extend(['--extra', extra]) # cmd_parts.extend(['-o', str(pylock_file), '-q']) cmd(cmd_parts)
[docs] def _read_compiled_dependency_versions(pylock_text: str) -> dict[str, Version]: """ Read resolved dependency versions from a compiled lockfile payload. The returned mapping stores the highest resolved version seen for each dependency name, normalized to lowercase for stable lookups. :param pylock_text: Raw ``pylock.pyX.Y.toml`` content. :type pylock_text: str :return: Mapping of package name to highest resolved version. :rtype: dict[str, Version] :raises click.ClickException: If the TOML payload cannot be parsed. """ try: doc = tomlkit.loads(pylock_text) except TOMLKitError as exc: raise click.ClickException('Unable to parse compiled dependency lockfile as TOML.') from exc resolved: dict[str, Version] = {} for item in doc.get('packages', []): if not isinstance(item, dict): continue name = item.get('name') version_text = item.get('version') if not isinstance(name, str) or not isinstance(version_text, str): continue try: version = Version(version_text) except Exception as exc: # pragma: no cover raise click.ClickException( f'Unable to parse resolved dependency version "{version_text}" for package "{name}".' ) from exc key = name.lower() if key not in resolved or version > resolved[key]: resolved[key] = version return resolved
[docs] def _load_pyproject_doc(toml_text: str) -> tomlkit.TOMLDocument: """ Parse a ``pyproject.toml`` payload once and return the TOML document. :param toml_text: Raw TOML payload. :type toml_text: str :return: Parsed TOML document. :rtype: tomlkit.TOMLDocument :raises click.ClickException: If the TOML payload cannot be parsed. """ try: return tomlkit.loads(toml_text) except TOMLKitError as exc: raise click.ClickException(f'Unable to parse {PYPROJECT_FILE} as TOML.') from exc
[docs] def _collect_compiled_dependency_versions(python_versions: list[str]) -> dict[str, Version]: """ Compile dependency lockfiles and collect the resolved versions they report. The command mirrors the dependency verification workflow by invoking ``uv pip compile`` for each supported CPython version. A temporary lockfile is generated for each version and removed afterwards. :param python_versions: Supported Python versions to compile. :type python_versions: list[str] :return: Mapping of package name to highest resolved version across the compiled lockfiles. :rtype: dict[str, Version] """ resolved: dict[str, Version] = {} if not python_versions: raise click.ClickException('Unable to determine supported Python versions for dependency freezing.') with tempfile.TemporaryDirectory() as tmpdir: tmpdir_path = Path(tmpdir) for python_version in python_versions: pylock_file = tmpdir_path / f'pylock.py{python_version}.toml' _compile_dependency_lockfile(python_version, pylock_file, list(DEFAULT_FREEZE_EXTRAS)) compiled_versions = _read_compiled_dependency_versions(pylock_file.read_text(encoding='utf-8')) for name, version in compiled_versions.items(): if name not in resolved or version > resolved[name]: resolved[name] = version return resolved
[docs] def _format_requirement(requirement: Requirement) -> str: """ Serialize a packaging requirement object back to a PEP 508 compatible string. :param requirement: Parsed requirement instance. :type requirement: Requirement :return: PEP 508 requirement string. :rtype: str """ name = str(requirement.name) extras = sorted(str(extra) for extra in getattr(requirement, 'extras', set()) or set()) if extras: name = f'{name}[{",".join(extras)}]' if requirement.url: rendered = f'{name} @ {requirement.url}' else: spec = str(requirement.specifier).strip() rendered = f'{name}{spec}' if spec else name if requirement.marker: rendered = f'{rendered} ; {requirement.marker}' return rendered
[docs] def _iter_specifiers(requirement: Requirement) -> list[Specifier]: """ Return a concrete list of specifiers for the given requirement. :param requirement: Parsed requirement instance. :type requirement: Requirement :return: List of specifier objects. :rtype: list[Specifier] """ return list(requirement.specifier) if requirement.specifier else []
[docs] def _has_frozen_upper_bound(requirement: Requirement) -> bool: """ Determine whether a requirement already contains an upper bound constraint. :param requirement: Parsed requirement instance. :type requirement: Requirement :return: ``True`` if the requirement is already frozen. :rtype: bool """ return any(getattr(spec, 'operator', '') in _FROZEN_OPERATORS for spec in _iter_specifiers(requirement))
[docs] def _highest_lower_bound(requirement: Requirement) -> str | None: """ Extract the highest lower-bound version from ``>=`` and ``>`` specifiers. :param requirement: Parsed requirement instance. :type requirement: Requirement :return: Highest lower bound version string, or ``None`` if missing. :rtype: str | None :raises click.ClickException: If version parsing fails. """ best: tuple[Version, str] | None = None for spec in _iter_specifiers(requirement): if spec.operator not in {'>=', '>'}: continue try: parsed = Version(spec.version) except Exception as exc: # pragma: no cover raise click.ClickException( f'Unable to parse lower bound "{spec.version}" in requirement "{requirement}".' ) from exc if best is None or parsed > best[0]: best = (parsed, spec.version) return best[1] if best else None
[docs] def freeze_requirement(requirement_text: str, *, resolved_version: str | None = None) -> tuple[str, list[str]]: """ Add a computed upper bound to a requirement string, if eligible. The function is intentionally conservative: - URL-based requirements are skipped (cannot be version constrained). - Requirements already containing ``<``, ``<=``, ``~=``, ``==`` or ``===`` are skipped. - Requirements without a lower bound emit a warning and are left unchanged. - When a resolved version is provided, the upper bound is computed from that version instead of the declared lower bound. :param requirement_text: Raw PEP 508 requirement string. :type requirement_text: str :return: Updated requirement text plus warnings. :rtype: tuple[str, list[str]] :raises click.ClickException: If parsing fails. """ warnings: list[str] = [] stripped = requirement_text.strip() if not stripped: return requirement_text, warnings try: requirement = Requirement(stripped) except Exception as exc: raise click.ClickException(f'Unable to parse dependency requirement "{requirement_text}".') from exc if requirement.url: warnings.append(f'Skipping URL requirement (cannot freeze): {requirement_text}') return requirement_text, warnings if _has_frozen_upper_bound(requirement): return requirement_text, warnings lower = _highest_lower_bound(requirement) if lower is None: warnings.append(f'Dependency has no lower bound and will not be frozen: {requirement_text}') return requirement_text, warnings upper = compute_upper_bound(resolved_version or lower) combined = SpecifierSet(f'{requirement.specifier},<{upper}' if str(requirement.specifier).strip() else f'<{upper}') requirement.specifier = combined return _format_requirement(requirement), warnings
[docs] def unfreeze_requirement(requirement_text: str) -> tuple[str, list[str]]: """ Remove a computed upper bound from a requirement string, if it matches the computed rule. Only the auto-generated upper bound ``<upper`` is removed; existing manual upper bounds are preserved. :param requirement_text: Raw PEP 508 requirement string. :type requirement_text: str :return: Updated requirement text plus warnings. :rtype: tuple[str, list[str]] :raises click.ClickException: If parsing fails. """ warnings: list[str] = [] stripped = requirement_text.strip() if not stripped: return requirement_text, warnings try: requirement = Requirement(stripped) except Exception as exc: raise click.ClickException(f'Unable to parse dependency requirement "{requirement_text}".') from exc if requirement.url: return requirement_text, warnings # Do not try to unfreeze already pinned/compatible requirements, or requirements that already # had an upper bound before freezing (we cannot safely distinguish manual bounds). specifiers = _iter_specifiers(requirement) if any(spec.operator in {'~=', '==', '==='} for spec in specifiers): return requirement_text, warnings lower = _highest_lower_bound(requirement) if lower is None: return requirement_text, warnings if any(spec.operator == '<=' for spec in specifiers): return requirement_text, warnings remaining: list[Specifier] = [] removed = False for spec in specifiers: if spec.operator == '<': removed = True continue remaining.append(spec) if not removed: return requirement_text, warnings requirement.specifier = SpecifierSet(','.join(str(spec) for spec in remaining)) return _format_requirement(requirement), warnings
[docs] def _update_dependency_list( dependencies: Any, *, transformer: Callable[[str], tuple[str, list[str]]], warnings: list[str], context: str, ) -> None: """ Update a TOML list of dependency strings in-place. :param dependencies: TOML array that contains dependency strings. :type dependencies: Any :param transformer: Callable applied to each dependency string. :type transformer: Any :param warnings: List of warnings to append to. :type warnings: list[str] :param context: Human-readable location for warnings. :type context: str """ for idx, item in enumerate(list(dependencies)): if not isinstance(item, str): warnings.append(f'Skipping non-string dependency entry in {context}: {item!r}') continue updated, item_warnings = transformer(item) warnings.extend(item_warnings) dependencies[idx] = updated
[docs] def freeze_pyproject_toml( toml_text: str, *, resolved_versions: dict[str, Version] | None = None, doc: tomlkit.TOMLDocument | None = None, ) -> tuple[str, list[str]]: """ Freeze dependencies in a ``pyproject.toml`` payload by adding upper bounds. The TOML structure is preserved via tomlkit; only dependency strings may be normalized. When ``resolved_versions`` is provided, the function uses those compiled versions as the basis for upper-bound computation. Otherwise the declared lower bounds are used as a fallback. :param toml_text: Raw TOML file content. :type toml_text: str :param resolved_versions: Optional mapping of dependency names to resolved versions. :type resolved_versions: dict[str, Version] | None :param doc: Optional pre-parsed TOML document to reuse when available. :type doc: tomlkit.TOMLDocument | None :return: Updated TOML plus warnings. :rtype: tuple[str, list[str]] :raises click.ClickException: If TOML parsing fails. """ warnings: list[str] = [] if doc is None: doc = _load_pyproject_doc(toml_text) project = doc.get('project') if project is None: raise click.ClickException(f'Missing [project] table in {PYPROJECT_FILE}.') def freeze_for_requirement(requirement_text: str) -> tuple[str, list[str]]: try: requirement = Requirement(requirement_text.strip()) except Exception as exc: raise click.ClickException(f'Unable to parse dependency requirement "{requirement_text}".') from exc resolved_version = None if resolved_versions is not None: resolved = resolved_versions.get(requirement.name.lower()) if resolved is not None: resolved_version = str(resolved) return freeze_requirement(requirement_text, resolved_version=resolved_version) dependencies = project.get('dependencies') if dependencies is not None: _update_dependency_list( dependencies, transformer=freeze_for_requirement, warnings=warnings, context='project.dependencies', ) optional = project.get('optional-dependencies') if optional is not None: for group, group_deps in optional.items(): _update_dependency_list( group_deps, transformer=freeze_for_requirement, warnings=warnings, context=f'project.optional-dependencies.{group}', ) return tomlkit.dumps(doc), warnings
[docs] def unfreeze_pyproject_toml( toml_text: str, *, baseline_toml_text: str | None = None, doc: tomlkit.TOMLDocument | None = None, baseline_doc: tomlkit.TOMLDocument | None = None, ) -> tuple[str, list[str]]: """ Unfreeze dependencies in a ``pyproject.toml`` payload by removing computed upper bounds. Only upper bounds matching the computed rule are removed; existing manual constraints remain. :param toml_text: Raw TOML file content. :type toml_text: str :param baseline_toml_text: Optional original TOML text captured before freezing. When provided, unfreezing is computed against the baseline to avoid altering dependencies that were already frozen before release. :type baseline_toml_text: str | None :param doc: Optional pre-parsed TOML document to reuse when available. :type doc: tomlkit.TOMLDocument | None :param baseline_doc: Optional pre-parsed baseline TOML document to reuse when available. :type baseline_doc: tomlkit.TOMLDocument | None :return: Updated TOML plus warnings. :rtype: tuple[str, list[str]] :raises click.ClickException: If TOML parsing fails. """ warnings: list[str] = [] if doc is None: doc = _load_pyproject_doc(toml_text) if baseline_toml_text is not None: if baseline_doc is None: baseline_doc = _load_pyproject_doc(baseline_toml_text) current_project = doc.get('project') baseline_project = baseline_doc.get('project') if current_project is None or baseline_project is None: raise click.ClickException(f'Missing [project] table in {PYPROJECT_FILE}.') current_project['dependencies'] = baseline_project.get('dependencies', current_project.get('dependencies')) current_optional = current_project.get('optional-dependencies') baseline_optional = baseline_project.get('optional-dependencies') if current_optional is not None and baseline_optional is not None: for group in current_optional: if group in baseline_optional: current_optional[group] = baseline_optional[group] return tomlkit.dumps(doc), warnings project = doc.get('project') if project is None: raise click.ClickException(f'Missing [project] table in {PYPROJECT_FILE}.') dependencies = project.get('dependencies') if dependencies is not None: _update_dependency_list( dependencies, transformer=unfreeze_requirement, warnings=warnings, context='project.dependencies', ) optional = project.get('optional-dependencies') if optional is not None: for group, group_deps in optional.items(): _update_dependency_list( group_deps, transformer=unfreeze_requirement, warnings=warnings, context=f'project.optional-dependencies.{group}', ) return tomlkit.dumps(doc), warnings
[docs] def _summarize_freeze_changes(before: str, after: str) -> str: """ Build a short summary for dry-run output. :param before: Original TOML content. :type before: str :param after: Updated TOML content. :type after: str :return: Human-readable summary line. :rtype: str """ if before == after: return 'No dependency constraints would be updated.' before_lines = before.splitlines() after_lines = after.splitlines() return f'pyproject.toml would be updated ({len(before_lines)} -> {len(after_lines)} lines).'
[docs] def freeze_dependencies(*, dry_run: bool) -> str: """ Freeze dependencies by adding computed upper bounds in ``pyproject.toml``. :param dry_run: Whether command execution is disabled. :type dry_run: bool :return: Original ``pyproject.toml`` content captured before freeze. :rtype: str """ CONSOLE.print('Freezing dependencies (adding upper bounds) ...') before = PYPROJECT_FILE.read_text(encoding='utf-8') doc = _load_pyproject_doc(before) supported_python_versions = _project_python_versions_from_doc(doc) resolved_versions = _collect_compiled_dependency_versions(supported_python_versions) after, warnings = freeze_pyproject_toml(before, resolved_versions=resolved_versions, doc=doc) for warning in warnings: CONSOLE.print(f'WARNING: {warning}') if dry_run: CONSOLE.print(_summarize_freeze_changes(before, after)) return before if before != after: PYPROJECT_FILE.write_text(after, encoding='utf-8') return before
[docs] def unfreeze_dependencies(original_pyproject_toml: str, *, dry_run: bool) -> None: """ Unfreeze dependencies by removing computed upper bounds in ``pyproject.toml``. :param dry_run: Whether command execution is disabled. :type dry_run: bool """ CONSOLE.print('Unfreezing dependencies (removing computed upper bounds) ...') before = PYPROJECT_FILE.read_text(encoding='utf-8') doc = _load_pyproject_doc(before) baseline_doc = _load_pyproject_doc(original_pyproject_toml) after, warnings = unfreeze_pyproject_toml( before, baseline_toml_text=original_pyproject_toml, doc=doc, baseline_doc=baseline_doc, ) for warning in warnings: CONSOLE.print(f'WARNING: {warning}') if dry_run: CONSOLE.print(_summarize_freeze_changes(before, after)) return if before != after: PYPROJECT_FILE.write_text(after, encoding='utf-8')
[docs] def update_requirements_and_readme(*, dry_run: bool) -> None: """ Update the requirements RST files and README.rst. :param dry_run: Whether command execution is disabled. :type dry_run: bool """ CONSOLE.print('Updating requirements and README.rst...') cmd(['hatch', 'run', 'dev:multidoc', 'requirements', '--update-readme', *REQUIREMENTS_GROUPS], dry_run=dry_run)
[docs] def commit_dependency_unfreeze(dry_run: bool) -> None: """ Commit the unfreezing of dependency upper bounds to ``main``. :param dry_run: Whether command execution is disabled. :type dry_run: bool """ CONSOLE.print('Committing dependency unfreeze...') tracked_files: list[str] = [str(PYPROJECT_FILE), 'README.rst'] for group in REQUIREMENTS_GROUPS: tracked_files.append(f'docs/source/requirements/{group}_requirements.rst') cmd(['git', 'add', *tracked_files], dry_run=dry_run) cmd(['git', 'commit', '-m', 'chore(dependencies): unfreeze upper bounds'], dry_run=dry_run)
[docs] def _registry_api_config(ctx: click.Context) -> GitlabAPIConfiguration: """Build the GitLab API configuration stored in the Click context.""" obj = ctx.obj or {} try: return build_gitlab_api_configuration( obj.get('gitlab_api_url'), obj.get('gitlab_project_id'), obj.get('gitlab_token'), ) except ValueError as exc: raise click.ClickException(str(exc)) from exc
[docs] def _normalize_pylock_input(item: str) -> tuple[str, str]: """Normalize a dependency registry item into its Python version and file name.""" try: return normalize_dependency_registry_item(item) except ValueError as exc: raise click.ClickException(str(exc)) from exc
[docs] def _normalize_mafw_version_option(value: str | None) -> str: """Return the registry label version, defaulting to the current MAFw version.""" if value is None: return normalize_mafw_version(MAFW_VERSION) try: return normalize_mafw_version(value) except ValueError as exc: raise click.ClickException(str(exc)) from exc
[docs] def _upload_pylock_reference( api_config: GitlabAPIConfiguration, python_version: str, file_path: Path, package_version: str, ) -> None: """ Upload a single dependency reference file to the GitLab registry. :param api_config: GitLab API configuration. :type api_config: GitlabAPIConfiguration :param python_version: Python version associated with the reference. :type python_version: str :param file_path: Path to the local reference file. :type file_path: Path :param package_version: Registry package version label. :type package_version: str """ upload_generic_file( api_config, DEPS_REGISTRY_PACKAGE_NAME, package_version, file_path, replace_existing=True, ) CONSOLE.print( f'Uploaded {file_path.name} for Python {python_version} as {DEPS_REGISTRY_PACKAGE_NAME}/{package_version}' )
[docs] def _download_pylock_reference( api_config: GitlabAPIConfiguration, python_version: str, file_name: str, package_version: str, output_dir: Path, *, quiet_if_missing: bool = False, ) -> Path | None: """ Download a single dependency reference file from the GitLab registry. :param api_config: GitLab API configuration. :type api_config: GitlabAPIConfiguration :param python_version: Python version associated with the reference. :type python_version: str :param file_name: Name of the reference file in the registry. :type file_name: str :param package_version: Registry package version label. :type package_version: str :param output_dir: Directory where the file should be downloaded. :type output_dir: Path :param quiet_if_missing: If ``True``, do not print a message if the file is missing. :type quiet_if_missing: bool :return: Path to the downloaded file, or ``None`` if not found. :rtype: Path | None """ dest = download_generic_file( api_config, DEPS_REGISTRY_PACKAGE_NAME, package_version, file_name, output_dir, ) if dest is not None: CONSOLE.print(f'Downloaded {python_version}: {dest}') elif not quiet_if_missing: CONSOLE.print(f'Not found in registry: {DEPS_REGISTRY_PACKAGE_NAME}/{package_version}/{file_name}') return dest
[docs] 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 click.ClickException: 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 click.ClickException('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 click.ClickException( 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 click.ClickException('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 click.ClickException('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 click.ClickException('Use at most one of: rc, alpha, beta.') return ','.join(normalized)
[docs] 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: VersionKind :raises click.ClickException: If the version cannot be parsed. """ match = VERSION_PATTERN.fullmatch(version.strip()) if match is None: raise click.ClickException(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 click.ClickException(f'Unsupported pre-release suffix "{suffix}" in version "{version}".')
[docs] def check_main_branch() -> None: """ Ensure the release process is executed from the ``main`` branch. :raises click.ClickException: If the current branch is not ``main``. """ branch = run_stdout('git rev-parse --abbrev-ref HEAD') if branch != 'main': raise click.ClickException('Must be on main branch.')
[docs] def ensure_clean_git() -> None: """ Ensure the git working tree is clean (excluding untracked files). :raises click.ClickException: If tracked changes are present. """ status = run_stdout('git status --porcelain -uno') if status: raise click.ClickException('Git working tree is not clean. Commit or stash changes first.')
[docs] def get_last_stable_tag() -> str | None: """ Return the latest stable tag matching ``vX.Y.Z``. :return: Latest stable tag or ``None`` when no stable tags are present. :rtype: str | None """ tags = run_stdout('git tag').splitlines() stable_tags: list[tuple[tuple[int, int, int], str]] = [] for tag in tags: match = STABLE_TAG_PATTERN.fullmatch(tag.strip()) if match is None: continue version_tuple = ( int(match.group('major')), int(match.group('minor')), int(match.group('micro')), ) stable_tags.append((version_tuple, tag.strip())) if not stable_tags: return None stable_tags.sort(key=lambda item: item[0]) return stable_tags[-1][1]
[docs] def parse_stable_tag(tag: str) -> tuple[int, int, int]: """ Parse a stable tag in the form ``vX.Y.Z``. :param tag: Stable tag value. :type tag: str :return: Parsed stable version tuple. :rtype: tuple[int, int, int] :raises click.ClickException: If the tag does not match ``vX.Y.Z``. """ match = STABLE_TAG_PATTERN.fullmatch(tag) if match is None: raise click.ClickException(f'Unsupported stable tag format "{tag}".') return ( int(match.group('major')), int(match.group('minor')), int(match.group('micro')), )
[docs] def check_missing_release(current_version: str) -> None: """ Prevent creating a new release when a stable release is already missing a tag. If the current project version is stable and ahead of the last stable git tag, the release is aborted to avoid skipping the untagged stable release. :param current_version: Current project version. :type current_version: str :raises click.ClickException: If a missing stable release is detected. """ current_kind = classify_version(current_version) if current_kind != 'stable': return current_major, current_minor, current_micro, _ = parse_version(current_version) last_stable_tag = get_last_stable_tag() if last_stable_tag is None: return last_major, last_minor, last_micro = parse_stable_tag(last_stable_tag) current_tuple = (current_major, current_minor, current_micro) last_tuple = (last_major, last_minor, last_micro) if current_tuple > last_tuple: raise click.ClickException( 'Missing stable release detected: ' f'last stable tag is {last_stable_tag}, but current version is v{current_version}. ' 'Tag the current stable version first, or disable this check with ' '--without-missing-release-check.' )
[docs] def prevent_duplicate_tag(version: str) -> None: """ Ensure the target release tag does not already exist. :param version: Target release version. :type version: str :raises click.ClickException: If ``v<version>`` already exists. """ existing_tags = run_stdout('git tag').splitlines() if f'v{version}' in existing_tags: raise click.ClickException(f'Tag v{version} already exists.')
[docs] 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 click.ClickException('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
[docs] 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 click.ClickException: If NOTICE.txt is missing or has unexpected format. """ if not NOTICE_FILE.exists(): raise click.ClickException(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 click.ClickException( 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}.')
[docs] def generate_changelog(version: str, dry_run: bool) -> None: """ Generate the project changelog for the target release. :param version: Target release version. :type version: str :param dry_run: Whether command execution is disabled. :type dry_run: bool """ CONSOLE.print('Generating changelog...') cmd(['hatch', 'run', 'dev.py3.14:change', '-t', version, '--output', str(CHANGELOG_FILE)], dry_run=dry_run)
[docs] def get_release_note_base_ref() -> str: """ Determine the git reference used as baseline for release-note metadata. :return: Last stable tag if available, otherwise the first commit hash. :rtype: str """ stable_tag = get_last_stable_tag() if stable_tag is not None: return stable_tag first_commit = run_stdout('git rev-list --max-parents=0 HEAD').splitlines() if not first_commit: raise click.ClickException('Unable to determine release-note baseline reference.') return first_commit[0]
[docs] def get_release_statistics(since_ref: str) -> tuple[str, str]: """ Collect release statistics since the baseline reference. :param since_ref: Baseline reference to compare against ``HEAD``. :type since_ref: str :return: Commit count and short diff statistics. :rtype: tuple[str, str] """ commit_count = run_stdout(f'git rev-list --count {since_ref}..HEAD') files_changed = run_stdout(f'git diff --shortstat {since_ref}..HEAD') return commit_count, files_changed
[docs] def get_contributors(since_ref: str) -> list[str]: """ Collect contributor names since the baseline reference. :param since_ref: Baseline reference to compare against ``HEAD``. :type since_ref: str :return: Ordered list of contributor names. :rtype: list[str] """ output = run_stdout(f'git shortlog -sn {since_ref}..HEAD') contributors: list[str] = [] for line in output.splitlines(): match = re.match(r'^\s*\d+\s+(.+)$', line) if match is not None: contributors.append(match.group(1).strip()) return contributors
[docs] def extract_version_changelog_block(changelog_content: str, version: str) -> str: """ Extract the changelog block associated with the target version. :param changelog_content: Complete changelog markdown text. :type changelog_content: str :param version: Target release version without leading ``v``. :type version: str :return: Markdown block for the selected release. :rtype: str :raises click.ClickException: If no section for the target version is found. """ header_match = re.search(rf'^## \[{re.escape(version)}\][^\n]*\n', changelog_content, flags=re.MULTILINE) if header_match is None: raise click.ClickException( f'Unable to find changelog section for version {version} in {CHANGELOG_FILE}. ' 'Ensure changelog generation produced a matching release section.' ) block_start = header_match.end() next_release = re.search(r'^## \[[^\]]+\]', changelog_content[block_start:], flags=re.MULTILINE) if next_release is None: return changelog_content[block_start:].strip() return changelog_content[block_start : block_start + next_release.start()].strip()
[docs] def _classify_changelog_subsection(subsection_heading: str) -> str | None: """ Map a changelog subsection heading to a release-note category key. :param subsection_heading: Raw level-3 heading from changelog. :type subsection_heading: str :return: Category key, or ``None`` if the heading is not mappable. :rtype: str | None """ normalized = re.sub(r'[^a-z]+', ' ', subsection_heading.lower()).strip() if 'new' in normalized and 'feature' in normalized: return 'new_features' if 'bug' in normalized and 'fix' in normalized: return 'bug_fixes' if 'refactor' in normalized: return 'refactorings' if 'doc' in normalized: return 'documentation' if 'other' in normalized: return 'other_changes' return None
[docs] def extract_change_sections_from_changelog_block(changelog_block: str) -> dict[str, str]: """ Extract release change sections from a changelog block. The parsing logic expects the block to contain level-3 headings (``###``) with standardized labels (e.g. "New Features", "Bug Fixes", ...). :param changelog_block: Changelog markdown block to parse. :type changelog_block: str :return: Mapping from release-note category key to markdown content. :rtype: dict[str, str] """ sections: dict[str, str] = {key: '' for key in RELEASE_SECTION_HEADERS} subsection_matches = list(re.finditer(r'^###\s+.*$', changelog_block, flags=re.MULTILINE)) for idx, heading_match in enumerate(subsection_matches): heading = heading_match.group(0) category = _classify_changelog_subsection(heading) if category is None or category not in sections: continue start = heading_match.end() end = subsection_matches[idx + 1].start() if idx + 1 < len(subsection_matches) else len(changelog_block) body = changelog_block[start:end].strip() if body: sections[category] = body return sections
[docs] def extract_change_sections_from_changelog(version: str) -> dict[str, str]: """ Extract release change sections from ``CHANGELOG.md``. :param version: Target release version without leading ``v``. :type version: str :return: Mapping from release-note category key to markdown content. :rtype: dict[str, str] :raises click.ClickException: If changelog file does not exist or the requested section is missing. """ if not CHANGELOG_FILE.exists(): raise click.ClickException(f'Unable to find {CHANGELOG_FILE}.') changelog_content = CHANGELOG_FILE.read_text(encoding='utf-8') block = extract_version_changelog_block(changelog_content, version) return extract_change_sections_from_changelog_block(block)
[docs] def render_release_note_section(content: str, header: str, section_markdown: str) -> str: """ Replace a release-note section in the markdown template. If the provided section markdown is empty, the entire section (header and placeholder) is removed from the content. :param content: Current release note markdown text. :type content: str :param header: Section header to replace. :type header: str :param section_markdown: Markdown content to insert under the section header. :type section_markdown: str :return: Updated markdown text. :rtype: str :raises click.ClickException: If the section header cannot be matched in template. """ section_content = section_markdown.strip() if not section_content: # Remove header and placeholder comment, including trailing whitespace # to avoid leaving multiple empty lines between sections. pattern = re.escape(header) + r'\s*\n\s*<!--.*?-->\s*' updated, replacements = re.subn(pattern, '', content, flags=re.DOTALL) else: pattern = re.escape(header) + r'\s*\n\s*<!--.*?-->' updated, replacements = re.subn(pattern, f'{header}\n{section_content}', content, flags=re.DOTALL) if replacements != 1: raise click.ClickException(f'Unable to update section "{header}" in {RELEASE_TEMPLATE_FILE}.') return updated
[docs] def create_release_note(version: str, dry_run: bool) -> Path: """ Create the release note markdown file for the given version. Change sections are copied from the generated changelog for the same version to ensure release notes stay aligned with changelog content. :param version: Target release version. :type version: str :param dry_run: Whether command execution is disabled. :type dry_run: bool :return: Path to the release note markdown file. :rtype: Path :raises click.ClickException: If the template is missing. """ output_path = Path(f'release_note_v{version}.md') if dry_run: CONSOLE.print(f'Release note would be generated at: {output_path}') return output_path if not RELEASE_TEMPLATE_FILE.exists(): raise click.ClickException(f'Release note template not found: {RELEASE_TEMPLATE_FILE}') change_sections = extract_change_sections_from_changelog(version) since_ref = get_release_note_base_ref() commit_count, files_changed = get_release_statistics(since_ref) contributors = get_contributors(since_ref) content = RELEASE_TEMPLATE_FILE.read_text(encoding='utf-8') content = content.replace('${TAG}', f'v{version}') content = render_release_note_section( content, RELEASE_SECTION_HEADERS['new_features'], change_sections['new_features'] ) content = render_release_note_section(content, RELEASE_SECTION_HEADERS['bug_fixes'], change_sections['bug_fixes']) content = render_release_note_section( content, RELEASE_SECTION_HEADERS['refactorings'], change_sections['refactorings'] ) content = render_release_note_section(content, RELEASE_SECTION_HEADERS['removed'], change_sections['removed']) content = render_release_note_section(content, RELEASE_SECTION_HEADERS['deprecated'], change_sections['deprecated']) content = render_release_note_section(content, RELEASE_SECTION_HEADERS['security'], change_sections['security']) content = render_release_note_section( content, RELEASE_SECTION_HEADERS['other_changes'], change_sections['other_changes'] ) content = render_release_note_section( content, '## 👥 Contributors', '\n'.join(f'* {name}' for name in contributors) ) content = render_release_note_section( content, '## 📊 Statistics', f'* Commits: {commit_count}\n* Files changed: {files_changed}', ) output_path.write_text(content, encoding='utf-8') CONSOLE.print(f'Release note generated: {output_path}') return output_path
[docs] def commit_changes(version: str, dry_run: bool, *, include_changelog: bool = True) -> None: """ Commit tracked release artifacts for the target version. The generated release note is intentionally not staged and remains untracked as requested. :param version: Target release version. :type version: str :param dry_run: Whether command execution is disabled. :type dry_run: bool :param include_changelog: Whether ``CHANGELOG.md`` should be staged and committed as part of the release artifacts. :type include_changelog: bool """ CONSOLE.print('Committing tracked release artifacts...') tracked_files: list[str] = [str(ABOUT_FILE), str(NOTICE_FILE), str(PYPROJECT_FILE), 'README.rst'] for group in REQUIREMENTS_GROUPS: tracked_files.append(f'docs/source/requirements/{group}_requirements.rst') if include_changelog: tracked_files.append(str(CHANGELOG_FILE)) cmd(['git', 'add', *tracked_files], dry_run=dry_run) cmd(['git', 'commit', '-m', f'chore(release): v{version}'], dry_run=dry_run)
[docs] def create_tag(version: str, dry_run: bool) -> str: """ Create the local git tag for the target version. :param version: Target release version. :type version: str :param dry_run: Whether command execution is disabled. :type dry_run: bool :return: Created tag name. :rtype: str """ tag = f'v{version}' CONSOLE.print(f'Creating local tag {tag}...') cmd(['git', 'tag', tag], dry_run=dry_run) return tag
[docs] def push_changes(dry_run: bool) -> None: """ Push commits and tags to the remote repository. :param dry_run: Whether command execution is disabled. :type dry_run: bool """ CONSOLE.print('Pushing commits and tags...') cmd(['git', 'push', 'origin-ssh', 'main'], dry_run=dry_run) cmd(['git', 'push', '--tags'], dry_run=dry_run)
[docs] def _parse_python_version(version: str) -> tuple[int, int]: """ Parse a Python version string in ``major.minor`` form. :param version: Python version string. :type version: str :return: Major/minor version tuple. :rtype: tuple[int, int] :raises click.ClickException: If the version is invalid. """ match = re.fullmatch(r'(\d+)\.(\d+)', version.strip()) if match is None: raise click.ClickException(f'Invalid Python version "{version}". Expected format like 3.11.') return int(match.group(1)), int(match.group(2))
[docs] def _python_versions_between( min_python_ver: str, max_python_ver: str, supported_versions: list[str], ) -> list[str]: """ Build the inclusive list of Python versions between two bounds. The returned versions must also be present in ``supported_versions``. :param min_python_ver: Minimum Python version. :type min_python_ver: str :param max_python_ver: Maximum Python version. :type max_python_ver: str :param supported_versions: List of supported Python versions from project metadata. :type supported_versions: list[str] :return: Ordered list of version strings. :rtype: list[str] """ min_major, min_minor = _parse_python_version(min_python_ver) max_major, max_minor = _parse_python_version(max_python_ver) if (min_major, min_minor) > (max_major, max_minor): raise click.ClickException('--min-python-ver must be less than or equal to --max-python-ver.') if min_major != 3 or max_major != 3: raise click.ClickException('Python dependency verification currently supports only Python 3.x versions.') versions = [f'{min_major}.{minor}' for minor in range(min_minor, max_minor + 1)] supported_set = set(supported_versions) if min_python_ver not in supported_set: raise click.ClickException(f'--min-python-ver {min_python_ver} is not listed in tool.mafw.supported-python.') if max_python_ver not in supported_set: raise click.ClickException(f'--max-python-ver {max_python_ver} is not listed in tool.mafw.supported-python.') missing = [version for version in versions if version not in supported_set] if missing: raise click.ClickException( 'Requested Python version range is not fully listed in tool.mafw.supported-python: ' + ', '.join(missing) ) return versions
[docs] def _ensure_mafw_project_root() -> list[str]: """ Ensure the current working directory is the MAFw project root. :return: Validated supported Python versions from ``tool.mafw.supported-python``. :rtype: list[str] :raises click.ClickException: If ``pyproject.toml`` is missing or does not identify MAFw. """ if not PYPROJECT_FILE.exists(): raise click.ClickException(f'Unable to find {PYPROJECT_FILE}. Run the command from the MAFw project root.') pyproject_content = PYPROJECT_FILE.read_text(encoding='utf-8') try: doc = tomlkit.loads(pyproject_content) except TOMLKitError as exc: raise click.ClickException(f'Unable to parse {PYPROJECT_FILE} as TOML.') from exc project = doc.get('project') if project is None or project.get('name') != 'mafw': raise click.ClickException(f'{PYPROJECT_FILE} does not describe the MAFw project.') return _project_python_versions()
@click.group( context_settings=CONTEXT_SETTINGS, help='Development tool for the management of MAFw releases.', cls=AbbreviateGroup ) def main() -> None: """ Entry point for the release management command group. :return: ``None``. """ @main.group(context_settings=CONTEXT_SETTINGS, cls=AbbreviateGroup) @click.pass_context def completion(ctx: click.Context) -> None: """ Manage shell completion for the ``release-mgt`` command. The completion workflow installs the Click-generated shell code into the active virtual environment, updates the activation script so completion is loaded automatically, and exposes a ``show`` helper for direct evaluation. \f .. versionadded:: v2.2 :param ctx: The click context. :type ctx: click.core.Context """ ctx.ensure_object(dict) ctx.obj['tool_name'] = 'release-mgt' @completion.command(name='install') @click.option( '-s', '--shell', type=click.Choice(['auto', 'bash', 'zsh', 'fish'], case_sensitive=False), default='auto', show_default=True, help='Target shell for completion installation', ) @click.option('-F', '--force', is_flag=True, default=False, help='Reinstall completion even if already loaded') @click.pass_context def completion_install(ctx: click.Context, shell: str, force: bool) -> None: """ Install the ``release-mgt`` shell completion script. When ``--shell`` is omitted, the command guesses the shell from ``$SHELL``. The generated Click completion script is stored in the active virtual environment and the activation script is updated so that future shell sessions load completion automatically. \f .. versionadded:: v2.2 :param ctx: The click context. :type ctx: click.core.Context :param shell: Target shell selector. :type shell: str :param force: Reinstall completion even if already loaded. :type force: bool """ check_ci_completion_guard() tool_name = ctx.obj['tool_name'] resolved_shell = _resolve_completion_shell(shell) script_path = _completion_script_path(tool_name, resolved_shell) if is_script_already_installed(tool_name, resolved_shell) and not force: raise click.ClickException( f'release-mgt completion is already installed in {script_path}. Use --force to reinstall it.' ) _install_completion(tool_name, resolved_shell, force, script_path) CONSOLE.print(f'Completion script installed in [blue underline]{script_path}[/blue underline].') CONSOLE.print('Exit and re-enter the virtual environment to activate shell completion.') @completion.command(name='uninstall') @click.pass_context def completion_uninstall(ctx: click.Context) -> None: """ Remove the installed ``release-mgt`` shell completion files. This command removes generated completion files from ``share/mafw`` and strips the marker block from the activation scripts. .. versionadded:: v2.2 :param ctx: The click context. :type ctx: click.core.Context """ tool_name = ctx.obj['tool_name'] _uninstall_completion_files(tool_name, None) CONSOLE.print(f'Shell completion for {tool_name} has been removed from the active virtual environment.') @completion.command(name='show') @click.option( '-s', '--shell', type=click.Choice(['auto', 'bash', 'zsh', 'fish'], case_sensitive=False), default='auto', show_default=True, help='Target shell for completion output', ) @click.pass_context def completion_show(ctx: click.Context, shell: str) -> None: """ Display the Click completion script on standard output. The output is intentionally clean so it can be used with ``eval``: .. code-block:: console eval "$(release-mgt completion show)" .. versionadded:: v2.2 :param ctx: The click context. :type ctx: click.core.Context :param shell: Target shell selector. :type shell: str """ check_ci_completion_guard() tool_name = ctx.obj['tool_name'] resolved_shell = _resolve_completion_shell(shell) click.echo(_completion_source_script(tool_name, resolved_shell), nl=True) @main.group( context_settings=CONTEXT_SETTINGS, help='Dependency verification and maintenance commands.', cls=AbbreviateGroup ) def release() -> None: """ Release-related command group. :return: ``None``. """ @main.group( context_settings=CONTEXT_SETTINGS, help='Dependency verification and maintenance commands.', cls=AbbreviateGroup ) def deps() -> None: """ Dependency-related command group. :return: ``None``. """ @deps.group( context_settings=CONTEXT_SETTINGS, help='Rolling compatibility for the latest versions of MAFw dependencies.', cls=AbbreviateGroup, ) def latest() -> None: """ Group for latest-dependency verification commands. :return: ``None``. """ @latest.command( context_settings=CONTEXT_SETTINGS, help='Verify the latest dependency stack across Python versions.\n\n' 'This command will perform a compatibility check with the newest version\n' 'of all MAFw dependencies.', ) @click.option( '--min-python-ver', default='3.11', show_default=True, type=click.STRING, help='The oldest version of python to be used for the dependencies verification, constrain it to >=3.11', ) @click.option( '--max-python-ver', default='3.14', show_default=True, type=click.STRING, help='The newest version of python to be used for the dependencies verification', ) @click.option( '--full-unittest', is_flag=True, default=False, help='Perform the test over the whole test suite.', ) @click.option( '--with-dep-file/--without-dep-file', default=True, show_default=True, help='Generate dependency lock files using uv pip compile and uninstall managed python before check.', ) @click.option( '--compare-with-ref/--no-compare-with-ref', default=True, show_default=True, help='Compare the generated dependency file with a reference one and skip tests if identical.', ) @click.option( '--gitlab-ref/--no-gitlab-ref', default=False, show_default=True, help='Download/upload dependency reference files from/to the GitLab generic registry.', ) @click.option('--gitlab-api-url', default=None, envvar='CI_API_V4_URL', help='GitLab API v4 base URL.') @click.option('--gitlab-project-id', default=None, type=click.INT, envvar='CI_PROJECT_ID', help='GitLab project ID.') @click.option('--gitlab-token', default=None, envvar='CI_JOB_TOKEN', help='GitLab API token.') def check( min_python_ver: str, max_python_ver: str, full_unittest: bool, with_dep_file: bool, compare_with_ref: bool, gitlab_ref: bool, gitlab_api_url: str | None, gitlab_project_id: int | None, gitlab_token: str | None, ) -> None: """ Verify the latest dependency stack for a Python version range. :param min_python_ver: Minimum Python version. :type min_python_ver: str :param max_python_ver: Maximum Python version. :type max_python_ver: str :param full_unittest: Whether to run the full test suite. :type full_unittest: bool :param with_dep_file: Whether to generate dependency files. :type with_dep_file: bool :param compare_with_ref: Whether to compare with reference lock file. :type compare_with_ref: bool :param gitlab_ref: Whether to use GitLab registry for reference files. :type gitlab_ref: bool :param gitlab_api_url: GitLab API v4 base URL. :type gitlab_api_url: str | None :param gitlab_project_id: GitLab project ID. :type gitlab_project_id: int | None :param gitlab_token: GitLab API token. :type gitlab_token: str | None """ supported_python_versions = _ensure_mafw_project_root() api_config: GitlabAPIConfiguration | None = None package_version = _normalize_mafw_version_option(None) if gitlab_ref: try: api_config = build_gitlab_api_configuration(gitlab_api_url, gitlab_project_id, gitlab_token) except ValueError as exc: raise click.ClickException(str(exc)) from exc for python_version in _python_versions_between(min_python_ver, max_python_ver, supported_python_versions): pylock_file = Path(f'pylock.py{python_version}.toml') pylock_ref_file = Path(f'pylock.py{python_version}_ref.toml') if with_dep_file or compare_with_ref or gitlab_ref: _compile_dependency_lockfile(python_version, pylock_file, list(DEFAULT_FREEZE_EXTRAS)) cmd(['uv', 'python', 'uninstall', '--managed-python', python_version]) if gitlab_ref and api_config: dest = _download_pylock_reference( api_config, _compile_python_selector(python_version), pylock_ref_file.name, package_version, Path.cwd(), quiet_if_missing=True, ) if dest is None: CONSOLE.print(f'WARNING: reference file for Python {python_version} not found in GitLab registry.') if pylock_ref_file.exists(): CONSOLE.print(f'A local reference file {pylock_ref_file} exists and will be used instead.') if (compare_with_ref or gitlab_ref) and pylock_ref_file.exists(): current_data = tomlkit.loads(pylock_file.read_text(encoding='utf-8')) reference_data = tomlkit.loads(pylock_ref_file.read_text(encoding='utf-8')) current_pkgs = {pkg['name']: pkg for pkg in current_data.get('package', [])} reference_pkgs = {pkg['name']: pkg for pkg in reference_data.get('package', [])} differences: list[str] = [] for name, pkg in current_pkgs.items(): if name not in reference_pkgs: differences.append(f'ADDED {name} {pkg.get("version", "unknown")}') else: ref_pkg = reference_pkgs[name] if pkg.get('version') != ref_pkg.get('version') or pkg.get('marker') != ref_pkg.get('marker'): differences.append(f'UPDATED {name}: {ref_pkg.get("version")} -> {pkg.get("version")}') for name in reference_pkgs: if name not in current_pkgs: differences.append(f'REMOVED {name}') if not differences: CONSOLE.print(f'Dependencies for Python {python_version} are identical to reference. Skipping tests.') continue CONSOLE.print(f'Dependencies for Python {python_version} have changed:') for diff in differences: CONSOLE.print(f' - {diff}') cmd(['hatch', 'env', 'remove', f'hatch-test.py{python_version}']) hatch_test_cmd = ['hatch', 'test', '-py', python_version] files_to_be_tested = [] if not full_unittest: files_to_be_tested = DEFAULT_TEST_FILES cmd(hatch_test_cmd + files_to_be_tested) cmd(['hatch', 'env', 'remove', f'types.py{python_version}']) cmd(['hatch', 'run', f'types.py{python_version}:check', '--python-version', python_version]) if compare_with_ref or gitlab_ref: pylock_file.replace(pylock_ref_file) CONSOLE.print(f'Updated reference file: {pylock_ref_file}') if gitlab_ref and api_config: _upload_pylock_reference(api_config, python_version, pylock_ref_file, package_version)
[docs] def _get_expected_lower_bounds() -> dict[str, Requirement]: """ Read pyproject.toml and extract dependencies with their expected lower bounds. :return: A dictionary mapping lowercase package names to their parsed Requirement objects. :rtype: dict[str, Requirement] :raises click.ClickException: If the pyproject.toml cannot be parsed. """ if not PYPROJECT_FILE.exists(): raise click.ClickException(f'Unable to find {PYPROJECT_FILE}.') try: doc = tomlkit.loads(PYPROJECT_FILE.read_text(encoding='utf-8')) except TOMLKitError as exc: raise click.ClickException(f'Unable to parse {PYPROJECT_FILE} as TOML.') from exc project = doc.get('project') if project is None: raise click.ClickException(f'Missing [project] table in {PYPROJECT_FILE}.') dependencies = project.get('dependencies', []) lower_bounds: dict[str, Requirement] = {} for dep_str in dependencies: if not isinstance(dep_str, str): continue try: req = Requirement(dep_str) except Exception as exc: raise click.ClickException(f'Unable to parse dependency requirement "{dep_str}".') from exc if _highest_lower_bound(req) is not None: lower_bounds[req.name.lower()] = req return lower_bounds
[docs] def _verify_lowest_resolution( env_name: str, python_version: str, expected_bounds: dict[str, Requirement], ) -> None: """ Verify that the specified environment has the expected lowest dependency versions. This function executes `uv pip list` in the given environment, parses the output, and compares installed versions against the expected lower bounds extracted from `pyproject.toml`. :param env_name: The name of the environment (e.g., 'hatch-test', 'types'). :type env_name: str :param python_version: The Python version string (e.g., '3.11'). :type python_version: str :param expected_bounds: Mapping of package names to their Requirement objects. :type expected_bounds: dict[str, Requirement] :raises click.ClickException: If a required dependency is missing. """ CONSOLE.print(f'Verifying lowest resolution for {env_name} (Python {python_version})...') # Construct the command: hatch run <env_name>.py<python_version>:uv pip list --format json command = [ 'hatch', 'run', f'{env_name}.py{python_version}:uv', 'pip', 'list', '--format', 'json', ] try: output = run_stdout(command) installed_pkgs = json.loads(output) except Exception as exc: raise click.ClickException(f'Failed to retrieve installed packages for {env_name}.py{python_version}: {exc}') # Create a mapping for easy lookup installed_map = {pkg['name'].lower(): pkg['version'] for pkg in installed_pkgs} # Prepare environment markers for evaluation # Note: packaging.markers.Marker.evaluate() can take an optional environment dict. # If not provided, it uses the current process environment. Since we are checking # for a specific python version, we should provide a mock environment. marker_env = { 'python_version': python_version, 'sys_platform': sys.platform, # Add other common markers if needed, but python_version is the most critical here. } for name, req in expected_bounds.items(): # Skip if marker doesn't apply to this python version if req.marker and not req.marker.evaluate(marker_env): continue if name not in installed_map: raise click.ClickException( f'Required dependency "{req.name}" is missing in environment {env_name}.py{python_version}.' ) installed_version_str = installed_map[name] expected_version_str = _highest_lower_bound(req) if expected_version_str is None: continue # Should not happen given _get_expected_lower_bounds logic installed_version = Version(installed_version_str) expected_version = Version(expected_version_str) if installed_version > expected_version: CONSOLE.print( f' WARNING: {req.name} version {installed_version_str} is newer than ' f'the expected lower bound {expected_version_str}.' ) elif installed_version < expected_version: # This shouldn't happen with lowest-direct unless there's a conflict, but worth noting CONSOLE.print( f' INFO: {req.name} version {installed_version_str} is older than ' f'the expected lower bound {expected_version_str}.' ) else: CONSOLE.print(f' OK: {req.name} is at version {installed_version_str}.')
@deps.group( context_settings=CONTEXT_SETTINGS, help='Placeholder commands for the oldest dependency stack.', cls=AbbreviateGroup ) def oldest() -> None: """ Group for oldest-dependency commands. :return: ``None``. """ @oldest.command( name='check', context_settings=CONTEXT_SETTINGS, help='Verify compatibility with oldest supported dependencies.' ) @click.option( '--min-python-ver', default='3.11', show_default=True, type=click.STRING, help='The oldest version of python to be used for the dependencies verification, constrain it to >=3.11', ) @click.option( '--max-python-ver', default='3.14', show_default=True, type=click.STRING, help='The newest version of python to be used for the dependencies verification', ) @click.option( '--full-unittest', is_flag=True, default=False, show_default=True, help='Perform the test over the whole test suite.', ) @click.option( '--remove-envs/--preserve-envs', default=True, show_default=True, help='Preserve created environment with lowest resolution after the check is completed', ) def check_oldest( min_python_ver: str, max_python_ver: str, full_unittest: bool, remove_envs: bool, ) -> None: """ Verify MAFw compatibility against the oldest supported dependency stack. This command iterates over a range of Python versions and, for each, creates isolated Hatch environments (`hatch-test` and `types`) using the UV solver's `lowest-direct` resolution. This ensures that the environment is populated with the minimum versions allowed by the `pyproject.toml` constraints. Verification steps: 1. Extract expected lower bounds from `project.dependencies` in `pyproject.toml`. 2. Create `hatch-test` environment with `UV_RESOLUTION=lowest-direct`. 3. Verify installed versions match the expected lower bounds via `uv pip list`. 4. Run the test suite (full or integration-only). 5. Create `types` environment with `UV_RESOLUTION=lowest-direct`. 6. Verify installed versions match the expected lower bounds. 7. Run static type checking. If a required dependency is missing in the created environment, a hard error is raised. If an installed dependency is newer than the expected lower bound, a warning is emitted. :param min_python_ver: The oldest Python version to verify (e.g., '3.11'). :type min_python_ver: str :param max_python_ver: The newest Python version to verify (e.g., '3.14'). :type max_python_ver: str :param full_unittest: If True, run all tests; otherwise, run default tests. :type full_unittest: bool :param remove_envs: If True, delete the temporary environments after check. :type remove_envs: bool :raises click.ClickException: If verification fails or environments cannot be created. """ supported_python_versions = _ensure_mafw_project_root() expected_bounds = _get_expected_lower_bounds() lowest_resolution = {'UV_RESOLUTION': 'lowest-direct'} for python_version in _python_versions_between(min_python_ver, max_python_ver, supported_python_versions): # remove existing test envs cmd_line = shlex.split(f'hatch env remove hatch-test.py{python_version}') cmd(cmd_line) # create test env with lowest-direct resolution cmd_line = shlex.split(f'hatch env create hatch-test.py{python_version}') cmd(cmd_line, env=lowest_resolution) # verify that the freshly created environment has the right lowest resolution _verify_lowest_resolution('hatch-test', python_version, expected_bounds) # run the test suite full or reduced files_to_be_tested = [] if not full_unittest: files_to_be_tested = DEFAULT_TEST_FILES cmd_line = shlex.split(f'hatch test -py {python_version}') cmd(cmd_line + files_to_be_tested) # remove existing typing env cmd_line = shlex.split(f'hatch env remove types.py{python_version}') cmd(cmd_line) # create static typing env with lowest direct resolution cmd_line = shlex.split(f'hatch env create types.py{python_version}') cmd(cmd_line, env=lowest_resolution) # verify that the freshly created environment has the right lowest resolution _verify_lowest_resolution('types', python_version, expected_bounds) # run static typing cmd_line = shlex.split(f'hatch run types.py{python_version}:check --python-version {python_version}') cmd(cmd_line) if remove_envs: # remove environments envs = ['hatch-test.py', 'types.py'] for env in envs: cmd_line = shlex.split(f'hatch env remove {env}{python_version}') cmd(cmd_line) @deps.command(context_settings=CONTEXT_SETTINGS, help='Freeze dependency upper bounds in pyproject.toml.') def freeze() -> None: """ Freeze dependency upper bounds in the project TOML. :return: ``None``. """ before = PYPROJECT_FILE.read_text(encoding='utf-8') doc = _load_pyproject_doc(before) supported_python_versions = _project_python_versions_from_doc(doc) resolved_versions = _collect_compiled_dependency_versions(supported_python_versions) after, warnings = freeze_pyproject_toml(before, resolved_versions=resolved_versions, doc=doc) for warning in warnings: CONSOLE.print(f'WARNING: {warning}') if before != after: PYPROJECT_FILE.write_text(after, encoding='utf-8') @deps.command(context_settings=CONTEXT_SETTINGS, help='Remove frozen dependency upper bounds from pyproject.toml.') def unfreeze() -> None: """ Unfreeze dependency upper bounds in the project TOML. :return: ``None``. """ before = PYPROJECT_FILE.read_text(encoding='utf-8') after, warnings = unfreeze_pyproject_toml(before) for warning in warnings: CONSOLE.print(f'WARNING: {warning}') if before != after: PYPROJECT_FILE.write_text(after, encoding='utf-8')
[docs] def _run_pip_audit( req_file: Path, output_file: Path, output_format: str, ) -> subprocess.CompletedProcess[Any]: """ Run pip-audit on a requirements file and save the output. :param req_file: Requirements file to audit. :type req_file: Path :param output_file: Path to the output report. :type output_file: Path :param output_format: Format of the report (e.g. 'markdown', 'json'). :type output_format: str :return: Completed process produced by the command execution. :rtype: subprocess.CompletedProcess[Any] """ cmd_parts = [ 'pip-audit', '-r', str(req_file), '--format', output_format, '-o', str(output_file), '--disable-pip', ] return cmd(cmd_parts, check=False)
@deps.command( context_settings=CONTEXT_SETTINGS, help='Audit dependency vulnerabilities using pip-audit.', ) @click.option( '--min-python-ver', default=None, type=click.STRING, help='The oldest version of python to be used for the audit. Default: lowest supported in pyproject.toml', ) @click.option( '--max-python-ver', default=None, type=click.STRING, help='The newest version of python to be used for the audit. Default: highest supported in pyproject.toml', ) @click.option( '-r', '--resolution', type=click.Choice(['highest', 'lowest', 'both']), default='both', show_default=True, help='The uv resolution strategy to audit.', ) @click.option( '--output-dir', type=click.Path(path_type=Path), default=Path('./audit'), show_default=True, help='Directory where to store the audit artifact files.', ) def audit( min_python_ver: str | None, max_python_ver: str | None, resolution: str, output_dir: Path, ) -> None: """ Audit dependency vulnerabilities using pip-audit. This command compiles the project requirements using uv with the specified resolution strategy and python version, then runs pip-audit to produce markdown and JSON reports. :param min_python_ver: Oldest Python version (e.g. '3.11'). :type min_python_ver: str | None :param max_python_ver: Newest Python version (e.g. '3.14'). :type max_python_ver: str | None :param resolution: UV resolution strategy ('highest', 'lowest', or 'both'). :type resolution: str :param output_dir: Directory where report artifacts are saved. :type output_dir: Path :raises click.ClickException: If a step fails or vulnerabilities are found. """ supported_python_versions = _ensure_mafw_project_root() if min_python_ver is None: min_python_ver = supported_python_versions[0] if max_python_ver is None: max_python_ver = supported_python_versions[-1] resolutions: list[str] if resolution == 'highest': resolutions = ['highest'] elif resolution == 'lowest': resolutions = ['lowest-direct'] else: resolutions = ['highest', 'lowest-direct'] python_versions = _python_versions_between(min_python_ver, max_python_ver, supported_python_versions) output_dir.mkdir(parents=True, exist_ok=True) has_vulnerabilities = False for res, python_ver in itertools.product(resolutions, python_versions): file_res = 'lowest' if res == 'lowest-direct' else 'highest' req_file = output_dir / f'requirements_{file_res}_{python_ver}.txt' audit_md = output_dir / f'audit_{file_res}_{python_ver}.md' audit_json = output_dir / f'audit_{file_res}_{python_ver}.json' if req_file.exists(): req_file.unlink() # Compile requirements.txt _compile_dependency_lockfile( python_version=python_ver, pylock_file=req_file, extras=list(DEFAULT_FREEZE_EXTRAS), resolution=res, output_format='requirements.txt', with_hashes=True, ) # Run pip-audit for markdown report res_md = _run_pip_audit(req_file, audit_md, 'markdown') if res_md.returncode != 0: has_vulnerabilities = True elif not audit_md.exists(): # pip-audit might not create the file if no vulnerabilities are found audit_md.write_text('No known vulnerabilities found.\n', encoding='utf-8') # Run pip-audit for JSON report res_json = _run_pip_audit(req_file, audit_json, 'json') if res_json.returncode != 0: has_vulnerabilities = True # Prepare overall auditing report report_file = output_dir / 'audit_report.md' timestamp = datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC') report_lines = [ '# Dependency vulnerabilities audit', '', f'Performed on {timestamp}', '', ] # Include Oldest supported ecosystem if lowest was requested and the files exist lowest_files_exist = False for python_ver in python_versions: if (output_dir / f'audit_lowest_{python_ver}.md').exists(): lowest_files_exist = True break if 'lowest-direct' in resolutions and lowest_files_exist: report_lines.extend( [ '## Oldest supported ecosystem', '', ] ) for python_ver in python_versions: md_file = output_dir / f'audit_lowest_{python_ver}.md' if md_file.exists(): report_lines.extend( [ f'### Python version {python_ver}', '', md_file.read_text(encoding='utf-8'), '', ] ) # Include Newest supported ecosystem if highest was requested and the files exist highest_files_exist = False for python_ver in python_versions: if (output_dir / f'audit_highest_{python_ver}.md').exists(): highest_files_exist = True break if 'highest' in resolutions and highest_files_exist: report_lines.extend( [ '## Newest supported ecosystem', '', ] ) for python_ver in python_versions: md_file = output_dir / f'audit_highest_{python_ver}.md' if md_file.exists(): report_lines.extend( [ f'### Python version {python_ver}', '', md_file.read_text(encoding='utf-8'), '', ] ) report_file.write_text('\n'.join(report_lines), encoding='utf-8') if has_vulnerabilities: raise click.ClickException('Vulnerabilities were found during the audit.') @deps.group( context_settings=CONTEXT_SETTINGS, help='GitLab registry operations for dependency reference files.', cls=AbbreviateGroup, ) @click.option('--gitlab-api-url', default=None, envvar='CI_API_V4_URL', help='GitLab API v4 base URL.') @click.option( '--gitlab-project-id', default=None, type=int, envvar='CI_PROJECT_ID', help='GitLab project numeric id (env: CI_PROJECT_ID).', ) @click.option('--gitlab-token', default=None, envvar='CI_JOB_TOKEN', help='GitLab API token.') @click.pass_context def registry( ctx: click.Context, gitlab_api_url: str | None, gitlab_project_id: int | None, gitlab_token: str | None, ) -> None: """ GitLab registry command group for dependency reference files. :param ctx: Click context. :type ctx: click.Context :param gitlab_api_url: GitLab API v4 base URL. :type gitlab_api_url: str | None :param gitlab_project_id: GitLab project ID. :type gitlab_project_id: int | None :param gitlab_token: GitLab API token. :type gitlab_token: str | None """ ctx.obj = ctx.obj or {} ctx.obj['gitlab_api_url'] = gitlab_api_url ctx.obj['gitlab_project_id'] = gitlab_project_id ctx.obj['gitlab_token'] = gitlab_token @registry.command() @click.argument('items', nargs=-1, required=False) @click.option('-a', '--all', 'all_entries', is_flag=True, default=False, help='Upload all matching reference files.') @click.option( '--mafw-version', default=None, help='MAFw package version label to use in the registry. Accepts X.Y.Z or vX.Y.Z.', ) @click.pass_context def upload(ctx: click.Context, items: tuple[str, ...], all_entries: bool, mafw_version: str | None) -> None: """ Upload dependency reference files to the GitLab generic registry. :param ctx: Click context. :type ctx: click.Context :param items: File names or Python versions. :type items: tuple[str, ...] :param all_entries: Whether to upload all local reference files. :type all_entries: bool :param mafw_version: Registry package version label override. :type mafw_version: str | None """ api_config = _registry_api_config(ctx) package_version = _normalize_mafw_version_option(mafw_version) candidates: list[tuple[str, Path]] = [] if all_entries: if items: raise click.ClickException('Do not pass filenames when using --all.') candidates = iter_local_pylock_reference_files(Path.cwd()) else: if not items: raise click.ClickException('Provide one or more filenames or Python versions, or use --all.') for item in items: python_version, normalized_name = _normalize_pylock_input(item) fp = Path(item) if not fp.is_absolute(): cwd_fp = Path.cwd() / fp if cwd_fp.exists(): fp = cwd_fp elif item == python_version: fp = Path.cwd() / normalized_name if not fp.exists(): raise click.ClickException(f'File not found: {fp}') if not PYLOCK_REFERENCE_PATTERN.fullmatch(fp.name): raise click.ClickException(f'File does not match pylock.py3.xx_ref.toml: {fp.name}') candidates.append((python_version, fp)) if not candidates: CONSOLE.print('No matching reference files found.') return for python_version, fp in candidates: _upload_pylock_reference(api_config, python_version, fp, package_version) @registry.command() @click.argument('items', nargs=-1, required=False) @click.option('--output-dir', default='.', type=click.Path(file_okay=False, dir_okay=True, path_type=Path)) @click.option('-a', '--all', 'all_entries', is_flag=True, default=False, help='Download all registry files.') @click.option( '--mafw-version', default=None, help='MAFw package version label to use in the registry. Accepts X.Y.Z or vX.Y.Z.', ) @click.pass_context def download( ctx: click.Context, items: tuple[str, ...], output_dir: Path, all_entries: bool, mafw_version: str | None, ) -> None: """Download dependency reference files from the GitLab generic registry.""" api_config = _registry_api_config(ctx) package_version = _normalize_mafw_version_option(mafw_version) output_dir = Path(output_dir).resolve() output_dir.mkdir(parents=True, exist_ok=True) if all_entries and items: raise click.ClickException('Do not pass filenames when using --all.') targets: list[tuple[str, str]] = [] if all_entries: mapping = resolve_package_ids_by_version(api_config, DEPS_REGISTRY_PACKAGE_NAME) targets = [(python_version, f'pylock.py{python_version}_ref.toml') for python_version in sorted(mapping)] else: if not items: raise click.ClickException('Provide one or more filenames or Python versions, or use --all.') for item in items: python_version, file_name = _normalize_pylock_input(item) targets.append((python_version, file_name)) for python_version, file_name in targets: _download_pylock_reference( api_config, python_version, file_name, package_version, output_dir, ) @registry.command() @click.argument('items', nargs=-1, required=False) @click.option('-a', '--all', 'all_entries', is_flag=True, default=False, help='Delete all registry files.') @click.option( '--mafw-version', default=None, help='MAFw package version label to use in the registry. Accepts X.Y.Z or vX.Y.Z.', ) @click.pass_context def delete(ctx: click.Context, items: tuple[str, ...], all_entries: bool, mafw_version: str | None) -> None: """Delete dependency reference files from the GitLab generic registry.""" api_config = _registry_api_config(ctx) package_version = _normalize_mafw_version_option(mafw_version) mapping = resolve_package_ids_by_version(api_config, DEPS_REGISTRY_PACKAGE_NAME) if all_entries and items: raise click.ClickException('Do not pass filenames when using --all.') targets: list[str] = [] if all_entries: targets = sorted(mapping.keys()) else: if not items: raise click.ClickException('Provide one or more filenames or Python versions, or use --all.') for item in items: python_version, _ = _normalize_pylock_input(item) targets.append(python_version) for python_version in targets: pkg_id = mapping.get(python_version) if pkg_id is None: CONSOLE.print(f'Not found in registry: {DEPS_REGISTRY_PACKAGE_NAME}/{package_version}/{python_version}') continue deleted = delete_generic_package_version(api_config, pkg_id, DEPS_REGISTRY_PACKAGE_NAME, package_version) if deleted: CONSOLE.print(f'Deleted {DEPS_REGISTRY_PACKAGE_NAME}/{package_version} for Python {python_version}') else: CONSOLE.print(f'Not found in registry: {DEPS_REGISTRY_PACKAGE_NAME}/{package_version}/{python_version}') @release.command( name='create', context_settings=CONTEXT_SETTINGS, help=( 'Prepare a new MAFw release from the main branch.\n\n' 'The command bumps the project version via hatch, updates the documentation target version, ' 'updates NOTICE.txt, regenerates CHANGELOG.md, optionally generates a release note markdown file, ' 'commits tracked release files, creates a local git tag, and optionally pushes ' 'commits and tags to code.europa.eu/main using ssh.\n\n' 'Release-note change sections are copied from the generated changelog section ' 'for the target version to keep both artifacts aligned.\n\n' 'Safety checks include clean working tree, duplicate tag prevention, and optional ' 'missing-release detection that blocks a new release when the current stable version ' 'is ahead of the latest stable tag.\n\n' 'The version selector supports comma-separated Hatch segments (e.g. "minor,rc", "rc", ' '"alpha", "beta", "release").\n\n' 'Dry-run mode prints all commands and resolves the target version via hatch, then restores ' f'{ABOUT_FILE} so the git worktree remains unchanged.' ), ) @click.argument('segments', type=str) @click.option( '--dry-run/--no-dry-run', default=False, show_default=True, help='Print commands and computed actions without executing git/hatch operations.', ) @click.option( '--with-push/--without-push', default=True, show_default=True, help='Enable or skip pushing commits and tags to the remote repository.', ) @click.option( '--with-release-note/--without-release-note', default=True, show_default=True, help='Enable or skip generation of release_note_v<version>.md.', ) @click.option( '--with-missing-release-check/--without-missing-release-check', default=True, show_default=True, help='Enable or skip the guard that prevents skipping an untagged stable release.', ) @click.option( '--auto-doc-version/--manual-doc-version', default=False, show_default=True, help='Automatically update the documentation target version without prompting.', ) @click.option( '--new-doc-version', default=None, type=click.STRING, help='Override the documentation target version using a major.minor value.', ) def create( segments: str, dry_run: bool, with_push: bool, with_release_note: bool, with_missing_release_check: bool, auto_doc_version: bool, new_doc_version: str | None, ) -> None: """ Execute the release pipeline for the selected version segment. :param segments: Hatch selector segments (comma-separated). :type segments: str :param dry_run: Whether command execution is disabled. :type dry_run: bool :param with_push: Whether remote pushes are enabled. :type with_push: bool :param with_release_note: Whether the release note file should be generated. :type with_release_note: bool :param with_missing_release_check: Whether missing-release safety check is enabled. :type with_missing_release_check: bool :param auto_doc_version: Whether the doc target version is applied without prompting. :type auto_doc_version: bool :param new_doc_version: Optional explicit documentation target version override. :type new_doc_version: str | None """ current_version = read_current_version() normalized_segments = normalize_hatch_segments(segments) if dry_run: CONSOLE.print( 'Dry-run mode enabled: commands are printed; git/changelog writes are skipped; ' f'version is resolved via hatch and {ABOUT_FILE} is restored afterwards.' ) else: check_main_branch() ensure_clean_git() if with_missing_release_check: check_missing_release(current_version) original_pyproject_toml = freeze_dependencies(dry_run=dry_run) version = bump_version( dry_run=dry_run, segment=normalized_segments, ) doc_target_version = _compute_doc_target_version(version, normalized_segments, new_doc_version) if auto_doc_version: CONSOLE.print(f'Documentation target version set to {doc_target_version}.') else: CONSOLE.print('Provide a new documentation target version.') prompt_default = _validate_doc_target_version(doc_target_version) doc_target_version = DocTargetVersionPrompt( 'Documentation target version', show_default=True, min_version=_next_minor_version(version), )(default=prompt_default) CONSOLE.print(f'Documentation target version selected: {doc_target_version}.') update_doc_target_version(doc_target_version, dry_run=dry_run) if not dry_run: prevent_duplicate_tag(version) update_notice_version(version, dry_run=dry_run) generate_changelog(version, dry_run=dry_run) changelog_updated = True update_requirements_and_readme(dry_run=dry_run) release_note_path: Path | None = None if with_release_note: release_note_path = create_release_note(version, dry_run=dry_run) CONSOLE.print(f'Release note file: {release_note_path}') commit_changes(version, dry_run=dry_run, include_changelog=True) tag = create_tag(version, dry_run=dry_run) unfreeze_dependencies(original_pyproject_toml, dry_run=dry_run) update_requirements_and_readme(dry_run=dry_run) commit_dependency_unfreeze(dry_run=dry_run) if with_push: push_changes(dry_run=dry_run) else: CONSOLE.print('Skipping remote push because --without-push is set.') CONSOLE.print('') CONSOLE.print('Release pipeline completed.') CONSOLE.print(f'Version: v{version}') CONSOLE.print(f'Documentation target: {doc_target_version}') CONSOLE.print(f'Tag: {tag}') if with_release_note and release_note_path is not None: CONSOLE.print(f'Release note: {release_note_path} (left untracked)') if changelog_updated: CONSOLE.print(f'{CHANGELOG_FILE} updated.') CONSOLE.print(f'{NOTICE_FILE} updated.') if __name__ == '__main__': main()