|
5 | 5 | import subprocess |
6 | 6 | from pathlib import Path |
7 | 7 | import logging |
8 | | -from typing import Optional, List, Tuple |
9 | | - |
10 | | -if sys.version_info >= (3, 11): |
11 | | - import tomllib |
12 | | -else: |
13 | | - import tomli as tomllib |
14 | | - |
15 | | -from cpp_linter_hooks.versions import CLANG_FORMAT_VERSIONS, CLANG_TIDY_VERSIONS |
| 8 | +from typing import Optional, Tuple |
| 9 | +from functools import lru_cache |
| 10 | +import json |
| 11 | +import urllib.request |
| 12 | +import re |
16 | 13 |
|
17 | 14 | LOG = logging.getLogger(__name__) |
18 | 15 |
|
19 | 16 |
|
20 | | -def get_version_from_dependency(tool: str) -> Optional[str]: |
21 | | - """Get the version of a tool from the pyproject.toml dependencies.""" |
22 | | - pyproject_path = Path(__file__).parent.parent / "pyproject.toml" |
23 | | - if not pyproject_path.exists(): |
24 | | - return None |
25 | | - with open(pyproject_path, "rb") as f: |
26 | | - data = tomllib.load(f) |
27 | | - # Check [project].dependencies |
28 | | - dependencies = data.get("project", {}).get("dependencies", []) |
29 | | - for dep in dependencies: |
30 | | - if dep.startswith(f"{tool}=="): |
31 | | - return dep.split("==")[1] |
32 | | - return None |
33 | | - |
| 17 | +@lru_cache(maxsize=4) |
| 18 | +def _get_pypi_versions(tool: str) -> Tuple[Optional[str], list]: |
| 19 | + """Fetch (latest_version, [stable_versions_descending]) from PyPI JSON API. |
34 | 20 |
|
35 | | -DEFAULT_CLANG_FORMAT_VERSION = CLANG_FORMAT_VERSIONS[-1] # latest from versions.py |
36 | | -DEFAULT_CLANG_TIDY_VERSION = CLANG_TIDY_VERSIONS[-1] # latest from versions.py |
| 21 | + Results are cached per tool name so repeated calls within the same |
| 22 | + process reuse the last HTTP response. |
| 23 | + """ |
| 24 | + try: |
| 25 | + url = f"https://pypi.org/pypi/{tool}/json" |
| 26 | + with urllib.request.urlopen(url, timeout=10) as response: |
| 27 | + data = json.loads(response.read()) |
| 28 | + except Exception as exc: |
| 29 | + LOG.warning("Failed to fetch versions for %s from PyPI: %s", tool, exc) |
| 30 | + return None, [] |
| 31 | + |
| 32 | + all_versions = list(data["releases"].keys()) |
| 33 | + |
| 34 | + # Filter out pre-release versions |
| 35 | + pre_release_pattern = re.compile( |
| 36 | + r".*(alpha|beta|rc|dev|a\d+|b\d+).*", re.IGNORECASE |
| 37 | + ) |
| 38 | + stable = [v for v in all_versions if not pre_release_pattern.match(v)] |
37 | 39 |
|
| 40 | + if not stable: |
| 41 | + LOG.warning("No stable versions found for %s on PyPI", tool) |
| 42 | + return None, [] |
38 | 43 |
|
39 | | -def _versions_for_tool(tool: str) -> List[str]: |
40 | | - """Return supported Python wheel versions for a clang tool.""" |
41 | | - return CLANG_FORMAT_VERSIONS if tool == "clang-format" else CLANG_TIDY_VERSIONS |
| 44 | + # Sort ascending by version tuple |
| 45 | + stable.sort(key=lambda x: tuple(map(int, x.split(".")))) |
42 | 46 |
|
| 47 | + latest = stable[-1] |
| 48 | + # Return descending for prefix matching (newest first) |
| 49 | + return latest, list(reversed(stable)) |
43 | 50 |
|
44 | | -def _default_version_for_tool(tool: str) -> Optional[str]: |
45 | | - """Return the default Python wheel version for a clang tool.""" |
46 | | - return ( |
47 | | - DEFAULT_CLANG_FORMAT_VERSION |
48 | | - if tool == "clang-format" |
49 | | - else DEFAULT_CLANG_TIDY_VERSION |
50 | | - ) |
51 | | - |
52 | | - |
53 | | -def _supported_versions_message(tool: str) -> str: |
54 | | - """Build a user-facing list of supported wheel versions for a clang tool.""" |
55 | | - versions = ", ".join(_versions_for_tool(tool)) |
56 | | - return f"Supported {tool} wheel versions: {versions}" |
57 | 51 |
|
| 52 | +def _resolve_version_from_pypi( |
| 53 | + tool: str, user_input: Optional[str] |
| 54 | +) -> Tuple[Optional[str], Optional[str]]: |
| 55 | + """Resolve a version dynamically from PyPI. |
| 56 | +
|
| 57 | + Returns (resolved_version, error_message). The error_message is |
| 58 | + suitable for displaying directly to the end user. |
| 59 | +
|
| 60 | + When PyPI is unreachable and no explicit version was requested, |
| 61 | + falls back to whatever version is already installed on the host |
| 62 | + so that pre-installed tools keep working offline. |
| 63 | + """ |
| 64 | + latest, versions = _get_pypi_versions(tool) |
| 65 | + |
| 66 | + if not versions: |
| 67 | + if user_input is None: |
| 68 | + # PyPI is unreachable, but the user didn't ask for a specific |
| 69 | + # version – try the locally installed tool as a fallback. |
| 70 | + installed = _detect_installed_version(tool) |
| 71 | + if installed: |
| 72 | + LOG.info( |
| 73 | + "PyPI unreachable; using locally installed %s %s", |
| 74 | + tool, |
| 75 | + installed, |
| 76 | + ) |
| 77 | + return installed, None |
| 78 | + return ( |
| 79 | + None, |
| 80 | + f"Could not find any stable versions of {tool} on PyPI. " |
| 81 | + "Check your network connection.", |
| 82 | + ) |
58 | 83 |
|
59 | | -def _resolve_version(versions: List[str], user_input: Optional[str]) -> Optional[str]: |
60 | | - """Resolve the latest matching version based on user input and available versions.""" |
61 | 84 | if user_input is None: |
62 | | - return None |
63 | | - if user_input in versions: |
64 | | - return user_input |
| 85 | + return latest, None |
65 | 86 |
|
66 | | - try: |
67 | | - # filter versions that start with the user input |
68 | | - matched_versions = [v for v in versions if v.startswith(user_input)] |
69 | | - if not matched_versions: |
70 | | - raise ValueError |
71 | | - |
72 | | - # define a function to parse version strings into tuples for comparison |
73 | | - def parse_version(v: str): |
74 | | - """Convert a dotted version string into an integer tuple.""" |
75 | | - return tuple(map(int, v.split("."))) |
| 87 | + # Exact match |
| 88 | + if user_input in versions: |
| 89 | + return user_input, None |
76 | 90 |
|
77 | | - # return the latest version |
78 | | - return max(matched_versions, key=parse_version) |
| 91 | + # Prefix match (e.g. "20" → "20.1.8"). Versions are newest-first, |
| 92 | + # so the first matching entry is the latest for that prefix. |
| 93 | + matched = [v for v in versions if v.startswith(user_input)] |
| 94 | + if matched: |
| 95 | + return matched[0], None |
79 | 96 |
|
80 | | - except ValueError: |
81 | | - LOG.warning("Version %s not found in available versions", user_input) |
82 | | - return None |
| 97 | + # No match – help the user |
| 98 | + sample = ", ".join(versions[:15]) |
| 99 | + return ( |
| 100 | + None, |
| 101 | + f"Unsupported {tool} version '{user_input}'.\n" |
| 102 | + f"Latest stable version: {latest}\n" |
| 103 | + f"Available versions (sample): {sample}\n" |
| 104 | + f"Run `pip index versions {tool}` to see all available versions.", |
| 105 | + ) |
83 | 106 |
|
84 | 107 |
|
85 | | -def resolve_tool_version( |
86 | | - tool: str, version: Optional[str] |
87 | | -) -> Tuple[Optional[str], Optional[str]]: |
88 | | - """Resolve a requested tool version or return a user-facing error message.""" |
89 | | - if version is None: |
90 | | - return _default_version_for_tool(tool), None |
| 108 | +def _detect_installed_version(tool: str) -> Optional[str]: |
| 109 | + """Return the version of *tool* already on PATH, or None. |
91 | 110 |
|
92 | | - resolved = _resolve_version(_versions_for_tool(tool), version) |
93 | | - if resolved is None: |
94 | | - return ( |
95 | | - None, |
96 | | - f"Unsupported {tool} version '{version}'.\n" |
97 | | - f"{_supported_versions_message(tool)}", |
| 111 | + Used as a fallback when PyPI is unreachable and no explicit version |
| 112 | + was requested. Extracts the version string from ``<tool> --version`` |
| 113 | + output (e.g. ``"clang-format version 18.1.8"`` → ``"18.1.8"``). |
| 114 | + """ |
| 115 | + existing = shutil.which(tool) |
| 116 | + if not existing: |
| 117 | + return None |
| 118 | + try: |
| 119 | + result = subprocess.run( |
| 120 | + [existing, "--version"], capture_output=True, text=True, timeout=10 |
98 | 121 | ) |
99 | | - return resolved, None |
| 122 | + except (OSError, subprocess.TimeoutExpired): |
| 123 | + return None |
| 124 | + match = re.search(r"(\d+\.\d+\.\d+(?:\.\d+)?)", result.stdout) |
| 125 | + return match.group(1) if match else None |
100 | 126 |
|
101 | 127 |
|
102 | 128 | def _is_version_installed(tool: str, version: str) -> Optional[Path]: |
@@ -128,15 +154,19 @@ def _install_tool(tool: str, version: str) -> Optional[Path]: |
128 | 154 | def resolve_install_with_diagnostics( |
129 | 155 | tool: str, version: Optional[str], verbose: bool = False |
130 | 156 | ) -> Tuple[Optional[Path], Optional[str]]: |
131 | | - """Resolve/install a tool, returning a user-facing error for bad versions.""" |
132 | | - user_version, error = resolve_tool_version(tool, version) |
| 157 | + """Resolve/install a tool, returning a user-facing error for bad versions. |
| 158 | +
|
| 159 | + Tool versions are resolved dynamically from PyPI — no hardcoded |
| 160 | + list is maintained in-tree. |
| 161 | + """ |
| 162 | + user_version, error = _resolve_version_from_pypi(tool, version) |
133 | 163 | if error is not None: |
134 | 164 | return None, error |
135 | 165 |
|
136 | 166 | if verbose: |
137 | 167 | if version is None: |
138 | 168 | print( |
139 | | - f"Using default {tool} Python wheel version {user_version}", |
| 169 | + f"Using latest {tool} Python wheel version {user_version}", |
140 | 170 | file=sys.stderr, |
141 | 171 | ) |
142 | 172 | elif version == user_version: |
|
0 commit comments