# Copyright 2026 European Union
# Author: Bulgheroni Antonio (antonio.bulgheroni@ec.europa.eu)
# SPDX-License-Identifier: EUPL-1.2
"""
Pure Python version policy transformations.
This module defines :class:`VersionManager`, the testable heart of the Python
version management feature (GitLab issue #247). Each operation takes a
:class:`~mafw.devtools.pyversions.config.PythonConfig` and returns a *new*
validated configuration (or raises :class:`~mafw.devtools.DevtoolsError`); none
of the operations performs any file or network I/O. This separation keeps the
business rules independent of both the TOML document and the CLI, which is what
makes them straightforward to exercise with property-based tests.
The supported operations are:
* :meth:`VersionManager.add_supported` — add (or promote) a stable version;
* :meth:`VersionManager.add_experimental` — register a version for evaluation;
* :meth:`VersionManager.remove` — retire a version;
* :meth:`VersionManager.set_default` — change the default interpreter;
* :meth:`VersionManager.set_free_threaded` — change the free-threaded interpreter.
.. versionadded:: 2.4
:author: Bulgheroni Antonio (antonio.bulgheroni@ec.europa.eu)
"""
from __future__ import annotations
from mafw.devtools import DevtoolsError
from mafw.devtools.pyversions.config import PythonConfig, _version_sort_key, base_version
from mafw.devtools.pyversions.identifiers import IdentifierKind, parse_identifier
from mafw.devtools.versions import PythonMinorVersion
[docs]
def _require_stable(version: str) -> str:
"""Validate that *version* is a well-formed stable ``major.minor`` target.
The project supports only CPython 3.x, therefore the major component is
constrained to ``3`` and the minor to the ``0``–``99`` range.
:param version: The candidate version string.
:type version: str
:return: The validated version string (unchanged).
:rtype: str
:raises DevtoolsError: If *version* is not a stable ``3.MINOR`` target.
"""
try:
parsed = PythonMinorVersion.parse(version)
except ValueError:
raise DevtoolsError(
f'{version!r} is not a well-formed stable CPython minor-version target (expected e.g. "3.15").'
) from None
if parsed.major != 3 or not (0 <= parsed.minor <= 99):
raise DevtoolsError(f'{version!r} is not a supported CPython target; expected "3.MINOR" with MINOR in 0..99.')
return version
[docs]
def _insert_sorted(versions: tuple[str, ...], version: str) -> tuple[str, ...]:
"""Return *versions* with *version* inserted, kept ascending and unique."""
merged = sorted({*versions, version}, key=_version_sort_key)
return tuple(merged)
[docs]
class VersionManager:
"""Pure transformations over a :class:`PythonConfig`.
Every method returns a freshly validated configuration; the input is never
mutated. Guard conditions (malformed input, duplicates, constraint
violations) raise :class:`DevtoolsError` before any new configuration is
produced, so a rejected operation leaves the caller's configuration intact.
"""
[docs]
def add_supported(self, cfg: PythonConfig, version: str) -> PythonConfig:
"""Add a stable version to the supported list, promoting it if experimental.
:param cfg: The current configuration.
:param version: A stable ``major.minor`` target such as ``"3.15"``.
:return: The updated, validated configuration.
:raises DevtoolsError: If *version* is malformed, a prerelease, or already
supported.
"""
# A prerelease or free-threaded token is not a valid stable target; the
# identifier parser classifies it so we can give a precise message.
identifier = parse_identifier(version)
if identifier.kind is not IdentifierKind.MINOR_TARGET:
raise DevtoolsError(
f'Cannot add {version!r} as a supported version: only stable major.minor targets '
f'are allowed (prereleases and free-threaded selectors are not).'
)
_require_stable(version)
if version in cfg.supported:
raise DevtoolsError(f'{version!r} is already a supported version.')
# Promotion: a version currently under evaluation leaves the experimental list.
experimental = tuple(v for v in cfg.experimental if v != version)
supported = _insert_sorted(cfg.supported, version)
updated = PythonConfig(
supported=supported,
experimental=experimental,
default=cfg.default,
free_threaded=cfg.free_threaded,
)
updated.validate()
return updated
[docs]
def add_experimental(self, cfg: PythonConfig, version: str) -> PythonConfig:
"""Register a version for experimental evaluation.
Stable minor targets and prerelease identifiers are both accepted; a
prerelease is recorded as its minor-version target (``3.16a1`` → ``3.16``)
so that Hatch/uv selectors can use it.
:param cfg: The current configuration.
:param version: A stable target or prerelease identifier.
:return: The updated, validated configuration.
:raises DevtoolsError: If *version* is malformed, already experimental, or
already supported.
"""
identifier = parse_identifier(version)
if identifier.kind is IdentifierKind.FREE_THREADED:
raise DevtoolsError(
f'Cannot register {version!r} as experimental: free-threaded selectors are managed '
f'via the "free-threaded" command, not the experimental list.'
)
# Normalise to the minor-version target used by Hatch/uv.
target = identifier.base_version
if target in cfg.experimental:
raise DevtoolsError(f'{target!r} is already an experimental version.')
if target in cfg.supported:
raise DevtoolsError(f'{target!r} conflicts with the supported list and cannot be experimental.')
experimental = _insert_sorted(cfg.experimental, target)
updated = PythonConfig(
supported=cfg.supported,
experimental=experimental,
default=cfg.default,
free_threaded=cfg.free_threaded,
)
updated.validate()
return updated
[docs]
def remove(self, cfg: PythonConfig, version: str) -> PythonConfig:
"""Remove a version from whichever canonical list contains it.
:param cfg: The current configuration.
:param version: A stable ``major.minor`` version to retire.
:return: The updated, validated configuration.
:raises DevtoolsError: If *version* is malformed, is the current default,
is the base version of the current free-threaded interpreter, or is
not configured at all.
"""
_require_stable(version)
if version == cfg.default:
raise DevtoolsError(
f'Cannot remove {version!r}: it is the current default interpreter. Reassign the default first.'
)
if version == base_version(cfg.free_threaded):
raise DevtoolsError(
f'Cannot remove {version!r}: it is the base version of the current free-threaded '
f'interpreter ({cfg.free_threaded!r}). Reassign the free-threaded interpreter first.'
)
in_supported = version in cfg.supported
in_experimental = version in cfg.experimental
if not in_supported and not in_experimental:
raise DevtoolsError(f'{version!r} is not configured; nothing to remove.')
supported = tuple(v for v in cfg.supported if v != version)
experimental = tuple(v for v in cfg.experimental if v != version)
updated = PythonConfig(
supported=supported,
experimental=experimental,
default=cfg.default,
free_threaded=cfg.free_threaded,
)
updated.validate()
return updated
[docs]
def set_default(self, cfg: PythonConfig, version: str) -> PythonConfig:
"""Change the default development interpreter.
:param cfg: The current configuration.
:param version: A supported stable version to become the new default.
:return: The updated configuration (identical if *version* is already
the default).
:raises DevtoolsError: If *version* is malformed, not supported, or only
experimental.
"""
_require_stable(version)
if version == cfg.default:
# No-op: the default already matches; return the configuration unchanged.
return cfg
if version in cfg.experimental and version not in cfg.supported:
raise DevtoolsError(
f'Cannot set {version!r} as the default: experimental versions cannot become the default.'
)
if version not in cfg.supported:
raise DevtoolsError(f'Cannot set {version!r} as the default: it is not an officially supported version.')
updated = PythonConfig(
supported=cfg.supported,
experimental=cfg.experimental,
default=version,
free_threaded=cfg.free_threaded,
)
updated.validate()
return updated
[docs]
def set_free_threaded(self, cfg: PythonConfig, version: str) -> PythonConfig:
"""Change the free-threaded test interpreter.
:param cfg: The current configuration.
:param version: A free-threaded selector such as ``"3.15t"`` whose base
version is officially supported.
:return: The updated, validated configuration.
:raises DevtoolsError: If *version* is not an ``X.Yt`` selector or its base
version is not supported.
"""
identifier = parse_identifier(version)
if identifier.kind is not IdentifierKind.FREE_THREADED:
raise DevtoolsError(f'{version!r} is not a free-threaded selector; expected the "X.Yt" form, e.g. "3.15t".')
if identifier.base_version not in cfg.supported:
raise DevtoolsError(
f'Cannot select free-threaded interpreter {version!r}: its base version '
f'{identifier.base_version!r} is not an officially supported version '
f'({", ".join(cfg.supported)}).'
)
updated = PythonConfig(
supported=cfg.supported,
experimental=cfg.experimental,
default=cfg.default,
free_threaded=version,
)
updated.validate()
return updated