-
-
Notifications
You must be signed in to change notification settings - Fork 1
feat: support for clang-include-cleaner and clang-apply-replacements and switch to dynamic PyPI version resolution
#185
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
b2ef878
e5ed83f
0e1340b
007a17b
28d7128
562cbf5
d89e73e
98fb20e
2944362
7ea2cac
d409918
8930e2f
47ead56
1f4a933
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
This file was deleted.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,182 @@ | ||
| """ | ||
| ``clang_tools.wheel_install`` | ||
| ----------------------------- | ||
|
|
||
| Self-contained wheel installation via PyPI. | ||
|
|
||
| Tool versions are resolved dynamically from the PyPI JSON API — | ||
| no hardcoded version list is maintained in-tree. | ||
| """ | ||
|
|
||
| import json | ||
| import logging | ||
| import re | ||
| import shutil | ||
| import subprocess | ||
| import sys | ||
| import urllib.request | ||
| from functools import lru_cache | ||
| from pathlib import Path | ||
| from typing import Optional, Tuple | ||
|
|
||
| LOG = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| @lru_cache(maxsize=4) | ||
| def _get_pypi_versions(tool: str) -> Tuple[Optional[str], list]: | ||
| """Fetch (latest_version, [stable_versions_descending]) from PyPI JSON API. | ||
|
|
||
| Results are cached per tool name so repeated calls within the same | ||
| process reuse the last HTTP response. | ||
| """ | ||
| try: | ||
| url = f"https://pypi.org/pypi/{tool}/json" | ||
| with urllib.request.urlopen(url, timeout=10) as response: | ||
| data = json.loads(response.read()) | ||
| except Exception as exc: | ||
| LOG.warning("Failed to fetch versions for %s from PyPI: %s", tool, exc) | ||
| return None, [] | ||
|
|
||
| all_versions = list(data["releases"].keys()) | ||
|
|
||
| # Filter out pre-release versions | ||
| pre_release_pattern = re.compile( | ||
| r".*(alpha|beta|rc|dev|a\d+|b\d+).*", re.IGNORECASE | ||
| ) | ||
| stable = [v for v in all_versions if not pre_release_pattern.match(v)] | ||
|
|
||
| if not stable: | ||
| LOG.warning("No stable versions found for %s on PyPI", tool) | ||
| return None, [] | ||
|
|
||
| # Sort ascending by version tuple | ||
| stable.sort(key=lambda x: tuple(map(int, x.split(".")))) | ||
|
|
||
| latest = stable[-1] | ||
| # Return descending for prefix matching (newest first) | ||
| return latest, list(reversed(stable)) | ||
|
|
||
|
|
||
| def _detect_installed_version(tool: str) -> Optional[str]: | ||
| """Return the version of *tool* already on PATH, or None. | ||
|
|
||
| Used as a fallback when PyPI is unreachable and no explicit version | ||
| was requested. Extracts the version string from ``<tool> --version`` | ||
| output (e.g. ``"clang-format version 18.1.8"`` → ``"18.1.8"``). | ||
| """ | ||
| existing = shutil.which(tool) | ||
| if not existing: | ||
| return None | ||
| try: | ||
| result = subprocess.run( | ||
| [existing, "--version"], capture_output=True, text=True, timeout=10 | ||
| ) | ||
| except (OSError, subprocess.TimeoutExpired): | ||
| return None | ||
| match = re.search(r"(\d+\.\d+\.\d+(?:\.\d+)?)", result.stdout) | ||
| return match.group(1) if match else None | ||
|
|
||
|
|
||
| def _resolve_version( | ||
| tool: str, user_input: Optional[str] | ||
| ) -> Tuple[Optional[str], Optional[str]]: | ||
| """Resolve a version dynamically from PyPI. | ||
|
|
||
| Returns (resolved_version, error_message). The error_message is | ||
| suitable for displaying directly to the end user. | ||
|
|
||
| When PyPI is unreachable and no explicit version was requested, | ||
| falls back to whatever version is already installed on the host | ||
| so that pre-installed tools keep working offline. | ||
| """ | ||
| latest, versions = _get_pypi_versions(tool) | ||
|
|
||
| if not versions: | ||
| if user_input is None: | ||
| installed = _detect_installed_version(tool) | ||
| if installed: | ||
| LOG.info( | ||
| "PyPI unreachable; using locally installed %s %s", | ||
| tool, | ||
| installed, | ||
| ) | ||
| return installed, None | ||
| return ( | ||
| None, | ||
| f"Could not find any stable versions of {tool} on PyPI. " | ||
| "Check your network connection.", | ||
| ) | ||
|
|
||
| if user_input is None: | ||
| return latest, None | ||
|
|
||
| # Exact match | ||
| if user_input in versions: | ||
| return user_input, None | ||
|
|
||
| # Prefix match (e.g. "20" → "20.1.8"). Versions are newest-first, | ||
| # so the first matching entry is the latest for that prefix. | ||
| matched = [v for v in versions if v.startswith(user_input)] | ||
| if matched: | ||
| return matched[0], None | ||
|
|
||
| # No match – help the user | ||
| sample = ", ".join(versions[:15]) | ||
| return ( | ||
| None, | ||
| f"Unsupported {tool} version '{user_input}'.\n" | ||
| f"Latest stable version: {latest}\n" | ||
| f"Available versions (sample): {sample}\n" | ||
| f"Run `pip index versions {tool}` to see all available versions.", | ||
| ) | ||
|
|
||
|
|
||
| def _is_version_installed(tool: str, version: str) -> Optional[Path]: | ||
| """Return the tool path if the installed version matches, otherwise None.""" | ||
| existing = shutil.which(tool) | ||
| if not existing: | ||
| return None | ||
| result = subprocess.run([existing, "--version"], capture_output=True, text=True) | ||
| if version in result.stdout: | ||
| return Path(existing) | ||
| return None | ||
|
|
||
|
|
||
| def _install_tool(tool: str, version: str) -> Optional[Path]: | ||
| """Install a tool using pip, logging output on failure.""" | ||
| result = subprocess.run( | ||
| [sys.executable, "-m", "pip", "install", f"{tool}=={version}"], | ||
| capture_output=True, | ||
| text=True, | ||
| ) | ||
| if result.returncode == 0: | ||
| return shutil.which(tool) | ||
| LOG.error("pip failed to install %s %s", tool, version) | ||
| LOG.error(result.stdout) | ||
| LOG.error(result.stderr) | ||
| return None | ||
|
|
||
|
|
||
| def resolve_wheel_install( | ||
| tool: str, version: Optional[str] | ||
| ) -> Tuple[Optional[Path], Optional[str]]: | ||
| """Resolve and install a clang tool as a Python wheel from PyPI. | ||
|
|
||
| Tool versions are resolved dynamically from the PyPI JSON API — | ||
| no hardcoded list is maintained in-tree. | ||
|
|
||
| :param tool: Tool name as registered on PyPI (e.g. ``"clang-format"``). | ||
| :param version: Desired version string, or ``None`` for the latest stable. | ||
|
|
||
| :returns: A tuple ``(path, error)`` where *path* is the installed | ||
| binary location on success and *error* is a user-facing message | ||
| on failure. Exactly one of the two is not ``None``. | ||
| """ | ||
| user_version, error = _resolve_version(tool, version) | ||
| if error is not None: | ||
| return None, error | ||
|
|
||
| return ( | ||
| _is_version_installed(tool, user_version) or _install_tool(tool, user_version), | ||
| None, | ||
| ) | ||
|
Comment on lines
+179
to
+182
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Don't let install failures escape as If Proposed fix def resolve_wheel_install(
tool: str, version: Optional[str]
) -> Tuple[Optional[Path], Optional[str]]:
@@
user_version, error = _resolve_version(tool, version)
if error is not None:
return None, error
- return (
- _is_version_installed(tool, user_version) or _install_tool(tool, user_version),
- None,
- )
+ installed = _is_version_installed(tool, user_version)
+ if installed is not None:
+ return installed, None
+
+ installed = _install_tool(tool, user_version)
+ if installed is not None:
+ return installed, None
+
+ return None, f"Failed to install {tool} {user_version}."🤖 Prompt for AI Agents |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -31,7 +31,6 @@ classifiers = [ | |
| ] | ||
|
|
||
| dependencies = [ | ||
| "cpp-linter-hooks>=1.1.7", | ||
| ] | ||
|
|
||
| dynamic = ["version"] | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Use an exact parsed version match here, not a raw substring check.
if version in result.stdoutcan reuse the wrong local binary for an explicit request when the installed version merely contains that string, and this path also drops the timeout/OSErrorhandling you already added in_detect_installed_version(). Parse the version token and compare for equality instead.Proposed fix
def _is_version_installed(tool: str, version: str) -> Optional[Path]: """Return the tool path if the installed version matches, otherwise None.""" existing = shutil.which(tool) if not existing: return None - result = subprocess.run([existing, "--version"], capture_output=True, text=True) - if version in result.stdout: - return Path(existing) + try: + result = subprocess.run( + [existing, "--version"], capture_output=True, text=True, timeout=10 + ) + except (OSError, subprocess.TimeoutExpired): + return None + match = re.search(r"(\d+\.\d+\.\d+(?:\.\d+)?)", result.stdout) + if match and match.group(1) == version: + return Path(existing) return None🧰 Tools
🪛 ast-grep (0.43.0)
[error] 138-138: Use of unsanitized data to create processes
Context: subprocess.run([existing, "--version"], capture_output=True, text=True)
Note: [CWE-78].
(os-system-unsanitized-data)
[error] 138-138: Command coming from incoming request
Context: subprocess.run([existing, "--version"], capture_output=True, text=True)
Note: [CWE-20].
(subprocess-from-request)
🪛 Ruff (0.15.15)
[error] 139-139:
subprocesscall: check for execution of untrusted input(S603)
🤖 Prompt for AI Agents