#!/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 _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_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 _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 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()