# Copyright 2026 European Union
# Author: Bulgheroni Antonio (antonio.bulgheroni@ec.europa.eu)
# SPDX-License-Identifier: EUPL-1.2
"""
PyPI version query utility for toolchain management.
This module provides a helper to query the PyPI JSON API for the latest
release version of a given package. It is used by all concrete
:class:`~mafw.devtools.toolchain.base.ToolChainTool` implementations to
satisfy the ``detect_latest_version`` contract.
"""
from __future__ import annotations
from typing import Final
import requests
from packaging.version import Version
from mafw.devtools import DevtoolsError
_PYPI_JSON_URL: Final[str] = 'https://pypi.org/pypi/{package}/json'
"""URL template for the PyPI JSON API endpoint."""
_REQUEST_TIMEOUT_S: Final[float] = 30.0
"""HTTP request timeout in seconds for PyPI queries."""
[docs]
def fetch_latest_version(package_name: str, *, allow_prerelease: bool = False) -> Version:
"""Query the PyPI JSON API for the latest release version of a package.
This function fetches the package metadata from PyPI, extracts the
``info.version`` field, and returns it as a :class:`~packaging.version.Version`.
Pre-release versions are filtered out unless explicitly allowed.
When ``allow_prerelease`` is False and the latest version on PyPI is a
pre-release, the function falls back to scanning ``releases`` keys to find
the most recent stable release.
:param package_name: The PyPI package name to query (e.g. ``"ruff"``).
:type package_name: str
:param allow_prerelease: If True, pre-release versions are acceptable.
:type allow_prerelease: bool
:return: The latest available version from PyPI.
:rtype: Version
:raises DevtoolsError: If the HTTP request fails, the response cannot be
parsed, or no suitable version is found.
"""
url = _PYPI_JSON_URL.format(package=package_name)
try:
response = requests.get(url, timeout=_REQUEST_TIMEOUT_S)
except requests.RequestException as exc:
raise DevtoolsError(f'Failed to query PyPI for package {package_name!r}: {exc}') from exc
if response.status_code == 404:
raise DevtoolsError(f'Package {package_name!r} not found on PyPI.')
if not (200 <= response.status_code < 300):
raise DevtoolsError(f'PyPI returned HTTP {response.status_code} for package {package_name!r}.')
try:
data = response.json()
except ValueError as exc:
raise DevtoolsError(f'Unable to parse PyPI JSON response for package {package_name!r}.') from exc
# Try the top-level info.version first (PyPI's canonical latest).
info_version_str: str | None = data.get('info', {}).get('version')
if info_version_str:
info_version = Version(info_version_str)
if allow_prerelease or not info_version.is_prerelease:
return info_version
# If info.version is a pre-release and we want stable, scan all releases
# to find the highest stable version.
releases: dict[str, object] = data.get('releases', {})
stable_versions: list[Version] = []
for version_str in releases:
try:
v = Version(version_str)
except Exception: # noqa: BLE001
# Skip unparseable version strings.
continue
if allow_prerelease or not v.is_prerelease:
stable_versions.append(v)
if not stable_versions:
raise DevtoolsError(
f'No suitable version found on PyPI for package {package_name!r} (allow_prerelease={allow_prerelease}).'
)
# Return the highest version found.
return max(stable_versions)