# Copyright 2026 European Union
# Author: Bulgheroni Antonio (antonio.bulgheroni@ec.europa.eu)
# SPDX-License-Identifier: EUPL-1.2
"""
Dependency compilation utilities for MAFw.
This module provides functions for compiling dependency lockfiles using ``uv``,
reading resolved dependency versions, and managing Python version metadata
from the project configuration.
"""
from __future__ import annotations
import re
import tempfile
import tomllib
from pathlib import Path
from typing import Any, Final
import tomlkit
from mafw.devtools import ensure_devtools_available
ensure_devtools_available()
from packaging.version import Version # noqa: E402
from tomlkit.exceptions import TOMLKitError # noqa: E402
from mafw.devtools import DevtoolsError # noqa: E402
from mafw.tools.shell_tools import run as cmd # noqa: E402
PYPROJECT_FILE: Final[Path] = Path('pyproject.toml')
"""Path to the TOML file containing the project dependencies."""
DEFAULT_FREEZE_EXTRAS: Final[tuple[str, ...]] = ('seaborn', 'all-db', 'steering-gui')
"""Extras used when compiling dependency lockfiles for release freezing and compatibility checks."""
[docs]
def load_pylock_packages(pylock_path: Path) -> dict[str, dict[str, Any]]:
"""Parse a pylock TOML file into a dictionary keyed by lowercase package name.
Each value in the returned dictionary contains at minimum the ``name``,
``version``, and optionally ``marker`` fields from the original TOML entry.
:param pylock_path: Path to the pylock TOML file.
:type pylock_path: Path
:return: Dictionary mapping lowercase package names to their package metadata.
:rtype: dict[str, dict[str, Any]]
:raises FileNotFoundError: If *pylock_path* does not exist.
:raises tomllib.TOMLDecodeError: If the file is not valid TOML.
"""
with open(pylock_path, 'rb') as f:
data = tomllib.load(f)
packages: dict[str, dict[str, Any]] = {}
for pkg in data.get('packages', []):
# Use lowercase name as the key for case-insensitive matching.
name = pkg.get('name', '')
packages[name.lower()] = dict(pkg)
return packages
[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 DevtoolsError: If the version is invalid.
"""
match = re.fullmatch(r'(\d+)\.(\d+)', version.strip())
if match is None:
raise DevtoolsError(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 DevtoolsError('--min-python-ver must be less than or equal to --max-python-ver.')
if min_major != 3 or max_major != 3:
raise DevtoolsError('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 DevtoolsError(f'--min-python-ver {min_python_ver} is not listed in tool.mafw.supported-python.')
if max_python_ver not in supported_set:
raise DevtoolsError(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 DevtoolsError(
'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 DevtoolsError: If ``pyproject.toml`` is missing or does not identify MAFw.
"""
if not PYPROJECT_FILE.exists():
raise DevtoolsError(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 DevtoolsError(f'Unable to parse {PYPROJECT_FILE} as TOML.') from exc
project = doc.get('project')
if project is None or project.get('name') != 'mafw':
raise DevtoolsError(f'{PYPROJECT_FILE} does not describe the MAFw project.')
return project_python_versions()
[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 DevtoolsError: 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 DevtoolsError(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 DevtoolsError(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 DevtoolsError('tool.mafw.supported-python must contain only strings.')
match = re.fullmatch(r'(\d+)\.(\d+)', item.strip())
if match is None:
raise DevtoolsError(
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 DevtoolsError(
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 DevtoolsError: If ``pyproject.toml`` cannot be parsed or the
field contains unsupported values.
"""
if not PYPROJECT_FILE.exists():
raise DevtoolsError(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( # pragma: no cover
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(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 DevtoolsError: If the TOML payload cannot be parsed.
"""
try:
doc = tomlkit.loads(pylock_text)
except TOMLKitError as exc:
raise DevtoolsError('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 DevtoolsError(
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 DevtoolsError: If the TOML payload cannot be parsed.
"""
try:
return tomlkit.loads(toml_text)
except TOMLKitError as exc:
raise DevtoolsError(f'Unable to parse {PYPROJECT_FILE} as TOML.') from exc
[docs]
def collect_compiled_dependency_versions(python_versions: list[str]) -> dict[str, Version]: # pragma: no cover
"""
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 DevtoolsError('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