Coverage for src/mafw/devtools/toolchain/pypi.py: 98%
40 statements
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-26 09:13 +0000
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-26 09:13 +0000
1# Copyright 2026 European Union
2# Author: Bulgheroni Antonio (antonio.bulgheroni@ec.europa.eu)
3# SPDX-License-Identifier: EUPL-1.2
4"""
5PyPI version query utility for toolchain management.
7This module provides a helper to query the PyPI JSON API for the latest
8release version of a given package. It is used by all concrete
9:class:`~mafw.devtools.toolchain.base.ToolChainTool` implementations to
10satisfy the ``detect_latest_version`` contract.
11"""
13from __future__ import annotations
15from typing import Final
17import requests
18from packaging.version import Version
20from mafw.devtools import DevtoolsError
22_PYPI_JSON_URL: Final[str] = 'https://pypi.org/pypi/{package}/json'
23"""URL template for the PyPI JSON API endpoint."""
25_REQUEST_TIMEOUT_S: Final[float] = 30.0
26"""HTTP request timeout in seconds for PyPI queries."""
29def fetch_latest_version(package_name: str, *, allow_prerelease: bool = False) -> Version:
30 """Query the PyPI JSON API for the latest release version of a package.
32 This function fetches the package metadata from PyPI, extracts the
33 ``info.version`` field, and returns it as a :class:`~packaging.version.Version`.
34 Pre-release versions are filtered out unless explicitly allowed.
36 When ``allow_prerelease`` is False and the latest version on PyPI is a
37 pre-release, the function falls back to scanning ``releases`` keys to find
38 the most recent stable release.
40 :param package_name: The PyPI package name to query (e.g. ``"ruff"``).
41 :type package_name: str
42 :param allow_prerelease: If True, pre-release versions are acceptable.
43 :type allow_prerelease: bool
44 :return: The latest available version from PyPI.
45 :rtype: Version
46 :raises DevtoolsError: If the HTTP request fails, the response cannot be
47 parsed, or no suitable version is found.
48 """
49 url = _PYPI_JSON_URL.format(package=package_name)
51 try:
52 response = requests.get(url, timeout=_REQUEST_TIMEOUT_S)
53 except requests.RequestException as exc:
54 raise DevtoolsError(f'Failed to query PyPI for package {package_name!r}: {exc}') from exc
56 if response.status_code == 404:
57 raise DevtoolsError(f'Package {package_name!r} not found on PyPI.')
59 if not (200 <= response.status_code < 300):
60 raise DevtoolsError(f'PyPI returned HTTP {response.status_code} for package {package_name!r}.')
62 try:
63 data = response.json()
64 except ValueError as exc:
65 raise DevtoolsError(f'Unable to parse PyPI JSON response for package {package_name!r}.') from exc
67 # Try the top-level info.version first (PyPI's canonical latest).
68 info_version_str: str | None = data.get('info', {}).get('version')
69 if info_version_str: 69 ↛ 76line 69 didn't jump to line 76 because the condition on line 69 was always true
70 info_version = Version(info_version_str)
71 if allow_prerelease or not info_version.is_prerelease:
72 return info_version
74 # If info.version is a pre-release and we want stable, scan all releases
75 # to find the highest stable version.
76 releases: dict[str, object] = data.get('releases', {})
77 stable_versions: list[Version] = []
78 for version_str in releases:
79 try:
80 v = Version(version_str)
81 except Exception: # noqa: BLE001
82 # Skip unparseable version strings.
83 continue
84 if allow_prerelease or not v.is_prerelease:
85 stable_versions.append(v)
87 if not stable_versions:
88 raise DevtoolsError(
89 f'No suitable version found on PyPI for package {package_name!r} (allow_prerelease={allow_prerelease}).'
90 )
92 # Return the highest version found.
93 return max(stable_versions)