# Copyright 2026 European Union
# Author: Bulgheroni Antonio (antonio.bulgheroni@ec.europa.eu)
# SPDX-License-Identifier: EUPL-1.2
"""
Python version-identifier parsing for the ``devtools python`` command group.
This module classifies the textual version tokens accepted by the Python
version management feature (GitLab issue #247) into exactly one of three
kinds and never consults the local machine for installed interpreters:
* ``3.15`` — a CPython minor-version target (:class:`IdentifierKind.MINOR_TARGET`);
* ``3.15t`` — a free-threaded interpreter selector (:class:`IdentifierKind.FREE_THREADED`);
* ``3.16a1`` — a specific prerelease identifier (:class:`IdentifierKind.PRERELEASE`).
The free-threaded ``t`` suffix is a Hatch/uv interpreter selector that the
standard :class:`packaging.version.Version` parser does not model, therefore the
parsing is implemented here with explicit, anchored regular expressions and
:class:`packaging.version.Version` is only used downstream for ordering and
comparison of the base ``major.minor`` versions.
.. versionadded:: 2.4
:author: Bulgheroni Antonio (antonio.bulgheroni@ec.europa.eu)
"""
from __future__ import annotations
import enum
from dataclasses import dataclass
from mafw.devtools import DevtoolsError
from mafw.devtools.versions import PythonMinorVersion, parse_pep440_version
_FREE_THREADED_SUFFIX = 't'
"""Suffix identifying a free-threaded interpreter selector."""
[docs]
class IdentifierKind(enum.Enum):
"""The three kinds of Python version identifier understood by the feature."""
MINOR_TARGET = 'minor'
"""A stable CPython ``major.minor`` release target, e.g. ``3.15``."""
FREE_THREADED = 'free_threaded'
"""A free-threaded interpreter selector, e.g. ``3.15t``."""
PRERELEASE = 'prerelease'
"""A specific prerelease identifier, e.g. ``3.16a1``."""
[docs]
@dataclass(frozen=True, slots=True)
class VersionIdentifier:
"""A parsed, immutable Python version identifier.
Instances are produced by :func:`parse_identifier`. They carry the parsed
components so that callers can reason about the base ``major.minor`` version
(shared by every kind) independently of the concrete selector.
:ivar kind: The classification of the token.
:ivar major: The major version number (always ``3`` in practice, but parsed
generically).
:ivar minor: The minor version number.
:ivar phase: For a prerelease, the phase marker (``a``, ``b`` or ``rc``);
``None`` otherwise.
:ivar serial: For a prerelease, the serial number following the phase
marker; ``None`` otherwise.
"""
kind: IdentifierKind
major: int
minor: int
phase: str | None = None
serial: int | None = None
@property
def base_version(self) -> str:
"""Return the ``major.minor`` base version string shared by every kind.
For ``3.15``, ``3.15t`` and ``3.16a1`` the base versions are ``3.15``,
``3.15`` and ``3.16`` respectively.
"""
return f'{self.major}.{self.minor}'
[docs]
def render(self) -> str:
"""Reproduce the original textual token.
``render(parse_identifier(token)) == token`` holds for every token that
:func:`parse_identifier` accepts, which makes the parser a lossless
round-trip for the three supported forms.
"""
if self.kind is IdentifierKind.MINOR_TARGET:
return self.base_version
if self.kind is IdentifierKind.FREE_THREADED:
return f'{self.base_version}t'
# Prerelease: phase and serial are guaranteed non-None by the parser.
return f'{self.base_version}{self.phase}{self.serial}'
[docs]
def parse_identifier(token: str) -> VersionIdentifier:
"""Classify a Python version token into a :class:`VersionIdentifier`.
The patterns are tried in a fixed order — minor target, free-threaded
selector, prerelease — each anchored with ``re.fullmatch`` so that only
an exact whole-string match is accepted. The machine is never queried for
installed interpreters; the result is a pure syntactic classification.
:param token: The raw version token, e.g. ``"3.15"``, ``"3.15t"`` or
``"3.16a1"``.
:type token: str
:return: The parsed identifier.
:rtype: VersionIdentifier
:raises DevtoolsError: If *token* matches none of the supported forms. The
error names the unrecognised token and no state is mutated.
"""
if token.endswith(_FREE_THREADED_SUFFIX):
try:
free_threaded_base = PythonMinorVersion.parse(token[:-1])
except ValueError:
pass
else:
return VersionIdentifier(IdentifierKind.FREE_THREADED, free_threaded_base.major, free_threaded_base.minor)
base: PythonMinorVersion | None
try:
base = PythonMinorVersion.parse(token)
except ValueError:
base = None
if base is not None:
return VersionIdentifier(IdentifierKind.MINOR_TARGET, base.major, base.minor)
# Keep the interpreter selector's exact spelling and shape while letting
# packaging validate and interpret the pre-release version itself.
for phase in ('a', 'b', 'rc'):
if token.count(phase) != 1:
continue
base_text, separator, serial_text = token.partition(phase)
if not separator or not serial_text.isdecimal():
continue
try:
base = PythonMinorVersion.parse(base_text)
parsed = parse_pep440_version(token)
except ValueError:
continue
if parsed.is_prerelease and parsed.pre is not None:
serial = int(serial_text)
return VersionIdentifier(
kind=IdentifierKind.PRERELEASE,
major=base.major,
minor=base.minor,
phase=phase,
serial=serial,
)
raise DevtoolsError(f'Unrecognised Python version identifier: {token!r}.')