# Copyright 2026 European Union
# Author: Bulgheroni Antonio (antonio.bulgheroni@ec.europa.eu)
# SPDX-License-Identifier: EUPL-1.2
"""Shared version parsing and formatting primitives for ``devtools``.
PEP 440 distribution versions, Python interpreter minors, and version-bearing
filenames have distinct syntax. This module centralizes their parsing while
leaving caller-specific policy and error messages at their boundaries.
:author: Bulgheroni Antonio (antonio.bulgheroni@ec.europa.eu)
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Final
from packaging.version import Version
PYTHON_MINOR_PATTERN: Final[re.Pattern[str]] = re.compile(r'(?P<major>\d+)\.(?P<minor>\d+)')
"""Exact syntax for a Python ``major.minor`` selector."""
STABLE_TAG_PATTERN: Final[re.Pattern[str]] = re.compile(r'v(?P<major>\d+)\.(?P<minor>\d+)\.(?P<micro>\d+)')
"""Exact syntax for a stable project tag; callers must use ``fullmatch``."""
PYLOCK_REFERENCE_PATTERN: Final[re.Pattern[str]] = re.compile(r'pylock\.py(?P<python_version>3_\d+)_ref\.toml')
"""Exact syntax for a Python reference lock filename; use ``fullmatch``."""
[docs]
@dataclass(frozen=True, slots=True, order=True)
class PythonMinorVersion:
"""A structural Python ``major.minor`` value for numeric ordering."""
major: int
minor: int
[docs]
@classmethod
def parse(cls, value: str) -> PythonMinorVersion:
"""Parse an exact ``major.minor`` token without applying project policy."""
match = PYTHON_MINOR_PATTERN.fullmatch(value)
if match is None:
raise ValueError(f'Invalid Python minor version: {value!r}')
return cls(int(match.group('major')), int(match.group('minor')))
def __str__(self) -> str:
"""Render the numeric components without leading zeroes."""
return f'{self.major}.{self.minor}'
[docs]
def parse_pep440_version(value: str) -> Version:
"""Parse *value* as a PEP 440 version using :class:`packaging.version.Version`."""
return Version(value)
[docs]
def pylock_version_tag(python_version: str) -> str:
"""Convert a dotted Python minor token to the filename-safe underscore form."""
PythonMinorVersion.parse(python_version)
return python_version.replace('.', '_')
[docs]
def build_pylock_reference_filename(python_version: str) -> str:
"""Build the reference lock filename for a Python minor token."""
return f'pylock.py{pylock_version_tag(python_version)}_ref.toml'
[docs]
def parse_pylock_reference_filename(file_name: str) -> tuple[str, str] | None:
"""Parse an exact pylock reference filename, accepting a path-like input."""
base = Path(file_name).name
match = PYLOCK_REFERENCE_PATTERN.fullmatch(base)
if match is None:
return None
python_version = match.group('python_version').replace('_', '.')
return python_version, base