Source code for mafw.devtools.pyversions.manager

#  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