Source code for mafw.devtools.pyversions.config

#  Copyright 2026 European Union
#  Author: Bulgheroni Antonio (antonio.bulgheroni@ec.europa.eu)
#  SPDX-License-Identifier: EUPL-1.2
"""
Canonical Python version configuration model.

This module defines :class:`PythonConfig`, the in-memory representation of the
``[tool.mafw]`` keys that are the single source of truth for the Python version
management feature (GitLab issue #247):

* ``supported-python``     — stable, officially supported CPython minor versions;
* ``experimental-python``  — versions under evaluation, not advertised as supported;
* ``default-python``       — the supported interpreter used for primary development;
* ``free-threaded-python`` — the independently selected free-threaded interpreter.

:class:`PythonConfig` is frozen and validates every configuration invariant
required by the feature. It knows how to build itself from a parsed
``pyproject.toml`` document, naming every missing key, but performs no file I/O
of its own — that responsibility belongs to the synchronization service.

.. versionadded: 2.4

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

from __future__ import annotations

from dataclasses import dataclass
from typing import Final

import tomlkit

from mafw.devtools import DevtoolsError
from mafw.devtools.pyversions.identifiers import IdentifierKind, parse_identifier
from mafw.devtools.versions import PythonMinorVersion

# The canonical keys under ``[tool.mafw]`` that this feature manages.
_SUPPORTED_KEY: Final[str] = 'supported-python'
_EXPERIMENTAL_KEY: Final[str] = 'experimental-python'
_DEFAULT_KEY: Final[str] = 'default-python'
_FREE_THREADED_KEY: Final[str] = 'free-threaded-python'

_REQUIRED_KEYS: Final[tuple[str, ...]] = (
    _SUPPORTED_KEY,
    _EXPERIMENTAL_KEY,
    _DEFAULT_KEY,
    _FREE_THREADED_KEY,
)
"""Every ``[tool.mafw]`` key required by the Python version management feature."""


[docs] def _version_sort_key(version: str) -> tuple[int, int]: """Return the ``(major, minor)`` numeric sort key for a ``major.minor`` string. Used to compare and order stable versions numerically rather than lexically, so that ``3.9`` sorts before ``3.10``. """ try: parsed = PythonMinorVersion.parse(version) except ValueError: # Defensive: callers validate format first, but keep ordering total. raise DevtoolsError(f'Cannot order non major.minor version: {version!r}.') return parsed.major, parsed.minor
[docs] def base_version(free_threaded: str) -> str: """Return the ``major.minor`` base version of a free-threaded selector. For example the base version of ``3.14t`` is ``3.14``. The function accepts any token the identifier parser understands and extracts its base version. :param free_threaded: A free-threaded selector such as ``"3.14t"``. :type free_threaded: str :return: The ``major.minor`` base version. :rtype: str :raises DevtoolsError: If *free_threaded* is not a valid identifier. """ return parse_identifier(free_threaded).base_version
[docs] @dataclass(frozen=True, slots=True) class PythonConfig: """Immutable canonical Python version policy. The four fields mirror the ``[tool.mafw]`` keys. Lists are stored as tuples so that instances remain hashable and cannot be mutated in place; the version manager produces new instances rather than editing existing ones. :ivar supported: Stable supported versions, ascending and unique. :ivar experimental: Experimental versions, ascending and unique. :ivar default: The default development interpreter (a supported version). :ivar free_threaded: The free-threaded interpreter selector (e.g. ``3.14t``). """ supported: tuple[str, ...] experimental: tuple[str, ...] default: str free_threaded: str
[docs] @classmethod def from_document(cls, doc: tomlkit.TOMLDocument) -> PythonConfig: """Build a :class:`PythonConfig` from a parsed ``pyproject.toml`` document. Every required ``[tool.mafw]`` key is checked; if one or more are missing, a single error naming *all* of them is raised so the operator can fix them in one pass. :param doc: The parsed TOML document. :type doc: tomlkit.TOMLDocument :return: The canonical configuration (not yet validated for invariants). :rtype: PythonConfig :raises DevtoolsError: If any required key is missing or has the wrong type. """ tool_mafw = doc.get('tool', {}).get('mafw', {}) missing = [key for key in _REQUIRED_KEYS if key not in tool_mafw] if missing: raise DevtoolsError('Missing required [tool.mafw] key(s): ' + ', '.join(sorted(missing)) + '.') supported = cls._as_str_tuple(tool_mafw[_SUPPORTED_KEY], _SUPPORTED_KEY) experimental = cls._as_str_tuple(tool_mafw[_EXPERIMENTAL_KEY], _EXPERIMENTAL_KEY) default = cls._as_str(tool_mafw[_DEFAULT_KEY], _DEFAULT_KEY) free_threaded = cls._as_str(tool_mafw[_FREE_THREADED_KEY], _FREE_THREADED_KEY) return cls( supported=supported, experimental=experimental, default=default, free_threaded=free_threaded, )
[docs] @staticmethod def _as_str_tuple(value: object, key: str) -> tuple[str, ...]: """Coerce a TOML array into a tuple of plain strings, validating its type.""" if not isinstance(value, list): raise DevtoolsError(f'[tool.mafw].{key} must be an array of strings.') result: list[str] = [] for item in value: if not isinstance(item, str): raise DevtoolsError(f'[tool.mafw].{key} must contain only strings.') result.append(str(item).strip()) return tuple(result)
[docs] @staticmethod def _as_str(value: object, key: str) -> str: """Coerce a TOML scalar into a plain string, validating its type.""" if not isinstance(value, str): raise DevtoolsError(f'[tool.mafw].{key} must be a string.') return str(value).strip()
[docs] def validate(self) -> None: """Enforce every configuration invariant required by the feature. The checks mirror Requirement 11 of the specification: * each list is free of duplicates; * the two lists are mutually exclusive; * each list is sorted ascending by ``(major, minor)``; * ``default`` is a stable version present in ``supported``; * the base version of ``free_threaded`` is present in ``supported``. The ``default`` and free-threaded selections are allowed to differ freely — there is deliberately no coupling constraint between them. :raises DevtoolsError: On the first violated invariant, with a message naming the offending key and value. """ self._validate_list(self.supported, _SUPPORTED_KEY) self._validate_list(self.experimental, _EXPERIMENTAL_KEY) overlap = sorted(set(self.supported) & set(self.experimental), key=_version_sort_key) if overlap: raise DevtoolsError( f'[tool.mafw].{_SUPPORTED_KEY} and [tool.mafw].{_EXPERIMENTAL_KEY} must be mutually ' f'exclusive; the following version(s) appear in both: {", ".join(overlap)}.' ) # The default must be a stable major.minor token that is officially supported. try: PythonMinorVersion.parse(self.default) except ValueError: raise DevtoolsError( f'[tool.mafw].{_DEFAULT_KEY} must be a stable major.minor version, got {self.default!r}.' ) if self.default not in self.supported: raise DevtoolsError( f'[tool.mafw].{_DEFAULT_KEY} = {self.default!r} must be one of the supported versions ' f'({", ".join(self.supported)}).' ) # The free-threaded selector must be of the X.Yt form and its base supported. ft_identifier = parse_identifier(self.free_threaded) if ft_identifier.kind is not IdentifierKind.FREE_THREADED: raise DevtoolsError( f'[tool.mafw].{_FREE_THREADED_KEY} must be a free-threaded selector such as "3.14t", ' f'got {self.free_threaded!r}.' ) if ft_identifier.base_version not in self.supported: raise DevtoolsError( f'[tool.mafw].{_FREE_THREADED_KEY} = {self.free_threaded!r} requires its base version ' f'{ft_identifier.base_version!r} to be among the supported versions ' f'({", ".join(self.supported)}).' )
[docs] @staticmethod def _validate_list(versions: tuple[str, ...], key: str) -> None: """Validate format, uniqueness and ascending order of a version list.""" seen: set[str] = set() for version in versions: try: PythonMinorVersion.parse(version) except ValueError: raise DevtoolsError( f'[tool.mafw].{key} must contain only stable major.minor versions; offending value: {version!r}.' ) if version in seen: raise DevtoolsError(f'[tool.mafw].{key} contains a duplicate version: {version!r}.') seen.add(version) ordered = sorted(versions, key=_version_sort_key) if list(versions) != ordered: raise DevtoolsError( f'[tool.mafw].{key} must be sorted in ascending order; expected {ordered}, got {list(versions)}.' )