Source code for mafw.devtools.toolchain.pypi

#  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)