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
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ repos:
- id: check-toml
- id: requirements-txt-fixer
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.22
rev: v0.16.0
hooks:
# Run the linter.
- id: ruff-check
Expand Down
8 changes: 3 additions & 5 deletions cpp_linter_hooks/clang_format.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,17 @@
import subprocess
import sys
from argparse import ArgumentParser
from typing import Tuple

from cpp_linter_hooks.util import resolve_install_with_diagnostics


parser = ArgumentParser()
parser.add_argument("--version", default=None)
parser.add_argument(
"-v", "--verbose", action="store_true", help="Enable verbose output"
)


def run_clang_format(args=None) -> Tuple[int, str]:
def run_clang_format(args=None) -> tuple[int, str]:
"""Run clang-format with hook-specific arguments removed."""
hook_args, other_args = parser.parse_known_args(args)
_, version_error = resolve_install_with_diagnostics(
Expand All @@ -40,9 +38,9 @@ def run_clang_format(args=None) -> Tuple[int, str]:
# Run the clang-format command with captured output
sp = subprocess.run(
command,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
capture_output=True,
encoding="utf-8",
check=False,
)

# Combine stdout and stderr for complete output
Expand Down
31 changes: 14 additions & 17 deletions cpp_linter_hooks/clang_tidy.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
"""Pre-commit hook wrapper for clang-tidy."""

from concurrent.futures import ThreadPoolExecutor
import subprocess
import sys
from argparse import ArgumentParser, ArgumentTypeError
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from typing import List, Optional, Tuple

from cpp_linter_hooks.util import resolve_install_with_diagnostics

Expand Down Expand Up @@ -63,15 +62,15 @@ def _positive_int(value: str) -> int:
parser.add_argument("--fix", action="store_true", help="Apply fixes in place (-fix)")


def _find_compile_commands() -> Optional[str]:
def _find_compile_commands() -> str | None:
"""Return the first common directory containing compile_commands.json."""
for d in COMPILE_DB_SEARCH_DIRS:
if (Path(d) / "compile_commands.json").exists():
return d
return None


def _compile_commands_not_found_message(path: Optional[str] = None) -> str:
def _compile_commands_not_found_message(path: str | None = None) -> str:
"""Build a user-facing message for missing compile_commands.json files."""
if path is None:
return "No compile_commands.json was found in common build directories.\n\n" + (
Expand All @@ -85,7 +84,7 @@ def _compile_commands_not_found_message(path: Optional[str] = None) -> str:

def _resolve_compile_db(
hook_args, other_args
) -> Tuple[Optional[str], Optional[Tuple[int, str]]]:
) -> tuple[str | None, tuple[int, str] | None]:
"""Resolve the compile_commands.json directory to pass as -p to clang-tidy.

Returns (db_path, None) on success or (None, (retval, message)) on error.
Expand Down Expand Up @@ -163,7 +162,7 @@ def _looks_like_msvc_error(output: str) -> bool:

def _append_guidance(output: str) -> str:
"""Append troubleshooting guidance when clang-tidy output matches known errors."""
hints: List[str] = []
hints: list[str] = []
if _looks_like_compile_db_error(output) and COMPILE_COMMANDS_HINT not in output:
hints.append(COMPILE_COMMANDS_HINT)
if _looks_like_msvc_error(output) and MSVC_HINT not in output:
Expand All @@ -174,12 +173,10 @@ def _append_guidance(output: str) -> str:
return output.rstrip("\n") + separator + "\n\n".join(hints)


def _exec_clang_tidy(command) -> Tuple[int, str]:
def _exec_clang_tidy(command) -> tuple[int, str]:
"""Run clang-tidy and return (retval, output)."""
try:
sp = subprocess.run(
command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, encoding="utf-8"
)
sp = subprocess.run(command, capture_output=True, encoding="utf-8", check=False)
output = (sp.stdout or "") + (sp.stderr or "")
output = _append_guidance(output)
retval = (
Expand All @@ -195,10 +192,10 @@ def _looks_like_source_file(path: str) -> bool:
return Path(path).suffix.lower() in SOURCE_FILE_SUFFIXES


def _split_source_files(args: List[str]) -> Tuple[List[str], List[str]]:
def _split_source_files(args: list[str]) -> tuple[list[str], list[str]]:
"""Split clang-tidy options from trailing source file arguments."""
split_idx = len(args)
source_files: List[str] = []
source_files: list[str] = []
for idx in range(len(args) - 1, -1, -1):
if not _looks_like_source_file(args[idx]):
break
Expand All @@ -207,17 +204,17 @@ def _split_source_files(args: List[str]) -> Tuple[List[str], List[str]]:
return args[:split_idx], list(reversed(source_files))


def _combine_outputs(results: List[Tuple[int, str]]) -> str:
def _combine_outputs(results: list[tuple[int, str]]) -> str:
"""Join non-empty clang-tidy outputs from multiple executions."""
return "\n".join(output.rstrip("\n") for _, output in results if output)


def _exec_parallel_clang_tidy(
command_prefix: List[str], source_files: List[str], jobs: int
) -> Tuple[int, str]:
command_prefix: list[str], source_files: list[str], jobs: int
) -> tuple[int, str]:
"""Run clang-tidy over source files in parallel and combine the results."""

def run_file(source_file: str) -> Tuple[int, str]:
def run_file(source_file: str) -> tuple[int, str]:
"""Run clang-tidy for a single source file."""
return _exec_clang_tidy(command_prefix + [source_file])

Expand All @@ -228,7 +225,7 @@ def run_file(source_file: str) -> Tuple[int, str]:
return retval, _combine_outputs(results)


def run_clang_tidy(args=None) -> Tuple[int, str]:
def run_clang_tidy(args=None) -> tuple[int, str]:
"""Run clang-tidy with hook-specific argument handling."""
hook_args, other_args = parser.parse_known_args(args)
_, version_error = resolve_install_with_diagnostics(
Expand Down
61 changes: 36 additions & 25 deletions cpp_linter_hooks/util.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
"""Shared helpers for resolving and installing clang tool wheels."""

import sys
import json
import logging
import re
import shutil
import subprocess
from pathlib import Path
import logging
from typing import Optional, Tuple
from functools import lru_cache
import json
import sys
import urllib.error
import urllib.request
import re
from functools import lru_cache
from pathlib import Path

LOG = logging.getLogger(__name__)


@lru_cache(maxsize=4)
def _get_pypi_versions(tool: str) -> Tuple[Optional[str], list]:
def _get_pypi_versions(tool: str) -> tuple[str | None, list]:
"""Fetch (latest_version, [stable_versions_descending]) from PyPI JSON API.

Results are cached per tool name so repeated calls within the same
Expand All @@ -25,7 +25,7 @@ def _get_pypi_versions(tool: str) -> Tuple[Optional[str], list]:
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:
except (OSError, json.JSONDecodeError) as exc:
LOG.warning("Failed to fetch versions for %s from PyPI: %s", tool, exc)
return None, []

Expand All @@ -50,8 +50,8 @@ def _get_pypi_versions(tool: str) -> Tuple[Optional[str], list]:


def _resolve_version_from_pypi(
tool: str, user_input: Optional[str]
) -> Tuple[Optional[str], Optional[str]]:
tool: str, user_input: str | None
) -> tuple[str | None, str | None]:
"""Resolve a version dynamically from PyPI.

Returns (resolved_version, error_message). The error_message is
Expand All @@ -77,8 +77,10 @@ def _resolve_version_from_pypi(
return installed, None
return (
None,
f"Could not find any stable versions of {tool} on PyPI. "
"Check your network connection.",
(
f"Could not find any stable versions of {tool} on PyPI. "
"Check your network connection."
),
)

if user_input is None:
Expand All @@ -98,14 +100,16 @@ def _resolve_version_from_pypi(
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.",
(
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 _detect_installed_version(tool: str) -> Optional[str]:
def _detect_installed_version(tool: str) -> str | None:
"""Return the version of *tool* already on PATH, or None.

Used as a fallback when PyPI is unreachable and no explicit version
Expand All @@ -117,31 +121,38 @@ def _detect_installed_version(tool: str) -> Optional[str]:
return None
try:
result = subprocess.run(
[existing, "--version"], capture_output=True, text=True, timeout=10
[existing, "--version"],
capture_output=True,
text=True,
timeout=10,
check=False,
)
except (OSError, subprocess.TimeoutExpired):
return None
matches = re.findall(r"\d+\.\d+\.\d+(?:\.\d+)?", result.stdout)
return matches[0] if matches else None


def _is_version_installed(tool: str, version: str) -> Optional[Path]:
def _is_version_installed(tool: str, version: str) -> Path | None:
"""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)
result = subprocess.run(
[existing, "--version"], capture_output=True, text=True, check=False
)
if version in result.stdout:
return Path(existing)
return None


def _install_tool(tool: str, version: str) -> Optional[Path]:
def _install_tool(tool: str, version: str) -> Path | None:
"""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,
check=False,
)
if result.returncode == 0:
return shutil.which(tool)
Expand All @@ -152,8 +163,8 @@ def _install_tool(tool: str, version: str) -> Optional[Path]:


def resolve_install_with_diagnostics(
tool: str, version: Optional[str], verbose: bool = False
) -> Tuple[Optional[Path], Optional[str]]:
tool: str, version: str | None, verbose: bool = False
) -> tuple[Path | None, str | None]:
"""Resolve/install a tool, returning a user-facing error for bad versions.

Tool versions are resolved dynamically from PyPI — no hardcoded
Expand Down Expand Up @@ -184,7 +195,7 @@ def resolve_install_with_diagnostics(
)


def resolve_install(tool: str, version: Optional[str]) -> Optional[Path]:
def resolve_install(tool: str, version: str | None) -> Path | None:
"""Resolve/install a tool, logging bad-version diagnostics."""
path, error = resolve_install_with_diagnostics(tool, version)
if error is not None:
Expand Down
3 changes: 2 additions & 1 deletion tests/test_clang_format.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import pytest
from pathlib import Path
from unittest.mock import patch

import pytest

from cpp_linter_hooks.clang_format import run_clang_format


Expand Down
11 changes: 6 additions & 5 deletions tests/test_clang_tidy.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
import pytest
import subprocess
import time
from pathlib import Path
from unittest.mock import patch, MagicMock
from unittest.mock import MagicMock, patch

import pytest

from cpp_linter_hooks.clang_tidy import _exec_clang_tidy, run_clang_tidy


@pytest.fixture(scope="function")
def generate_compilation_database():
subprocess.run(["mkdir", "-p", "build"])
subprocess.run(["cmake", "-Bbuild", "testing/"])
subprocess.run(["cmake", "-Bbuild", "testing/"])
subprocess.run(["mkdir", "-p", "build"], check=True)
subprocess.run(["cmake", "-Bbuild", "testing/"], check=True)
subprocess.run(["cmake", "-Bbuild", "testing/"], check=True)


@pytest.mark.benchmark
Expand Down
4 changes: 2 additions & 2 deletions tests/test_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@
"""

import shutil
from pathlib import Path

import pytest
from pathlib import Path

from cpp_linter_hooks.clang_format import run_clang_format
from cpp_linter_hooks.clang_tidy import run_clang_tidy
Expand Down Expand Up @@ -201,7 +201,7 @@ def test_clang_tidy_parallel_execution_completes(tmp_path):
main_file.write_bytes(MAIN_C.read_bytes())
good_file.write_bytes(GOOD_C.read_bytes())

ret, output = run_clang_tidy(
ret, _output = run_clang_tidy(
[
"--no-compile-commands",
"--jobs=2",
Expand Down
16 changes: 9 additions & 7 deletions tests/test_util.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,20 @@
"""Tests for cpp_linter_hooks.util -- dynamic PyPI version resolution."""

import pytest
from unittest.mock import patch
from pathlib import Path
import subprocess
import sys
from pathlib import Path
from unittest.mock import patch

import pytest

from cpp_linter_hooks.util import (
_get_pypi_versions,
_resolve_version_from_pypi,
_detect_installed_version,
_is_version_installed,
_get_pypi_versions,
_install_tool,
resolve_install_with_diagnostics,
_is_version_installed,
_resolve_version_from_pypi,
resolve_install,
resolve_install_with_diagnostics,
)

# ── sample PyPI responses for consistent test data ──────────────────────
Expand Down Expand Up @@ -293,6 +294,7 @@ def patched_run(*args, **kwargs):
[sys.executable, "-m", "pip", "install", "clang-format==20.1.7"],
capture_output=True,
text=True,
check=False,
)


Expand Down