# 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)}.'
)