diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 605ffd2..84979c9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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 diff --git a/cpp_linter_hooks/clang_format.py b/cpp_linter_hooks/clang_format.py index ab74086..b7705ba 100644 --- a/cpp_linter_hooks/clang_format.py +++ b/cpp_linter_hooks/clang_format.py @@ -3,11 +3,9 @@ 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( @@ -15,7 +13,7 @@ ) -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( @@ -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 diff --git a/cpp_linter_hooks/clang_tidy.py b/cpp_linter_hooks/clang_tidy.py index a013048..5b1c8f7 100644 --- a/cpp_linter_hooks/clang_tidy.py +++ b/cpp_linter_hooks/clang_tidy.py @@ -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 @@ -63,7 +62,7 @@ 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(): @@ -71,7 +70,7 @@ def _find_compile_commands() -> Optional[str]: 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" + ( @@ -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. @@ -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: @@ -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 = ( @@ -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 @@ -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]) @@ -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( diff --git a/cpp_linter_hooks/util.py b/cpp_linter_hooks/util.py index 492c620..bf41dfd 100644 --- a/cpp_linter_hooks/util.py +++ b/cpp_linter_hooks/util.py @@ -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 @@ -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, [] @@ -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 @@ -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: @@ -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 @@ -117,7 +121,11 @@ 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 @@ -125,23 +133,26 @@ def _detect_installed_version(tool: str) -> Optional[str]: 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) @@ -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 @@ -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: diff --git a/tests/test_clang_format.py b/tests/test_clang_format.py index 215f60b..00c52ac 100644 --- a/tests/test_clang_format.py +++ b/tests/test_clang_format.py @@ -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 diff --git a/tests/test_clang_tidy.py b/tests/test_clang_tidy.py index 3fa5a2f..f112db2 100644 --- a/tests/test_clang_tidy.py +++ b/tests/test_clang_tidy.py @@ -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 diff --git a/tests/test_integration.py b/tests/test_integration.py index 6ceb49c..66c2bba 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -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 @@ -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", diff --git a/tests/test_util.py b/tests/test_util.py index 82b7f53..1497512 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -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 ────────────────────── @@ -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, )