Source code for mafw.devtools.pyversions.derivations

#  Copyright 2026 European Union
#  Author: Bulgheroni Antonio (antonio.bulgheroni@ec.europa.eu)
#  SPDX-License-Identifier: EUPL-1.2
"""
Canonical-to-derived projections for the Python version management feature.

Every location in ``pyproject.toml`` that the feature keeps in sync is a pure
*projection* of the canonical :class:`~mafw.devtools.pyversions.config.PythonConfig`.
This module gathers those projection functions so that both the synchronization
service and the consistency check compute the *desired* value of each derived
location in exactly one place.

It also provides the classification used to distinguish version references that
are **managed** by a given role (and must therefore follow it) from deliberate
**pinned** interpreter selections (which must never be rewritten by a role
change), such as the pre-push command ``hatch test -py 3.12`` that intentionally
exercises an interpreter without ``sys._is_gil_enabled``.

> ** IMPORTANT NOTE **
> Along with some very stable projections, like for example the project classifiers,
> this module is addressing also a few points (gil / nogil environments and pre-push
> command definition) that are rather volatile. So everytime we change something in the
> `pyproject.toml` we should verify that there are not drifts with respect to what
> is described here.

.. versionadded:: 2.4

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

from __future__ import annotations

import enum
import re
from typing import Final

from mafw.devtools.pyversions.config import PythonConfig, base_version

# ---------------------------------------------------------------------------
# Desired derived values (pure projections of the canonical configuration)
# ---------------------------------------------------------------------------

_CLASSIFIER_PREFIX: Final[str] = 'Programming Language :: Python :: '
"""Prefix of the per-version Python classifiers in ``[project].classifiers``."""


[docs] def desired_classifiers(cfg: PythonConfig) -> list[str]: """Return the per-version Python classifiers implied by the supported list. Experimental versions are deliberately excluded: a version under evaluation must never be advertised as officially supported. """ return [f'{_CLASSIFIER_PREFIX}{version}' for version in cfg.supported]
[docs] def desired_standard_matrix(cfg: PythonConfig) -> list[str]: """Return the ``hatch-test`` standard matrix (the supported versions).""" return list(cfg.supported)
[docs] def desired_dev_matrix(cfg: PythonConfig) -> list[str]: """Return the ``dev`` environment matrix. The ``dev`` environment intentionally omits the free-threaded interpreter (its optional features are incompatible with it), so the matrix is exactly the stable supported versions. """ return list(cfg.supported)
[docs] def desired_types_matrix(cfg: PythonConfig) -> list[str]: """Return the ``types`` environment matrix (the supported versions).""" return list(cfg.supported)
[docs] def desired_free_threaded_matrix(cfg: PythonConfig) -> list[str]: """Return the dedicated ``hatch-test`` free-threaded matrix row.""" return [cfg.free_threaded]
[docs] def desired_nogil_python(cfg: PythonConfig) -> str: """Return the interpreter for the ``nogil`` environment (the free-threaded one).""" return cfg.free_threaded
[docs] def desired_gil_python(cfg: PythonConfig) -> str: """Return the interpreter for the ``gil`` environment. The ``gil`` environment mirrors ``nogil`` but with the standard, GIL-enabled interpreter of the *same* minor version, so that performance comparisons stay aligned. It therefore tracks the base version of the free-threaded selector. """ return base_version(cfg.free_threaded)
[docs] def desired_experimental_matrix(cfg: PythonConfig) -> list[str]: """Return the dedicated ``experimental`` environment matrix. Experimental versions live only here; they never expand the mandatory standard, ``dev`` or ``types`` matrices. """ return list(cfg.experimental)
# --------------------------------------------------------------------------- # Pre-push command classification (managed vs pinned) # --------------------------------------------------------------------------- # A ``hatch test -py <selector>`` invocation. The selector may be a stable # ``3.14`` target or a free-threaded ``3.14t`` selector. _HATCH_TEST_PY_RE: Final[re.Pattern[str]] = re.compile(r'\bhatch\s+test\s+-py\s+(\d+\.\d+t?)\b') """Matches the interpreter selector of a ``hatch test -py <selector>`` command.""" # A ``dev.pyX.Y:`` or ``types.pyX.Y:`` environment reference inside a command, # e.g. ``hatch run dev.py3.14:ruff-check`` or ``hatch run types.py3.14:check``. _ENV_PY_REF_RE: Final[re.Pattern[str]] = re.compile(r'\b(dev|types)\.py(\d+\.\d+)\b') """Matches a ``dev.pyX.Y`` / ``types.pyX.Y`` environment reference."""
[docs] class PrePushClassification(enum.Enum): """How a pre-push command relates to the managed Python version roles.""" DEFAULT_MANAGED = 'default_managed' """References the default interpreter and must follow ``default-python``.""" FREE_THREADED_MANAGED = 'free_threaded' """References the free-threaded interpreter and must follow ``free-threaded-python``.""" PINNED = 'pinned' """A deliberate pin to a specific interpreter; never rewritten by a role change.""" UNCLASSIFIED = 'unclassified' """Contains no recognised interpreter selector, or an ambiguous one."""
[docs] def classify_pre_push(command: str, cfg: PythonConfig) -> PrePushClassification: """Classify a single pre-push command against the managed roles. The classification uses narrowly scoped, anchored patterns that match only the interpreter selector, never the whole command text. A command is: * :attr:`PrePushClassification.FREE_THREADED_MANAGED` if it selects the current free-threaded interpreter (an ``X.Yt`` selector); * :attr:`PrePushClassification.DEFAULT_MANAGED` if it selects the current default interpreter (either ``-py <default>`` or ``dev.py<default>:`` / ``types.py<default>:``); * :attr:`PrePushClassification.PINNED` if it selects some *other* concrete stable interpreter (for example ``hatch test -py 3.12``); * :attr:`PrePushClassification.UNCLASSIFIED` if it contains no recognised interpreter selector. :param command: The pre-push command string. :param cfg: The current canonical configuration. :return: The classification of *command*. """ ft_match = _HATCH_TEST_PY_RE.search(command) if ft_match is not None: selector = ft_match.group(1) if selector == cfg.free_threaded: return PrePushClassification.FREE_THREADED_MANAGED if selector == cfg.default: return PrePushClassification.DEFAULT_MANAGED # A concrete, different interpreter — a deliberate pin. return PrePushClassification.PINNED env_match = _ENV_PY_REF_RE.search(command) if env_match is not None: version = env_match.group(2) if version == cfg.default: return PrePushClassification.DEFAULT_MANAGED # A dev/types reference to a non-default version is a deliberate pin. return PrePushClassification.PINNED return PrePushClassification.UNCLASSIFIED
[docs] def retarget_default_command(command: str, old_default: str, new_default: str) -> str: """Rewrite the default-interpreter selector in a managed pre-push command. Only the interpreter selector that currently equals *old_default* is rewritten to *new_default*; the rest of the command (and any other version token) is left untouched. The function is only ever called on commands that have been classified as :attr:`PrePushClassification.DEFAULT_MANAGED`. :param command: The command to rewrite. :param old_default: The current default version (e.g. ``"3.14"``). :param new_default: The new default version (e.g. ``"3.15"``). :return: The rewritten command. """ def _replace_hatch_test(match: re.Match[str]) -> str: return match.group(0).replace(match.group(1), new_default) if match.group(1) == old_default else match.group(0) def _replace_env_ref(match: re.Match[str]) -> str: return match.group(0).replace(match.group(2), new_default) if match.group(2) == old_default else match.group(0) rewritten = _HATCH_TEST_PY_RE.sub(_replace_hatch_test, command) rewritten = _ENV_PY_REF_RE.sub(_replace_env_ref, rewritten) return rewritten
[docs] def retarget_free_threaded_command(command: str, new_free_threaded: str) -> str: """Rewrite the free-threaded selector in a managed pre-push command. Only an ``X.Yt`` selector is replaced; stable selectors (pins) are ignored. The function is only ever called on commands classified as :attr:`PrePushClassification.FREE_THREADED_MANAGED`. :param command: The command to rewrite. :param new_free_threaded: The new free-threaded selector (e.g. ``"3.15t"``). :return: The rewritten command. """ def _replace(match: re.Match[str]) -> str: selector = match.group(1) # Only free-threaded selectors (ending in ``t``) are managed here. return match.group(0).replace(selector, new_free_threaded) if selector.endswith('t') else match.group(0) return _HATCH_TEST_PY_RE.sub(_replace, command)