# 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)