|
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 ( |
16 | | - CLANG_FORMAT_VERSIONS, |
17 | | - CLANG_TIDY_VERSIONS, |
18 | | - CLANG_INCLUDE_CLEANER_VERSIONS, |
19 | | - CLANG_APPLY_REPLACEMENTS_VERSIONS, |
20 | | -) |
| 8 | +from typing import Optional, Tuple |
| 9 | +from functools import lru_cache |
| 10 | +import json |
| 11 | +import urllib.request |
| 12 | +import re |
21 | 13 |
|
22 | 14 | LOG = logging.getLogger(__name__) |
23 | 15 |
|
24 | 16 |
|
25 | | -def get_version_from_dependency(tool: str) -> Optional[str]: |
26 | | - """Get the version of a tool from the pyproject.toml dependencies.""" |
27 | | - pyproject_path = Path(__file__).parent.parent / "pyproject.toml" |
28 | | - if not pyproject_path.exists(): |
29 | | - return None |
30 | | - with open(pyproject_path, "rb") as f: |
31 | | - data = tomllib.load(f) |
32 | | - # Check [project].dependencies |
33 | | - dependencies = data.get("project", {}).get("dependencies", []) |
34 | | - for dep in dependencies: |
35 | | - if dep.startswith(f"{tool}=="): |
36 | | - return dep.split("==")[1] |
37 | | - return None |
| 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. |
38 | 20 |
|
| 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)] |
39 | 39 |
|
40 | | -DEFAULT_CLANG_FORMAT_VERSION = CLANG_FORMAT_VERSIONS[-1] # latest from versions.py |
41 | | -DEFAULT_CLANG_TIDY_VERSION = CLANG_TIDY_VERSIONS[-1] # latest from versions.py |
42 | | -DEFAULT_CLANG_INCLUDE_CLEANER_VERSION = CLANG_INCLUDE_CLEANER_VERSIONS[-1] |
43 | | -DEFAULT_CLANG_APPLY_REPLACEMENTS_VERSION = CLANG_APPLY_REPLACEMENTS_VERSIONS[-1] |
44 | | - |
| 40 | + if not stable: |
| 41 | + LOG.warning("No stable versions found for %s on PyPI", tool) |
| 42 | + return None, [] |
45 | 43 |
|
46 | | -def _versions_for_tool(tool: str) -> List[str]: |
47 | | - """Return supported Python wheel versions for a clang tool.""" |
48 | | - _tool_map = { |
49 | | - "clang-format": CLANG_FORMAT_VERSIONS, |
50 | | - "clang-tidy": CLANG_TIDY_VERSIONS, |
51 | | - "clang-include-cleaner": CLANG_INCLUDE_CLEANER_VERSIONS, |
52 | | - "clang-apply-replacements": CLANG_APPLY_REPLACEMENTS_VERSIONS, |
53 | | - } |
54 | | - return _tool_map[tool] |
| 44 | + # Sort ascending by version tuple |
| 45 | + stable.sort(key=lambda x: tuple(map(int, x.split(".")))) |
55 | 46 |
|
| 47 | + latest = stable[-1] |
| 48 | + # Return descending for prefix matching (newest first) |
| 49 | + return latest, list(reversed(stable)) |
56 | 50 |
|
57 | | -def _default_version_for_tool(tool: str) -> Optional[str]: |
58 | | - """Return the default Python wheel version for a clang tool.""" |
59 | | - _default_map = { |
60 | | - "clang-format": DEFAULT_CLANG_FORMAT_VERSION, |
61 | | - "clang-tidy": DEFAULT_CLANG_TIDY_VERSION, |
62 | | - "clang-include-cleaner": DEFAULT_CLANG_INCLUDE_CLEANER_VERSION, |
63 | | - "clang-apply-replacements": DEFAULT_CLANG_APPLY_REPLACEMENTS_VERSION, |
64 | | - } |
65 | | - return _default_map[tool] |
66 | 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. |
67 | 56 |
|
68 | | -def _supported_versions_message(tool: str) -> str: |
69 | | - """Build a user-facing list of supported wheel versions for a clang tool.""" |
70 | | - versions = ", ".join(_versions_for_tool(tool)) |
71 | | - return f"Supported {tool} wheel versions: {versions}" |
| 57 | + Returns (resolved_version, error_message). The error_message is |
| 58 | + suitable for displaying directly to the end user. |
| 59 | + """ |
| 60 | + latest, versions = _get_pypi_versions(tool) |
72 | 61 |
|
| 62 | + if not versions: |
| 63 | + return ( |
| 64 | + None, |
| 65 | + f"Could not find any stable versions of {tool} on PyPI. " |
| 66 | + "Check your network connection.", |
| 67 | + ) |
73 | 68 |
|
74 | | -def _resolve_version(versions: List[str], user_input: Optional[str]) -> Optional[str]: |
75 | | - """Resolve the latest matching version based on user input and available versions.""" |
76 | 69 | if user_input is None: |
77 | | - return None |
78 | | - if user_input in versions: |
79 | | - return user_input |
80 | | - |
81 | | - try: |
82 | | - # filter versions that start with the user input |
83 | | - matched_versions = [v for v in versions if v.startswith(user_input)] |
84 | | - if not matched_versions: |
85 | | - raise ValueError |
86 | | - |
87 | | - # define a function to parse version strings into tuples for comparison |
88 | | - def parse_version(v: str): |
89 | | - """Convert a dotted version string into an integer tuple.""" |
90 | | - return tuple(map(int, v.split("."))) |
91 | | - |
92 | | - # return the latest version |
93 | | - return max(matched_versions, key=parse_version) |
94 | | - |
95 | | - except ValueError: |
96 | | - LOG.warning("Version %s not found in available versions", user_input) |
97 | | - return None |
| 70 | + return latest, None |
98 | 71 |
|
| 72 | + # Exact match |
| 73 | + if user_input in versions: |
| 74 | + return user_input, None |
99 | 75 |
|
100 | | -def resolve_tool_version( |
101 | | - tool: str, version: Optional[str] |
102 | | -) -> Tuple[Optional[str], Optional[str]]: |
103 | | - """Resolve a requested tool version or return a user-facing error message.""" |
104 | | - if version is None: |
105 | | - return _default_version_for_tool(tool), None |
| 76 | + # Prefix match (e.g. "20" → "20.1.8"). Versions are newest-first, |
| 77 | + # so the first matching entry is the latest for that prefix. |
| 78 | + matched = [v for v in versions if v.startswith(user_input)] |
| 79 | + if matched: |
| 80 | + return matched[0], None |
106 | 81 |
|
107 | | - resolved = _resolve_version(_versions_for_tool(tool), version) |
108 | | - if resolved is None: |
109 | | - return ( |
110 | | - None, |
111 | | - f"Unsupported {tool} version '{version}'.\n" |
112 | | - f"{_supported_versions_message(tool)}", |
113 | | - ) |
114 | | - return resolved, None |
| 82 | + # No match – help the user |
| 83 | + sample = ", ".join(versions[:15]) |
| 84 | + return ( |
| 85 | + None, |
| 86 | + f"Unsupported {tool} version '{user_input}'.\n" |
| 87 | + f"Latest stable version: {latest}\n" |
| 88 | + f"Available versions (sample): {sample}\n" |
| 89 | + f"Run `pip index versions {tool}` to see all available versions.", |
| 90 | + ) |
115 | 91 |
|
116 | 92 |
|
117 | 93 | def _is_version_installed(tool: str, version: str) -> Optional[Path]: |
@@ -143,15 +119,19 @@ def _install_tool(tool: str, version: str) -> Optional[Path]: |
143 | 119 | def resolve_install_with_diagnostics( |
144 | 120 | tool: str, version: Optional[str], verbose: bool = False |
145 | 121 | ) -> Tuple[Optional[Path], Optional[str]]: |
146 | | - """Resolve/install a tool, returning a user-facing error for bad versions.""" |
147 | | - user_version, error = resolve_tool_version(tool, version) |
| 122 | + """Resolve/install a tool, returning a user-facing error for bad versions. |
| 123 | +
|
| 124 | + Tool versions are resolved dynamically from PyPI — no hardcoded |
| 125 | + list is maintained in-tree. |
| 126 | + """ |
| 127 | + user_version, error = _resolve_version_from_pypi(tool, version) |
148 | 128 | if error is not None: |
149 | 129 | return None, error |
150 | 130 |
|
151 | 131 | if verbose: |
152 | 132 | if version is None: |
153 | 133 | print( |
154 | | - f"Using default {tool} Python wheel version {user_version}", |
| 134 | + f"Using latest {tool} Python wheel version {user_version}", |
155 | 135 | file=sys.stderr, |
156 | 136 | ) |
157 | 137 | elif version == user_version: |
|
0 commit comments