Source code for mafw.devtools.pyversions.identifiers

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