Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 31 additions & 40 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -141,49 +141,40 @@ jobs:
clang-query-${{ matrix.version }} --version
clang-apply-replacements-${{ matrix.version }} --version

- name: Install and check clang-format wheels
if: ${{ fromJSON(matrix.version) >= 10 }}
- name: Install and check wheels
shell: bash
run: |
set -e
clang-tools install clang-format --wheel --version ${{ matrix.version }}

echo "Checking clang-format versions..."
clang-format --version

# Verify versions contain expected string
clang_format_version=$(clang-format --version)

echo "clang-format version: $clang_format_version"

if ! echo "$clang_format_version" | grep -q "version ${{ matrix.version }}"; then
echo "❌ Unexpected clang-format version!"
exit 1
fi

echo "✅ clang-format Versions are correct."

- name: Install and check clang-tidy wheels
if: ${{ fromJSON(matrix.version) >= 13 }}
shell: bash
run: |
set -e
clang-tools install clang-tidy --wheel --version ${{ matrix.version }}

echo "Checking clang-tidy versions..."
clang-tidy --version

# Verify versions contain expected string
clang_tidy_version=$(clang-tidy --version)

echo "clang-tidy version: $clang_tidy_version"

if ! echo "$clang_tidy_version" | grep -q "version ${{ matrix.version }}"; then
echo "❌ Unexpected clang-tidy version!"
exit 1
fi

echo "✅ clang-tidy Versions are correct."
ver=${{ matrix.version }}

check_wheel() {
local tool=$1 min=$2 max=$3
if [ "$ver" -lt "$min" ]; then
echo "⏭️ Skipping $tool wheel (min version $min, have $ver)"
return 0
fi
if [ -n "$max" ] && [ "$ver" -gt "$max" ]; then
echo "⏭️ Skipping $tool wheel (max version $max, have $ver)"
return 0
fi

echo "::group::Installing $tool wheel v$ver"
clang-tools install "$tool" --wheel --version "$ver"
"$tool" --version
local out=$("$tool" --version)
echo "$tool version: $out"
if ! echo "$out" | grep -q "version $ver"; then
echo "❌ Unexpected $tool version!"
exit 1
fi
echo "✅ $tool OK."
echo "::endgroup::"
}

check_wheel clang-format 10 ""
check_wheel clang-tidy 13 ""
check_wheel clang-include-cleaner 22 22
check_wheel clang-apply-replacements 16 17

docs:
uses: cpp-linter/.github/.github/workflows/mkdocs.yml@main
32 changes: 21 additions & 11 deletions clang_tools/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,28 +27,38 @@ def _is_version_like(target: str) -> bool:
return False


#: Known tool names supported by wheel installs
WHEEL_TOOLS = {"clang-format", "clang-tidy"}
#: Known tool names commonly installed as Python wheels.
#: With dynamic PyPI version resolution, any clang tool available on PyPI
#: can be installed, but these are the canonical ones.
WHEEL_TOOLS = {
"clang-format",
"clang-tidy",
"clang-include-cleaner",
"clang-apply-replacements",
}


def _wheel_install(tools: list[str], version: Optional[str]) -> int:
"""Install tool(s) as Python wheels using ``cpp_linter_hooks``.
"""Install tool(s) as Python wheels.

Delegates to :func:`cpp_linter_hooks.util.resolve_install` for version
resolution and pip-based installation.
Tool versions are resolved dynamically from the PyPI JSON API —
no hardcoded list is maintained in-tree.

:returns: exit code (0 on success, 1 on failure).
"""
from cpp_linter_hooks.util import resolve_install # lazy import
from .wheel_install import resolve_wheel_install

ok = True
for tool in tools:
path = resolve_install(tool, version)
version_str = f" version {version}" if version else " latest version"
if path:
path, error = resolve_wheel_install(tool, version)
if error is not None:
print(f"{YELLOW}{error}{RESET_COLOR}", file=sys.stderr)
ok = False
elif path:
version_str = f" version {version}" if version else " latest version"
print(f"{tool}{version_str} installed at: {path}")
else:
print(f"Failed to install {tool}{version_str}", file=sys.stderr)
print(f"Failed to install {tool}", file=sys.stderr)
ok = False
return 0 if ok else 1

Expand Down Expand Up @@ -184,7 +194,7 @@ def get_parser() -> argparse.ArgumentParser:
install_p.add_argument(
"--wheel",
action="store_true",
help="Force wheel installation via cpp-linter-hooks",
help="Force wheel installation (resolves versions from PyPI)",
)
install_p.add_argument(
"--version",
Expand Down
16 changes: 0 additions & 16 deletions clang_tools/wheel.py

This file was deleted.

182 changes: 182 additions & 0 deletions clang_tools/wheel_install.py
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
Comment on lines +134 to +142

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Use an exact parsed version match here, not a raw substring check.

if version in result.stdout can reuse the wrong local binary for an explicit request when the installed version merely contains that string, and this path also drops the timeout/OSError handling 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: subprocess call: check for execution of untrusted input

(S603)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@clang_tools/wheel_install.py` around lines 134 - 142, The
_is_version_installed function uses a substring check (if version in
result.stdout) which can incorrectly match when the installed version merely
contains the requested version as a substring. Replace this with exact version
parsing and equality comparison. Additionally, add the timeout and OSError
handling to the subprocess.run call that you already implemented in
_detect_installed_version to make the code consistent and more robust. Parse the
version token from the stdout output and compare it for equality rather than
substring containment.



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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't let install failures escape as (None, None).

If _install_tool() fails, this returns (None, None), which breaks the resolver contract documented in this module and drops the per-tool error handling that clang_tools/main.py now expects. Return a non-None error whenever the install or post-install lookup fails.

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
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@clang_tools/wheel_install.py` around lines 179 - 182, The current return
statement always returns None as the second element of the tuple, even when
_install_tool() fails. This breaks the resolver contract that expects a non-None
error when installation or post-install lookup fails. Modify the return
statement to capture any error that occurs when _install_tool(tool,
user_version) is called and return that error as the second element of the tuple
instead of hardcoding None, ensuring the contract is maintained and error
handling in clang_tools/main.py works correctly.

1 change: 0 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ classifiers = [
]

dependencies = [
"cpp-linter-hooks>=1.1.7",
]

dynamic = ["version"]
Expand Down
Loading
Loading