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

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. 

6 

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""" 

12 

13from __future__ import annotations 

14 

15from typing import Final 

16 

17import requests 

18from packaging.version import Version 

19 

20from mafw.devtools import DevtoolsError 

21 

22_PYPI_JSON_URL: Final[str] = 'https://pypi.org/pypi/{package}/json' 

23"""URL template for the PyPI JSON API endpoint.""" 

24 

25_REQUEST_TIMEOUT_S: Final[float] = 30.0 

26"""HTTP request timeout in seconds for PyPI queries.""" 

27 

28 

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. 

31 

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. 

35 

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. 

39 

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) 

50 

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 

55 

56 if response.status_code == 404: 

57 raise DevtoolsError(f'Package {package_name!r} not found on PyPI.') 

58 

59 if not (200 <= response.status_code < 300): 

60 raise DevtoolsError(f'PyPI returned HTTP {response.status_code} for package {package_name!r}.') 

61 

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 

66 

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 

73 

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) 

86 

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 ) 

91 

92 # Return the highest version found. 

93 return max(stable_versions)