From f345167351b9a6b02a9529eae034e802c94b4f8d Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Sat, 5 Jul 2025 21:50:30 +0300 Subject: [PATCH 01/13] Migrate from clang-tools binaries to wheels --- .gitignore | 2 +- MIGRATION.md | 111 +++++++++++++++++++++++++++++++ README.md | 5 +- cpp_linter_hooks/clang_format.py | 4 +- cpp_linter_hooks/clang_tidy.py | 4 +- cpp_linter_hooks/util.py | 73 +++++++++++--------- pyproject.toml | 5 +- testing/main.c | 6 +- tests/test_util.py | 81 ++++++++++++++-------- 9 files changed, 223 insertions(+), 68 deletions(-) create mode 100644 MIGRATION.md diff --git a/.gitignore b/.gitignore index c7a7c37..ab1c8ff 100644 --- a/.gitignore +++ b/.gitignore @@ -13,7 +13,7 @@ result.txt testing/main.c */*compile_commands.json -# Ignore clang-tools binaries +# Ignore Python wheel packages (clang-format, clang-tidy) clang-tidy-1* clang-tidy-2* clang-format-1* diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000..4f4a176 --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,111 @@ +# Migration Guide: From clang-tools to Python Wheels + +## Overview + +Starting from version 0.9.0, `cpp-linter-hooks` has migrated from using the `clang-tools` package to using Python wheel packages for `clang-format` and `clang-tidy`. This change provides better cross-platform compatibility, easier installation, and more reliable dependency management. + +## What Changed + +### Dependencies +- **Before**: Used `clang-tools==0.15.1` package +- **After**: Uses `clang-format` and `clang-tidy` wheel packages from PyPI + +### Installation Method +- **Before**: clang-format and clang-tidy were installed via `clang-tools` package which managed binaries +- **After**: clang-format and clang-tidy are installed as Python packages and available as executables + +### Benefits of Migration + +1. **Better Cross-Platform Support**: Python wheels work consistently across different operating systems +2. **Simplified Installation**: No need to manage binary installations separately +3. **More Reliable**: No more issues with binary compatibility or single threaded execution +4. **Better Version Management**: Each tool version is a separate package release + +## Breaking Changes + +### For End Users + +- **No breaking changes**: The pre-commit hook configuration remains exactly the same +- All existing `.pre-commit-config.yaml` files will continue to work without modification + +### For Developers +- The internal `ensure_installed()` function now returns the tool name instead of a Path object +- The `util.py` module has been rewritten to use `shutil.which()` instead of `clang_tools.install` +- Tests have been updated to mock the new wheel-based installation + +## Migration Steps + +### For End Users +No action required! Your existing configuration will continue to work. + +### For Developers/Contributors +1. Update your development environment: + ```bash + pip install clang-format clang-tidy + ``` + +2. If you were importing from the utility module: + ```python + # Before + from cpp_linter_hooks.util import ensure_installed + path = ensure_installed("clang-format", "18") + command = [str(path), "--version"] + + # After + from cpp_linter_hooks.util import ensure_installed + tool_name = ensure_installed("clang-format", "18") + command = [tool_name, "--version"] + ``` + +## Version Support + +The wheel packages support the same LLVM versions as before: +- LLVM 16, 17, 18, 19, 20+ +- The `--version` argument continues to work as expected + +## Troubleshooting + +### Tool Not Found Error +If you encounter "command not found" errors: + +1. Ensure the wheel packages are installed: + ```bash + pip install clang-format clang-tidy + ``` + +2. Verify the tools are available: + ```bash + clang-format --version + clang-tidy --version + ``` + +3. Check that the tools are in your PATH: + ```bash + which clang-format + which clang-tidy + ``` + +### Version Mismatch +If you need a specific version, you can install it explicitly: +```bash +pip install clang-format==18.1.8 +pip install clang-tidy==18.1.8 +``` + +## Support + +If you encounter any issues after the migration, please: +1. Check this migration guide +2. Search existing [issues](https://github.com/cpp-linter/cpp-linter-hooks/issues) +3. Create a new issue with: + - Your operating system + - Python version + - The exact error message + - Your `.pre-commit-config.yaml` configuration + +## References + +- [clang-format wheel package](https://github.com/ssciwr/clang-format-wheel) +- [clang-tidy wheel package](https://github.com/ssciwr/clang-tidy-wheel) +- [PyPI clang-format](https://pypi.org/project/clang-format/) +- [PyPI clang-tidy](https://pypi.org/project/clang-tidy/) diff --git a/README.md b/README.md index 3be3194..5690cff 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ repos: ### Custom Clang Tool Version -To use specific versions of [clang-tools](https://github.com/cpp-linter/clang-tools-pip?tab=readme-ov-file#supported-versions): +To use specific versions of clang-format and clang-tidy (using Python wheel packages): ```yaml repos: @@ -67,6 +67,9 @@ repos: args: [--checks=.clang-tidy, --version=18] # Specifies version ``` +> [!NOTE] +> Starting from version 0.9.0, this package uses Python wheel packages ([clang-format](https://pypi.org/project/clang-format/) and [clang-tidy](https://pypi.org/project/clang-tidy/)) instead of the previous clang-tools binaries. The wheel packages provide better cross-platform compatibility and easier installation. + ## Output ### clang-format Output diff --git a/cpp_linter_hooks/clang_format.py b/cpp_linter_hooks/clang_format.py index de3319f..9824582 100644 --- a/cpp_linter_hooks/clang_format.py +++ b/cpp_linter_hooks/clang_format.py @@ -15,8 +15,8 @@ def run_clang_format(args=None) -> Tuple[int, str]: hook_args, other_args = parser.parse_known_args(args) - path = ensure_installed("clang-format", hook_args.version) - command = [str(path), "-i"] + tool_name = ensure_installed("clang-format", hook_args.version) + command = [tool_name, "-i"] # Add verbose flag if requested if hook_args.verbose: diff --git a/cpp_linter_hooks/clang_tidy.py b/cpp_linter_hooks/clang_tidy.py index 2a51334..1462357 100644 --- a/cpp_linter_hooks/clang_tidy.py +++ b/cpp_linter_hooks/clang_tidy.py @@ -11,8 +11,8 @@ def run_clang_tidy(args=None) -> Tuple[int, str]: hook_args, other_args = parser.parse_known_args(args) - path = ensure_installed("clang-tidy", hook_args.version) - command = [str(path)] + tool_name = ensure_installed("clang-tidy", hook_args.version) + command = [tool_name] command.extend(other_args) retval = 0 diff --git a/cpp_linter_hooks/util.py b/cpp_linter_hooks/util.py index 0907dac..2c32eeb 100644 --- a/cpp_linter_hooks/util.py +++ b/cpp_linter_hooks/util.py @@ -1,51 +1,64 @@ import sys +import shutil from pathlib import Path import logging from typing import Optional -from clang_tools.util import Version -from clang_tools.install import is_installed as _is_installed, install_tool - - LOG = logging.getLogger(__name__) +DEFAULT_CLANG_VERSION = "20" # Default version for clang tools, can be overridden -DEFAULT_CLANG_VERSION = "18" # Default version for clang tools, can be overridden - -def is_installed(tool_name: str, version: str) -> Optional[Path]: +def is_installed(tool_name: str, version: str = "") -> Optional[Path]: """Check if tool is installed. - Checks the current python prefix and PATH via clang_tools.install.is_installed. + With wheel packages, the tools are installed as regular Python packages + and available via shutil.which(). """ - # check in current python prefix (usual situation when we installed into pre-commit venv) - directory = Path(sys.executable).parent - path = directory / f"{tool_name}-{version}" - if path.is_file(): - return path - - # parse the user-input version as a string - parsed_ver = Version(version) - # also check using clang_tools - path = _is_installed(tool_name, parsed_ver) - if path is not None: - return Path(path) + # Check if tool is available in PATH + tool_path = shutil.which(tool_name) + if tool_path is not None: + return Path(tool_path) + + # Check if tool is available in current Python environment + if sys.executable: + python_dir = Path(sys.executable).parent + tool_path = python_dir / tool_name + if tool_path.is_file(): + return tool_path + + # Also check Scripts directory on Windows + scripts_dir = python_dir / "Scripts" + if scripts_dir.exists(): + tool_path = scripts_dir / tool_name + if tool_path.is_file(): + return tool_path + # Try with .exe extension on Windows + tool_path = scripts_dir / f"{tool_name}.exe" + if tool_path.is_file(): + return tool_path - # not found return None -def ensure_installed(tool_name: str, version: str = DEFAULT_CLANG_VERSION) -> Path: +def ensure_installed(tool_name: str, version: str = "") -> str: """ - Ensure tool is available at given version. + Ensure tool is available. With wheel packages, we assume the tools are + installed as dependencies and available in PATH. + + Returns the tool name (not path) since the wheel packages install the tools + as executables that can be called directly. """ - LOG.info("Checking for %s, version %s", tool_name, version) + LOG.info("Checking for %s", tool_name) path = is_installed(tool_name, version) if path is not None: - LOG.info("%s, version %s is already installed", tool_name, version) - return path + LOG.info("%s is available at %s", tool_name, path) + return tool_name # Return tool name for direct execution - LOG.info("Installing %s, version %s", tool_name, version) - directory = Path(sys.executable).parent - install_tool(tool_name, version, directory=str(directory), no_progress_bar=True) - return directory / f"{tool_name}-{version}" + # If not found, we'll still return the tool name and let subprocess handle the error + LOG.warning( + "%s not found in PATH. Make sure the %s wheel package is installed.", + tool_name, + tool_name, + ) + return tool_name diff --git a/pyproject.toml b/pyproject.toml index 014ca91..72106c4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ requires-python = ">=3.9" [project] name = "cpp_linter_hooks" -description = "Automatically formats and lints C/C++ code using clang-format and clang-tidy" +description = "Automatically formats and lints C/C++ code using clang-format and clang-tidy Python wheels" readme = "README.md" keywords = ["clang", "clang-format", "clang-tidy", "pre-commit", "pre-commit-hooks"] license = "MIT" @@ -32,7 +32,8 @@ classifiers = [ "Topic :: Software Development :: Build Tools", ] dependencies = [ - "clang-tools==0.15.1", + "clang-format", + "clang-tidy", ] dynamic = ["version"] diff --git a/testing/main.c b/testing/main.c index 6b1a169..d907942 100644 --- a/testing/main.c +++ b/testing/main.c @@ -1,2 +1,6 @@ #include -int main() {for (;;) break; printf("Hello world!\n");return 0;} +int main() { + for (;;) break; + printf("Hello world!\n"); + return 0; +} diff --git a/tests/test_util.py b/tests/test_util.py index 842ccaa..7b8aef3 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -2,8 +2,9 @@ import sys import pytest from itertools import product +from unittest.mock import patch -from cpp_linter_hooks.util import ensure_installed, DEFAULT_CLANG_VERSION +from cpp_linter_hooks.util import ensure_installed, is_installed VERSIONS = [None, "18"] @@ -13,38 +14,60 @@ @pytest.mark.benchmark @pytest.mark.parametrize(("tool", "version"), list(product(TOOLS, VERSIONS))) def test_ensure_installed(tool, version, tmp_path, monkeypatch, caplog): - bin_path = tmp_path / "bin" + """Test that ensure_installed returns the tool name for wheel packages.""" with monkeypatch.context() as m: - m.setattr(sys, "executable", str(bin_path / "python")) + m.setattr(sys, "executable", str(tmp_path / "bin" / "python")) - for run in range(2): - # clear any existing log messages + # Mock shutil.which to simulate the tool being available + with patch("shutil.which", return_value=str(tmp_path / tool)): caplog.clear() caplog.set_level(logging.INFO, logger="cpp_linter_hooks.util") if version is None: - ensure_installed(tool) + result = ensure_installed(tool) else: - ensure_installed(tool, version=version) - - bin_version = version or DEFAULT_CLANG_VERSION - assert (bin_path / f"{tool}-{bin_version}").is_file - - # first run should install - assert ( - caplog.record_tuples[0][2] - == f"Checking for {tool}, version {bin_version}" - ) - if run == 0: - # FIXME - # assert caplog.record_tuples[1][2] == f"Installing {tool}, version {bin_version}" - assert ( - caplog.record_tuples[1][2] - == f"{tool}, version {bin_version} is already installed" - ) - # second run should just confirm it's already installed - else: - assert ( - caplog.record_tuples[1][2] - == f"{tool}, version {bin_version} is already installed" - ) + result = ensure_installed(tool, version=version) + + # Should return the tool name for direct execution + assert result == tool + + # Check that we logged checking for the tool + assert any("Checking for" in record.message for record in caplog.records) + + +def test_is_installed_with_shutil_which(tmp_path): + """Test is_installed when tool is found via shutil.which.""" + tool_path = tmp_path / "clang-format" + tool_path.touch() + + with patch("shutil.which", return_value=str(tool_path)): + result = is_installed("clang-format") + assert result == tool_path + + +def test_is_installed_not_found(): + """Test is_installed when tool is not found anywhere.""" + with ( + patch("shutil.which", return_value=None), + patch("sys.executable", "/nonexistent/python"), + ): + result = is_installed("clang-format") + assert result is None + + +def test_ensure_installed_tool_not_found(caplog): + """Test ensure_installed when tool is not found.""" + with ( + patch("shutil.which", return_value=None), + patch("sys.executable", "/nonexistent/python"), + ): + caplog.clear() + caplog.set_level(logging.WARNING, logger="cpp_linter_hooks.util") + + result = ensure_installed("clang-format") + + # Should still return the tool name + assert result == "clang-format" + + # Should log a warning + assert any("not found in PATH" in record.message for record in caplog.records) From 7e13841ded53f605673f5c923af6dc370e944398 Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Sat, 5 Jul 2025 21:56:19 +0300 Subject: [PATCH 02/13] allow run in parallel and pin clang-tools version --- .pre-commit-hooks.yaml | 4 ++-- pyproject.toml | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml index 741827b..4e30e38 100644 --- a/.pre-commit-hooks.yaml +++ b/.pre-commit-hooks.yaml @@ -4,7 +4,7 @@ entry: clang-format-hook language: python files: \.(h\+\+|h|hh|hxx|hpp|c|cc|cpp|c\+\+|cxx)$ - require_serial: true + require_serial: false - id: clang-tidy name: clang-tidy @@ -12,4 +12,4 @@ entry: clang-tidy-hook language: python files: \.(h\+\+|h|hh|hxx|hpp|c|cc|cpp|c\+\+|cxx)$ - require_serial: true + require_serial: false diff --git a/pyproject.toml b/pyproject.toml index 72106c4..1c9f4f0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ requires-python = ">=3.9" [project] name = "cpp_linter_hooks" -description = "Automatically formats and lints C/C++ code using clang-format and clang-tidy Python wheels" +description = "Automatically formats and lints C/C++ code using clang-format and clang-tidy" readme = "README.md" keywords = ["clang", "clang-format", "clang-tidy", "pre-commit", "pre-commit-hooks"] license = "MIT" @@ -32,8 +32,8 @@ classifiers = [ "Topic :: Software Development :: Build Tools", ] dependencies = [ - "clang-format", - "clang-tidy", + "clang-format==20.1.7", + "clang-tidy==20.1.0", ] dynamic = ["version"] From 9579df688b1376464ae5a115ecf0ed55f31e0429 Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Sat, 5 Jul 2025 21:58:10 +0300 Subject: [PATCH 03/13] update run.sh --- testing/run.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testing/run.sh b/testing/run.sh index b176b06..3407656 100644 --- a/testing/run.sh +++ b/testing/run.sh @@ -29,7 +29,7 @@ failed_cases=`grep -c "Failed" result.txt` echo $failed_cases " cases failed." -if [ $failed_cases -eq 10 ]; then +if [ $failed_cases -eq 9 ]; then echo "==============================" echo "Test cpp-linter-hooks success." echo "==============================" From c03ae0f851dae9a621529497c16fc98e33006850 Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Sat, 5 Jul 2025 23:51:54 +0300 Subject: [PATCH 04/13] fix utils.py tests and others --- MIGRATION.md | 2 +- cpp_linter_hooks/clang_format.py | 4 +- cpp_linter_hooks/clang_tidy.py | 4 +- cpp_linter_hooks/util.py | 240 ++++++++++++++++++++++++------- pyproject.toml | 1 + tests/test_util.py | 37 ++--- 6 files changed, 215 insertions(+), 73 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index 4f4a176..e34a613 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -50,7 +50,7 @@ No action required! Your existing configuration will continue to work. from cpp_linter_hooks.util import ensure_installed path = ensure_installed("clang-format", "18") command = [str(path), "--version"] - + # After from cpp_linter_hooks.util import ensure_installed tool_name = ensure_installed("clang-format", "18") diff --git a/cpp_linter_hooks/clang_format.py b/cpp_linter_hooks/clang_format.py index 9824582..ae732f6 100644 --- a/cpp_linter_hooks/clang_format.py +++ b/cpp_linter_hooks/clang_format.py @@ -3,11 +3,11 @@ from argparse import ArgumentParser from typing import Tuple -from .util import ensure_installed, DEFAULT_CLANG_VERSION +from .util import ensure_installed, DEFAULT_CLANG_FORMAT_VERSION parser = ArgumentParser() -parser.add_argument("--version", default=DEFAULT_CLANG_VERSION) +parser.add_argument("--version", default=DEFAULT_CLANG_FORMAT_VERSION) parser.add_argument( "-v", "--verbose", action="store_true", help="Enable verbose output" ) diff --git a/cpp_linter_hooks/clang_tidy.py b/cpp_linter_hooks/clang_tidy.py index 1462357..7f5dd5c 100644 --- a/cpp_linter_hooks/clang_tidy.py +++ b/cpp_linter_hooks/clang_tidy.py @@ -2,11 +2,11 @@ from argparse import ArgumentParser from typing import Tuple -from .util import ensure_installed, DEFAULT_CLANG_VERSION +from .util import ensure_installed, DEFAULT_CLANG_TIDY_VERSION parser = ArgumentParser() -parser.add_argument("--version", default=DEFAULT_CLANG_VERSION) +parser.add_argument("--version", default=DEFAULT_CLANG_TIDY_VERSION) def run_clang_tidy(args=None) -> Tuple[int, str]: diff --git a/cpp_linter_hooks/util.py b/cpp_linter_hooks/util.py index 2c32eeb..a4fe95e 100644 --- a/cpp_linter_hooks/util.py +++ b/cpp_linter_hooks/util.py @@ -1,64 +1,200 @@ import sys import shutil +import toml +import subprocess from pathlib import Path import logging -from typing import Optional +from typing import Optional, List +from packaging.version import Version, InvalidVersion LOG = logging.getLogger(__name__) -DEFAULT_CLANG_VERSION = "20" # Default version for clang tools, can be overridden - - -def is_installed(tool_name: str, version: str = "") -> Optional[Path]: - """Check if tool is installed. - - With wheel packages, the tools are installed as regular Python packages - and available via shutil.which(). - """ - # Check if tool is available in PATH - tool_path = shutil.which(tool_name) - if tool_path is not None: - return Path(tool_path) - - # Check if tool is available in current Python environment - if sys.executable: - python_dir = Path(sys.executable).parent - tool_path = python_dir / tool_name - if tool_path.is_file(): - return tool_path - - # Also check Scripts directory on Windows - scripts_dir = python_dir / "Scripts" - if scripts_dir.exists(): - tool_path = scripts_dir / tool_name - if tool_path.is_file(): - return tool_path - # Try with .exe extension on Windows - tool_path = scripts_dir / f"{tool_name}.exe" - if tool_path.is_file(): - return tool_path +def get_version_from_dependency(tool: str) -> Optional[str]: + pyproject_path = Path(__file__).parent.parent / "pyproject.toml" + if not pyproject_path.exists(): + return None + data = toml.load(pyproject_path) + dependencies = data.get("project", {}).get("dependencies", []) + for dep in dependencies: + if dep.startswith(f"{tool}=="): + return dep.split("==")[1] return None -def ensure_installed(tool_name: str, version: str = "") -> str: - """ - Ensure tool is available. With wheel packages, we assume the tools are - installed as dependencies and available in PATH. - - Returns the tool name (not path) since the wheel packages install the tools - as executables that can be called directly. - """ - LOG.info("Checking for %s", tool_name) - path = is_installed(tool_name, version) - if path is not None: - LOG.info("%s is available at %s", tool_name, path) - return tool_name # Return tool name for direct execution - - # If not found, we'll still return the tool name and let subprocess handle the error - LOG.warning( - "%s not found in PATH. Make sure the %s wheel package is installed.", - tool_name, - tool_name, +DEFAULT_CLANG_FORMAT_VERSION = get_version_from_dependency("clang-format") or "20.1.7" +DEFAULT_CLANG_TIDY_VERSION = get_version_from_dependency("clang-tidy") or "20.1.0" + + +CLANG_FORMAT_VERSIONS = [ + "6.0.1", + "7.1.0", + "8.0.1", + "9.0.0", + "10.0.1", + "10.0.1.1", + "11.0.1", + "11.0.1.1", + "11.0.1.2", + "11.1.0", + "11.1.0.1", + "11.1.0.2", + "12.0.1", + "12.0.1.1", + "12.0.1.2", + "13.0.0", + "13.0.1", + "13.0.1.1", + "14.0.0", + "14.0.1", + "14.0.3", + "14.0.4", + "14.0.5", + "14.0.6", + "15.0.4", + "15.0.6", + "15.0.7", + "16.0.0", + "16.0.1", + "16.0.2", + "16.0.3", + "16.0.4", + "16.0.5", + "16.0.6", + "17.0.1", + "17.0.2", + "17.0.3", + "17.0.4", + "17.0.5", + "17.0.6", + "18.1.0", + "18.1.1", + "18.1.2", + "18.1.3", + "18.1.4", + "18.1.5", + "18.1.6", + "18.1.7", + "18.1.8", + "19.1.0", + "19.1.1", + "19.1.2", + "19.1.3", + "19.1.4", + "19.1.5", + "19.1.6", + "19.1.7", + "20.1.0", + "20.1.3", + "20.1.4", + "20.1.5", + "20.1.6", + "20.1.7", +] + +CLANG_TIDY_VERSIONS = [ + "13.0.1.1", + "14.0.6", + "15.0.2", + "15.0.2.1", + "16.0.4", + "17.0.1", + "18.1.1", + "18.1.8", + "19.1.0", + "19.1.0.1", + "20.1.0", +] + + +def _resolve_version(versions: List[str], user_input: Optional[str]) -> Optional[str]: + if user_input is None: + return None + try: + user_ver = Version(user_input) + except InvalidVersion: + return None + + candidates = [Version(v) for v in versions] + if user_input.count(".") == 0: + matches = [v for v in candidates if v.major == user_ver.major] + elif user_input.count(".") == 1: + matches = [ + v + for v in candidates + if f"{v.major}.{v.minor}" == f"{user_ver.major}.{user_ver.minor}" + ] + else: + return str(user_ver) if user_ver in candidates else None + + return str(max(matches)) if matches else None + + +def _get_runtime_version(tool: str) -> Optional[str]: + try: + output = subprocess.check_output([tool, "--version"], text=True) + if tool == "clang-tidy": + lines = output.strip().splitlines() + if len(lines) > 1: + return lines[1].split()[-1] + elif tool == "clang-format": + return output.strip().split()[-1] + except Exception: + return None + + +def _install_tool(tool: str, version: str) -> Optional[Path]: + try: + subprocess.check_call( + [sys.executable, "-m", "pip", "install", f"{tool}=={version}"] + ) + return shutil.which(tool) + except subprocess.CalledProcessError: + LOG.error("Failed to install %s==%s", tool, version) + return None + + +def _resolve_install(tool: str, version: Optional[str]) -> Optional[Path]: + user_version = _resolve_version( + CLANG_FORMAT_VERSIONS if tool == "clang-format" else CLANG_TIDY_VERSIONS, + version, ) - return tool_name + if user_version is None: + user_version = ( + DEFAULT_CLANG_FORMAT_VERSION + if tool == "clang-format" + else DEFAULT_CLANG_TIDY_VERSION + ) + + path = shutil.which(tool) + if path: + runtime_version = _get_runtime_version(tool) + if runtime_version and user_version not in runtime_version: + LOG.info( + "%s version mismatch (%s != %s), reinstalling...", + tool, + runtime_version, + user_version, + ) + return _install_tool(tool, user_version) + return Path(path) + + return _install_tool(tool, user_version) + + +def is_installed(tool: str) -> Optional[Path]: + """Check if a tool is installed and return its path.""" + path = shutil.which(tool) + if path: + return Path(path) + return None + + +def ensure_installed(tool: str, version: Optional[str] = None) -> str: + LOG.info("Ensuring %s is installed", tool) + tool_path = _resolve_install(tool, version) + if tool_path: + LOG.info("%s available at %s", tool, tool_path) + return tool + LOG.warning("%s not found and could not be installed", tool) + return tool diff --git a/pyproject.toml b/pyproject.toml index 1c9f4f0..1bf713b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,7 @@ classifiers = [ dependencies = [ "clang-format==20.1.7", "clang-tidy==20.1.0", + "toml>=0.10.2", ] dynamic = ["version"] diff --git a/tests/test_util.py b/tests/test_util.py index 7b8aef3..3ccd1f1 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -1,5 +1,4 @@ import logging -import sys import pytest from itertools import product from unittest.mock import patch @@ -15,24 +14,27 @@ @pytest.mark.parametrize(("tool", "version"), list(product(TOOLS, VERSIONS))) def test_ensure_installed(tool, version, tmp_path, monkeypatch, caplog): """Test that ensure_installed returns the tool name for wheel packages.""" - with monkeypatch.context() as m: - m.setattr(sys, "executable", str(tmp_path / "bin" / "python")) - + with monkeypatch.context(): # Mock shutil.which to simulate the tool being available with patch("shutil.which", return_value=str(tmp_path / tool)): - caplog.clear() - caplog.set_level(logging.INFO, logger="cpp_linter_hooks.util") + # Mock _get_runtime_version to return a matching version + mock_version = "20.1.7" if tool == "clang-format" else "20.1.0" + with patch( + "cpp_linter_hooks.util._get_runtime_version", return_value=mock_version + ): + caplog.clear() + caplog.set_level(logging.INFO, logger="cpp_linter_hooks.util") - if version is None: - result = ensure_installed(tool) - else: - result = ensure_installed(tool, version=version) + if version is None: + result = ensure_installed(tool) + else: + result = ensure_installed(tool, version=version) - # Should return the tool name for direct execution - assert result == tool + # Should return the tool name for direct execution + assert result == tool - # Check that we logged checking for the tool - assert any("Checking for" in record.message for record in caplog.records) + # Check that we logged ensuring the tool is installed + assert any("Ensuring" in record.message for record in caplog.records) def test_is_installed_with_shutil_which(tmp_path): @@ -59,7 +61,7 @@ def test_ensure_installed_tool_not_found(caplog): """Test ensure_installed when tool is not found.""" with ( patch("shutil.which", return_value=None), - patch("sys.executable", "/nonexistent/python"), + patch("cpp_linter_hooks.util._install_tool", return_value=None), ): caplog.clear() caplog.set_level(logging.WARNING, logger="cpp_linter_hooks.util") @@ -70,4 +72,7 @@ def test_ensure_installed_tool_not_found(caplog): assert result == "clang-format" # Should log a warning - assert any("not found in PATH" in record.message for record in caplog.records) + assert any( + "not found and could not be installed" in record.message + for record in caplog.records + ) From b67718a8e63684081a369822255f5160fdc99038 Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Sat, 5 Jul 2025 23:54:46 +0300 Subject: [PATCH 05/13] fix: add mising package --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 1bf713b..9ad8181 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,7 @@ dependencies = [ "clang-format==20.1.7", "clang-tidy==20.1.0", "toml>=0.10.2", + "packaging>=20.0", ] dynamic = ["version"] From 99f4501287a468adce5da5bcd0f27e31535e3fcf Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Sun, 6 Jul 2025 00:04:45 +0300 Subject: [PATCH 06/13] revert mian.c change --- testing/main.c | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/testing/main.c b/testing/main.c index d907942..9ba0049 100644 --- a/testing/main.c +++ b/testing/main.c @@ -1,6 +1,2 @@ #include -int main() { - for (;;) break; - printf("Hello world!\n"); - return 0; -} +int main() {for (;;) break; printf("Hello world!\n");return 0;} \ No newline at end of file From fbb7eb9d7623c8c08a393f36e455881ceef706ff Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Sun, 6 Jul 2025 00:06:14 +0300 Subject: [PATCH 07/13] revert mian.c change --- testing/main.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testing/main.c b/testing/main.c index 9ba0049..6b1a169 100644 --- a/testing/main.c +++ b/testing/main.c @@ -1,2 +1,2 @@ #include -int main() {for (;;) break; printf("Hello world!\n");return 0;} \ No newline at end of file +int main() {for (;;) break; printf("Hello world!\n");return 0;} From d1ee4f5ed5353e955912bc200feeb0ef87a97a46 Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Sun, 6 Jul 2025 00:32:45 +0300 Subject: [PATCH 08/13] docs: update docs --- MIGRATION.md | 146 +++++++++++++++++++++++++-------------------------- README.md | 14 ++--- 2 files changed, 79 insertions(+), 81 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index e34a613..f1e9753 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1,111 +1,109 @@ -# Migration Guide: From clang-tools to Python Wheels +# Migration: From Clang Tools Binaries to Python Wheels ## Overview -Starting from version 0.9.0, `cpp-linter-hooks` has migrated from using the `clang-tools` package to using Python wheel packages for `clang-format` and `clang-tidy`. This change provides better cross-platform compatibility, easier installation, and more reliable dependency management. +Starting from version **v1.0.0**, `cpp-linter-hooks` has migrated from using the `clang-tools` package to using Python wheel packages for `clang-format` and `clang-tidy`. This change provides: + +- **Better cross-platform compatibility** +- **Easier installation and dependency management** +- **Improved performance and reliability** +- **More predictable version management** ## What Changed -### Dependencies -- **Before**: Used `clang-tools==0.15.1` package -- **After**: Uses `clang-format` and `clang-tidy` wheel packages from PyPI +### Core Changes -### Installation Method -- **Before**: clang-format and clang-tidy were installed via `clang-tools` package which managed binaries -- **After**: clang-format and clang-tidy are installed as Python packages and available as executables +| Aspect | Before (< v1.0.0) | After (≥ v1.0.0) | +|--------|-------------------|-------------------| +| **Installation** | `clang-tools` package (binary management) | Python wheel packages (`clang-format`, `clang-tidy`) | +| **Distribution** | Single package for both tools | Separate packages for each tool | +| **Version Control** | Limited version flexibility | Enhanced version management with pip | +| **Performance** | Standard performance | Optimized wheel packages | -### Benefits of Migration +### Implementation Details -1. **Better Cross-Platform Support**: Python wheels work consistently across different operating systems -2. **Simplified Installation**: No need to manage binary installations separately -3. **More Reliable**: No more issues with binary compatibility or single threaded execution -4. **Better Version Management**: Each tool version is a separate package release +- **Dependencies**: Updated to use separate `clang-format==20.1.7` and `clang-tidy==20.1.0` Python wheels +- **Installation Logic**: Enhanced with pip-based installation and runtime version checks +- **Performance**: Pre-commit hooks can now run in parallel for better speed ## Breaking Changes ### For End Users -- **No breaking changes**: The pre-commit hook configuration remains exactly the same -- All existing `.pre-commit-config.yaml` files will continue to work without modification +> **No breaking changes for end users** -### For Developers -- The internal `ensure_installed()` function now returns the tool name instead of a Path object -- The `util.py` module has been rewritten to use `shutil.which()` instead of `clang_tools.install` -- Tests have been updated to mock the new wheel-based installation +- Your existing `.pre-commit-config.yaml` files will continue to work without modification +- All hook configurations remain backward compatible +- No changes required to your workflow ## Migration Steps ### For End Users -No action required! Your existing configuration will continue to work. - -### For Developers/Contributors -1. Update your development environment: - ```bash - pip install clang-format clang-tidy - ``` -2. If you were importing from the utility module: - ```python - # Before - from cpp_linter_hooks.util import ensure_installed - path = ensure_installed("clang-format", "18") - command = [str(path), "--version"] +**No action required!** Your existing configuration will continue to work seamlessly. - # After - from cpp_linter_hooks.util import ensure_installed - tool_name = ensure_installed("clang-format", "18") - command = [tool_name, "--version"] - ``` +However, we recommend updating to the latest version for: +- Better performance +- Enhanced reliability +- Latest features and bug fixes -## Version Support +#### Example Configuration (No Changes Needed) -The wheel packages support the same LLVM versions as before: -- LLVM 16, 17, 18, 19, 20+ -- The `--version` argument continues to work as expected +```yaml +repos: + - repo: https://github.com/cpp-linter/cpp-linter-hooks + rev: v1.0.0 # Use the latest version + hooks: + - id: clang-format + args: [--style=Google] + - id: clang-tidy + args: [--checks=-*,readability-*] +``` ## Troubleshooting -### Tool Not Found Error -If you encounter "command not found" errors: - -1. Ensure the wheel packages are installed: - ```bash - pip install clang-format clang-tidy - ``` +### Common Issues -2. Verify the tools are available: - ```bash - clang-format --version - clang-tidy --version - ``` - -3. Check that the tools are in your PATH: - ```bash - which clang-format - which clang-tidy - ``` - -### Version Mismatch -If you need a specific version, you can install it explicitly: +#### Issue: Tool not found after migration +**Solution**: Clear your pre-commit cache: ```bash -pip install clang-format==18.1.8 -pip install clang-tidy==18.1.8 +pre-commit clean +pre-commit install +``` + +#### Issue: Version mismatch errors +**Solution**: Ensure you're using the latest version of `cpp-linter-hooks`: +```yaml +rev: v1.0.0 # Update to latest version ``` ## Support -If you encounter any issues after the migration, please: -1. Check this migration guide -2. Search existing [issues](https://github.com/cpp-linter/cpp-linter-hooks/issues) -3. Create a new issue with: - - Your operating system +If you encounter issues after migration: + +1. **Check this guide**: Review the troubleshooting section above +2. **Search existing issues**: [GitHub Issues](https://github.com/cpp-linter/cpp-linter-hooks/issues) +3. **Report new issues**: Include the following information: + - Operating system and version - Python version - - The exact error message + - `cpp-linter-hooks` version + - Complete error message/stack trace - Your `.pre-commit-config.yaml` configuration ## References -- [clang-format wheel package](https://github.com/ssciwr/clang-format-wheel) -- [clang-tidy wheel package](https://github.com/ssciwr/clang-tidy-wheel) -- [PyPI clang-format](https://pypi.org/project/clang-format/) -- [PyPI clang-tidy](https://pypi.org/project/clang-tidy/) +### Official Packages +- [clang-format Python wheel](https://pypi.org/project/clang-format/) - PyPI package +- [clang-tidy Python wheel](https://pypi.org/project/clang-tidy/) - PyPI package + +### Source Repositories +- [clang-format wheel source](https://github.com/ssciwr/clang-format-wheel) - GitHub repository +- [clang-tidy wheel source](https://github.com/ssciwr/clang-tidy-wheel) - GitHub repository + +### Documentation +- [cpp-linter-hooks Documentation](https://github.com/cpp-linter/cpp-linter-hooks) - Main repository +- [Pre-commit Framework](https://pre-commit.com/) - Pre-commit documentation + +--- + +**Happy linting!** diff --git a/README.md b/README.md index 5690cff..e110d0c 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ Add this configuration to your `.pre-commit-config.yaml` file: ```yaml repos: - repo: https://github.com/cpp-linter/cpp-linter-hooks - rev: v0.8.3 # Use the tag or commit you want + rev: v1.0.0 # Use the tag or commit you want hooks: - id: clang-format args: [--style=Google] # Other coding style: LLVM, GNU, Chromium, Microsoft, Mozilla, WebKit. @@ -44,7 +44,7 @@ To use custom configurations like `.clang-format` and `.clang-tidy`: ```yaml repos: - repo: https://github.com/cpp-linter/cpp-linter-hooks - rev: v0.8.3 + rev: v1.0.0 hooks: - id: clang-format args: [--style=file] # Loads style from .clang-format file @@ -59,7 +59,7 @@ To use specific versions of clang-format and clang-tidy (using Python wheel pack ```yaml repos: - repo: https://github.com/cpp-linter/cpp-linter-hooks - rev: v0.8.3 + rev: v1.0.0 hooks: - id: clang-format args: [--style=file, --version=18] # Specifies version @@ -68,7 +68,7 @@ repos: ``` > [!NOTE] -> Starting from version 0.9.0, this package uses Python wheel packages ([clang-format](https://pypi.org/project/clang-format/) and [clang-tidy](https://pypi.org/project/clang-tidy/)) instead of the previous clang-tools binaries. The wheel packages provide better cross-platform compatibility and easier installation. +> Starting from version v1.0.0, this package uses Python wheel packages ([clang-format](https://pypi.org/project/clang-format/) and [clang-tidy](https://pypi.org/project/clang-tidy/)) instead of the previous clang-tools binaries. The wheel packages provide better cross-platform compatibility and easier installation. For more details, see the [Migration Guide](MIGRATION.md). ## Output @@ -144,12 +144,12 @@ Use -header-filter=.* to display errors from all non-system headers. Use -system ### Performance Optimization -> [!WARNING] +> [!TIP] > If your `pre-commit` runs longer than expected, it is highly recommended to add `files` in `.pre-commit-config.yaml` to limit the scope of the hook. This helps improve performance by reducing the number of files being checked and avoids unnecessary processing. Here's an example configuration: ```yaml - repo: https://github.com/cpp-linter/cpp-linter-hooks - rev: v0.8.3 + rev: v1.0.0 hooks: - id: clang-format args: [--style=file, --version=18] @@ -175,7 +175,7 @@ This approach ensures that only modified files are checked, further speeding up ```yaml repos: - repo: https://github.com/cpp-linter/cpp-linter-hooks - rev: v0.8.3 + rev: v1.0.0 hooks: - id: clang-format args: [--style=file, --version=18, --verbose] # Add -v or --verbose for detailed output From 4dcaef10e5f73020f21ca2cd9609fa6243041f52 Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Sun, 6 Jul 2025 00:37:51 +0300 Subject: [PATCH 09/13] add @pytest.mark.benchmark to test --- tests/test_util.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_util.py b/tests/test_util.py index 3ccd1f1..3c5afae 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -6,7 +6,7 @@ from cpp_linter_hooks.util import ensure_installed, is_installed -VERSIONS = [None, "18"] +VERSIONS = [None, "20"] TOOLS = ["clang-format", "clang-tidy"] @@ -37,6 +37,7 @@ def test_ensure_installed(tool, version, tmp_path, monkeypatch, caplog): assert any("Ensuring" in record.message for record in caplog.records) +@pytest.mark.benchmark def test_is_installed_with_shutil_which(tmp_path): """Test is_installed when tool is found via shutil.which.""" tool_path = tmp_path / "clang-format" @@ -47,6 +48,7 @@ def test_is_installed_with_shutil_which(tmp_path): assert result == tool_path +@pytest.mark.benchmark def test_is_installed_not_found(): """Test is_installed when tool is not found anywhere.""" with ( @@ -57,6 +59,7 @@ def test_is_installed_not_found(): assert result is None +@pytest.mark.benchmark def test_ensure_installed_tool_not_found(caplog): """Test ensure_installed when tool is not found.""" with ( From ebc62b62b131e026b515c7a65592701f8b8b5744 Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Sun, 6 Jul 2025 00:40:22 +0300 Subject: [PATCH 10/13] pre-commit autoupdate --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 325e6c1..8b5a3cb 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -14,7 +14,7 @@ repos: hooks: - id: pyupgrade - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.12.1 + rev: v0.12.2 hooks: - id: ruff - id: ruff-format From 6610e8da07dad120ce4e5fcb5e3c100efd239634 Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Sun, 6 Jul 2025 00:45:29 +0300 Subject: [PATCH 11/13] update docs --- MIGRATION.md | 41 +++-------------------------------------- 1 file changed, 3 insertions(+), 38 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index f1e9753..c1bb5ae 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -15,9 +15,8 @@ Starting from version **v1.0.0**, `cpp-linter-hooks` has migrated from using the | Aspect | Before (< v1.0.0) | After (≥ v1.0.0) | |--------|-------------------|-------------------| -| **Installation** | `clang-tools` package (binary management) | Python wheel packages (`clang-format`, `clang-tidy`) | -| **Distribution** | Single package for both tools | Separate packages for each tool | -| **Version Control** | Limited version flexibility | Enhanced version management with pip | +| **Installation** | Install from GitHub release | Install with from PyPI | +| **Distribution** | Binary packages | Python wheel packages | | **Performance** | Standard performance | Optimized wheel packages | ### Implementation Details @@ -28,9 +27,7 @@ Starting from version **v1.0.0**, `cpp-linter-hooks` has migrated from using the ## Breaking Changes -### For End Users - -> **No breaking changes for end users** +**No breaking changes for end users** - Your existing `.pre-commit-config.yaml` files will continue to work without modification - All hook configurations remain backward compatible @@ -38,8 +35,6 @@ Starting from version **v1.0.0**, `cpp-linter-hooks` has migrated from using the ## Migration Steps -### For End Users - **No action required!** Your existing configuration will continue to work seamlessly. However, we recommend updating to the latest version for: @@ -47,36 +42,6 @@ However, we recommend updating to the latest version for: - Enhanced reliability - Latest features and bug fixes -#### Example Configuration (No Changes Needed) - -```yaml -repos: - - repo: https://github.com/cpp-linter/cpp-linter-hooks - rev: v1.0.0 # Use the latest version - hooks: - - id: clang-format - args: [--style=Google] - - id: clang-tidy - args: [--checks=-*,readability-*] -``` - -## Troubleshooting - -### Common Issues - -#### Issue: Tool not found after migration -**Solution**: Clear your pre-commit cache: -```bash -pre-commit clean -pre-commit install -``` - -#### Issue: Version mismatch errors -**Solution**: Ensure you're using the latest version of `cpp-linter-hooks`: -```yaml -rev: v1.0.0 # Update to latest version -``` - ## Support If you encounter issues after migration: From 02670a3462bdd014c04c2f3ef9a001bab5a2965a Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Sun, 6 Jul 2025 00:47:20 +0300 Subject: [PATCH 12/13] update docs --- MIGRATION.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index c1bb5ae..95b5f94 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -7,7 +7,6 @@ Starting from version **v1.0.0**, `cpp-linter-hooks` has migrated from using the - **Better cross-platform compatibility** - **Easier installation and dependency management** - **Improved performance and reliability** -- **More predictable version management** ## What Changed @@ -17,7 +16,7 @@ Starting from version **v1.0.0**, `cpp-linter-hooks` has migrated from using the |--------|-------------------|-------------------| | **Installation** | Install from GitHub release | Install with from PyPI | | **Distribution** | Binary packages | Python wheel packages | -| **Performance** | Standard performance | Optimized wheel packages | +| **Performance** | Run serially | Run in parallel | ### Implementation Details From f34d1e5545675843f686fbf86619d523ef9a390b Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Sun, 6 Jul 2025 00:53:22 +0300 Subject: [PATCH 13/13] increase test coverage --- tests/test_util.py | 346 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 345 insertions(+), 1 deletion(-) diff --git a/tests/test_util.py b/tests/test_util.py index 3c5afae..dfeca88 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -2,8 +2,23 @@ import pytest from itertools import product from unittest.mock import patch +from pathlib import Path +import subprocess +import sys -from cpp_linter_hooks.util import ensure_installed, is_installed +from cpp_linter_hooks.util import ( + ensure_installed, + is_installed, + get_version_from_dependency, + _resolve_version, + _get_runtime_version, + _install_tool, + _resolve_install, + CLANG_FORMAT_VERSIONS, + CLANG_TIDY_VERSIONS, + DEFAULT_CLANG_FORMAT_VERSION, + DEFAULT_CLANG_TIDY_VERSION, +) VERSIONS = [None, "20"] @@ -79,3 +94,332 @@ def test_ensure_installed_tool_not_found(caplog): "not found and could not be installed" in record.message for record in caplog.records ) + + +# Tests for get_version_from_dependency +@pytest.mark.benchmark +def test_get_version_from_dependency_success(): + """Test get_version_from_dependency with valid pyproject.toml.""" + mock_toml_content = { + "project": { + "dependencies": [ + "clang-format==20.1.7", + "clang-tidy==20.1.0", + "other-package==1.0.0", + ] + } + } + + with ( + patch("pathlib.Path.exists", return_value=True), + patch("toml.load", return_value=mock_toml_content), + ): + result = get_version_from_dependency("clang-format") + assert result == "20.1.7" + + result = get_version_from_dependency("clang-tidy") + assert result == "20.1.0" + + +@pytest.mark.benchmark +def test_get_version_from_dependency_missing_file(): + """Test get_version_from_dependency when pyproject.toml doesn't exist.""" + with patch("pathlib.Path.exists", return_value=False): + result = get_version_from_dependency("clang-format") + assert result is None + + +@pytest.mark.benchmark +def test_get_version_from_dependency_missing_dependency(): + """Test get_version_from_dependency with missing dependency.""" + mock_toml_content = {"project": {"dependencies": ["other-package==1.0.0"]}} + + with ( + patch("pathlib.Path.exists", return_value=True), + patch("toml.load", return_value=mock_toml_content), + ): + result = get_version_from_dependency("clang-format") + assert result is None + + +@pytest.mark.benchmark +def test_get_version_from_dependency_malformed_toml(): + """Test get_version_from_dependency with malformed toml.""" + mock_toml_content = {} + + with ( + patch("pathlib.Path.exists", return_value=True), + patch("toml.load", return_value=mock_toml_content), + ): + result = get_version_from_dependency("clang-format") + assert result is None + + +# Tests for _resolve_version +@pytest.mark.benchmark +@pytest.mark.parametrize( + "user_input,expected", + [ + (None, None), + ("20", "20.1.7"), # Should find latest 20.x + ("20.1", "20.1.7"), # Should find latest 20.1.x + ("20.1.7", "20.1.7"), # Exact match + ("18", "18.1.8"), # Should find latest 18.x + ("18.1", "18.1.8"), # Should find latest 18.1.x + ("99", None), # Non-existent major version + ("20.99", None), # Non-existent minor version + ("invalid", None), # Invalid version string + ], +) +def test_resolve_version_clang_format(user_input, expected): + """Test _resolve_version with various inputs for clang-format.""" + result = _resolve_version(CLANG_FORMAT_VERSIONS, user_input) + assert result == expected + + +@pytest.mark.benchmark +@pytest.mark.parametrize( + "user_input,expected", + [ + (None, None), + ("20", "20.1.0"), # Should find latest 20.x + ("18", "18.1.8"), # Should find latest 18.x + ("19", "19.1.0.1"), # Should find latest 19.x + ("99", None), # Non-existent major version + ], +) +def test_resolve_version_clang_tidy(user_input, expected): + """Test _resolve_version with various inputs for clang-tidy.""" + result = _resolve_version(CLANG_TIDY_VERSIONS, user_input) + assert result == expected + + +# Tests for _get_runtime_version +@pytest.mark.benchmark +def test_get_runtime_version_clang_format(): + """Test _get_runtime_version for clang-format.""" + mock_output = "Ubuntu clang-format version 20.1.7-1ubuntu1\n" + + with patch("subprocess.check_output", return_value=mock_output): + result = _get_runtime_version("clang-format") + assert result == "20.1.7-1ubuntu1" + + +@pytest.mark.benchmark +def test_get_runtime_version_clang_tidy(): + """Test _get_runtime_version for clang-tidy.""" + mock_output = "LLVM (http://llvm.org/):\n LLVM version 20.1.0\n" + + with patch("subprocess.check_output", return_value=mock_output): + result = _get_runtime_version("clang-tidy") + assert result == "20.1.0" + + +@pytest.mark.benchmark +def test_get_runtime_version_exception(): + """Test _get_runtime_version when subprocess fails.""" + with patch( + "subprocess.check_output", + side_effect=subprocess.CalledProcessError(1, ["clang-format"]), + ): + result = _get_runtime_version("clang-format") + assert result is None + + +@pytest.mark.benchmark +def test_get_runtime_version_clang_tidy_single_line(): + """Test _get_runtime_version for clang-tidy with single line output.""" + mock_output = "LLVM version 20.1.0\n" + + with patch("subprocess.check_output", return_value=mock_output): + result = _get_runtime_version("clang-tidy") + assert result is None # Should return None for single line + + +# Tests for _install_tool +@pytest.mark.benchmark +def test_install_tool_success(): + """Test _install_tool successful installation.""" + mock_path = "/usr/bin/clang-format" + + with ( + patch("subprocess.check_call") as mock_check_call, + patch("shutil.which", return_value=mock_path), + ): + result = _install_tool("clang-format", "20.1.7") + assert result == mock_path + + mock_check_call.assert_called_once_with( + [sys.executable, "-m", "pip", "install", "clang-format==20.1.7"] + ) + + +@pytest.mark.benchmark +def test_install_tool_failure(): + """Test _install_tool when pip install fails.""" + with ( + patch( + "subprocess.check_call", + side_effect=subprocess.CalledProcessError(1, ["pip"]), + ), + patch("cpp_linter_hooks.util.LOG") as mock_log, + ): + result = _install_tool("clang-format", "20.1.7") + assert result is None + + mock_log.error.assert_called_once_with( + "Failed to install %s==%s", "clang-format", "20.1.7" + ) + + +@pytest.mark.benchmark +def test_install_tool_success_but_not_found(): + """Test _install_tool when install succeeds but tool not found in PATH.""" + with patch("subprocess.check_call"), patch("shutil.which", return_value=None): + result = _install_tool("clang-format", "20.1.7") + assert result is None + + +# Tests for _resolve_install +@pytest.mark.benchmark +def test_resolve_install_tool_already_installed_correct_version(): + """Test _resolve_install when tool is already installed with correct version.""" + mock_path = "/usr/bin/clang-format" + + with ( + patch("shutil.which", return_value=mock_path), + patch("cpp_linter_hooks.util._get_runtime_version", return_value="20.1.7"), + ): + result = _resolve_install("clang-format", "20.1.7") + assert result == Path(mock_path) + + +@pytest.mark.benchmark +def test_resolve_install_tool_version_mismatch(): + """Test _resolve_install when tool has wrong version.""" + mock_path = "/usr/bin/clang-format" + + with ( + patch("shutil.which", return_value=mock_path), + patch("cpp_linter_hooks.util._get_runtime_version", return_value="18.1.8"), + patch( + "cpp_linter_hooks.util._install_tool", return_value=Path(mock_path) + ) as mock_install, + patch("cpp_linter_hooks.util.LOG") as mock_log, + ): + result = _resolve_install("clang-format", "20.1.7") + assert result == Path(mock_path) + + mock_install.assert_called_once_with("clang-format", "20.1.7") + mock_log.info.assert_called_once_with( + "%s version mismatch (%s != %s), reinstalling...", + "clang-format", + "18.1.8", + "20.1.7", + ) + + +@pytest.mark.benchmark +def test_resolve_install_tool_not_installed(): + """Test _resolve_install when tool is not installed.""" + with ( + patch("shutil.which", return_value=None), + patch( + "cpp_linter_hooks.util._install_tool", + return_value=Path("/usr/bin/clang-format"), + ) as mock_install, + ): + result = _resolve_install("clang-format", "20.1.7") + assert result == Path("/usr/bin/clang-format") + + mock_install.assert_called_once_with("clang-format", "20.1.7") + + +@pytest.mark.benchmark +def test_resolve_install_no_version_specified(): + """Test _resolve_install when no version is specified.""" + with ( + patch("shutil.which", return_value=None), + patch( + "cpp_linter_hooks.util._install_tool", + return_value=Path("/usr/bin/clang-format"), + ) as mock_install, + ): + result = _resolve_install("clang-format", None) + assert result == Path("/usr/bin/clang-format") + + mock_install.assert_called_once_with( + "clang-format", DEFAULT_CLANG_FORMAT_VERSION + ) + + +@pytest.mark.benchmark +def test_resolve_install_invalid_version(): + """Test _resolve_install with invalid version.""" + with ( + patch("shutil.which", return_value=None), + patch( + "cpp_linter_hooks.util._install_tool", + return_value=Path("/usr/bin/clang-format"), + ) as mock_install, + ): + result = _resolve_install("clang-format", "invalid.version") + assert result == Path("/usr/bin/clang-format") + + # Should fallback to default version + mock_install.assert_called_once_with( + "clang-format", DEFAULT_CLANG_FORMAT_VERSION + ) + + +# Tests for ensure_installed edge cases +@pytest.mark.benchmark +def test_ensure_installed_version_mismatch(caplog): + """Test ensure_installed with version mismatch scenario.""" + mock_path = "/usr/bin/clang-format" + + with ( + patch("shutil.which", return_value=mock_path), + patch("cpp_linter_hooks.util._get_runtime_version", return_value="18.1.8"), + patch("cpp_linter_hooks.util._install_tool", return_value=Path(mock_path)), + ): + caplog.clear() + caplog.set_level(logging.INFO, logger="cpp_linter_hooks.util") + + result = ensure_installed("clang-format", "20.1.7") + assert result == "clang-format" + + # Should log version mismatch + assert any("version mismatch" in record.message for record in caplog.records) + + +@pytest.mark.benchmark +def test_ensure_installed_no_runtime_version(): + """Test ensure_installed when runtime version cannot be determined.""" + mock_path = "/usr/bin/clang-format" + + with ( + patch("shutil.which", return_value=mock_path), + patch("cpp_linter_hooks.util._get_runtime_version", return_value=None), + ): + result = ensure_installed("clang-format", "20.1.7") + assert result == "clang-format" + + +# Tests for constants and defaults +@pytest.mark.benchmark +def test_default_versions(): + """Test that default versions are set correctly.""" + assert DEFAULT_CLANG_FORMAT_VERSION is not None + assert DEFAULT_CLANG_TIDY_VERSION is not None + assert isinstance(DEFAULT_CLANG_FORMAT_VERSION, str) + assert isinstance(DEFAULT_CLANG_TIDY_VERSION, str) + + +@pytest.mark.benchmark +def test_version_lists_not_empty(): + """Test that version lists are not empty.""" + assert len(CLANG_FORMAT_VERSIONS) > 0 + assert len(CLANG_TIDY_VERSIONS) > 0 + assert all(isinstance(v, str) for v in CLANG_FORMAT_VERSIONS) + assert all(isinstance(v, str) for v in CLANG_TIDY_VERSIONS)