Source code for mafw.devtools.dependencies.compile

#  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