Skip to content
Closed
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
1 change: 1 addition & 0 deletions .github/workflows/pre-commit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ on:
push:
branches: main
pull_request:
types: [opened, synchronize, reopened, edited]
branches: main
workflow_dispatch:

Expand Down
20 changes: 17 additions & 3 deletions cpp_linter_hooks/clang_format.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from argparse import ArgumentParser
from typing import Tuple

from cpp_linter_hooks.util import _resolve_install, DEFAULT_CLANG_FORMAT_VERSION
from cpp_linter_hooks.util import resolve_install, DEFAULT_CLANG_FORMAT_VERSION


parser = ArgumentParser()
Expand All @@ -14,9 +14,23 @@


def run_clang_format(args=None) -> Tuple[int, str]:
"""
Run clang-format with the given arguments and return its exit code and combined output.

Parses known hook options from `args`, optionally ensures a specific clang-format version is installed, builds and executes a clang-format command (modifying files in-place by default), and captures stdout and stderr merged into a single output string.

Parameters:
args (Optional[Sequence[str]]): Argument list to parse (typically sys.argv[1:]). If omitted, uses parser defaults.

Returns:
tuple[int, str]: A pair (retval, output) where `output` is the concatenation of stdout and stderr.
`retval` is the subprocess return code, except:
- `-1` when the command included `--dry-run` (special sentinel to indicate dry-run mode),
- `1` when clang-format could not be found (FileNotFoundError converted to an exit-like code).
"""
hook_args, other_args = parser.parse_known_args(args)
if hook_args.version:
_resolve_install("clang-format", hook_args.version)
resolve_install("clang-format", hook_args.version)
command = ["clang-format", "-i"]

# Add verbose flag if requested
Expand Down Expand Up @@ -73,4 +87,4 @@ def main() -> int:


if __name__ == "__main__":
raise SystemExit(main())
raise SystemExit(main())
17 changes: 14 additions & 3 deletions cpp_linter_hooks/clang_tidy.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,28 @@
from argparse import ArgumentParser
from typing import Tuple

from cpp_linter_hooks.util import _resolve_install, DEFAULT_CLANG_TIDY_VERSION
from cpp_linter_hooks.util import resolve_install, DEFAULT_CLANG_TIDY_VERSION


parser = ArgumentParser()
parser.add_argument("--version", default=DEFAULT_CLANG_TIDY_VERSION)


def run_clang_tidy(args=None) -> Tuple[int, str]:
"""
Run clang-tidy with the given command-line arguments and return a status code and captured output.

Parameters:
args (Optional[Sequence[str]]): Arguments to parse and forward to clang-tidy; if None, uses sys.argv. If the parsed arguments include a --version value, the specified clang-tidy version is ensured to be installed before running.

Returns:
Tuple[int, str]: A pair (status, output).
- status: 0 when clang-tidy executed and produced no warnings or errors; 1 when clang-tidy reports any "warning:" or "error:", or when clang-tidy cannot be executed.
- output: Captured stdout from clang-tidy, or the error text if execution failed.
"""
hook_args, other_args = parser.parse_known_args(args)
if hook_args.version:
_resolve_install("clang-tidy", hook_args.version)
resolve_install("clang-tidy", hook_args.version)
command = ["clang-tidy"] + other_args

retval = 0
Expand All @@ -37,4 +48,4 @@ def main() -> int:


if __name__ == "__main__":
raise SystemExit(main())
raise SystemExit(main())
26 changes: 22 additions & 4 deletions cpp_linter_hooks/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,16 @@ def parse_version(v: str):


def _install_tool(tool: str, version: str) -> Optional[Path]:
"""Install a tool using pip, suppressing output."""
"""
Install the specified tool version into the current Python environment and return its executable path.

Parameters:
tool (str): The package/executable name to install (e.g., "clang-format").
version (str): The exact version string to install (e.g., "14.0.6").

Returns:
Path: Path to the installed tool's executable if the installation succeeds and the executable is found, `None` otherwise.
"""
try:
subprocess.check_call(
[sys.executable, "-m", "pip", "install", f"{tool}=={version}"],
Expand All @@ -72,8 +81,17 @@ def _install_tool(tool: str, version: str) -> Optional[Path]:
return None


def _resolve_install(tool: str, version: Optional[str]) -> Optional[Path]:
"""Resolve the installation of a tool, checking for version and installing if necessary."""
def resolve_install(tool: str, version: Optional[str]) -> Optional[Path]:
"""
Resolve and install the requested clang tool version and return its executable path.

Parameters:
tool (str): Tool name, expected "clang-format" or "clang-tidy".
version (Optional[str]): Desired version string (exact match or prefix). If None, falls back to the default version from pyproject.toml when available.

Returns:
Optional[Path]: Path to the installed tool executable if installation succeeded, `None` otherwise.
"""
user_version = _resolve_version(
CLANG_FORMAT_VERSIONS if tool == "clang-format" else CLANG_TIDY_VERSIONS,
version,
Expand All @@ -85,4 +103,4 @@ def _resolve_install(tool: str, version: Optional[str]) -> Optional[Path]:
else DEFAULT_CLANG_TIDY_VERSION
)

return _install_tool(tool, user_version)
return _install_tool(tool, user_version)
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ dev = [
[tool.setuptools]
zip-safe = false
packages = ["cpp_linter_hooks"]
include-package-data = true

[tool.setuptools.package-data]
cpp_linter_hooks = ["../pyproject.toml"]

[tool.setuptools_scm]
# It would be nice to include the commit hash in the version, but that
Expand Down
28 changes: 14 additions & 14 deletions tests/test_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
get_version_from_dependency,
_resolve_version,
_install_tool,
_resolve_install,
resolve_install,
DEFAULT_CLANG_FORMAT_VERSION,
DEFAULT_CLANG_TIDY_VERSION,
)
Expand Down Expand Up @@ -159,22 +159,22 @@ def test_install_tool_success_but_not_found():
assert result is None


# Tests for _resolve_install
# 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."""
"""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),
):
result = _resolve_install("clang-format", "20.1.7")
result = resolve_install("clang-format", "20.1.7")
assert Path(result) == Path(mock_path)


@pytest.mark.benchmark
def test_resolve_install_tool_version_mismatch():
"""Test _resolve_install when tool has wrong version."""
"""Test resolve_install when tool has wrong version."""
mock_path = "/usr/bin/clang-format"

with (
Expand All @@ -183,39 +183,39 @@ def test_resolve_install_tool_version_mismatch():
"cpp_linter_hooks.util._install_tool", return_value=Path(mock_path)
) as mock_install,
):
result = _resolve_install("clang-format", "20.1.7")
result = resolve_install("clang-format", "20.1.7")
assert result == Path(mock_path)

mock_install.assert_called_once_with("clang-format", "20.1.7")


@pytest.mark.benchmark
def test_resolve_install_tool_not_installed():
"""Test _resolve_install when tool is 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")
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."""
"""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)
result = resolve_install("clang-format", None)
assert result == Path("/usr/bin/clang-format")

mock_install.assert_called_once_with(
Expand All @@ -225,15 +225,15 @@ def test_resolve_install_no_version_specified():

@pytest.mark.benchmark
def test_resolve_install_invalid_version():
"""Test _resolve_install with 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")
result = resolve_install("clang-format", "invalid.version")
assert result == Path("/usr/bin/clang-format")

# Should fallback to default version
Expand Down Expand Up @@ -263,7 +263,7 @@ def test_version_lists_not_empty():

@pytest.mark.benchmark
def test_resolve_install_with_none_default_version():
"""Test _resolve_install when DEFAULT versions are None."""
"""Test resolve_install when DEFAULT versions are None."""
with (
patch("shutil.which", return_value=None),
patch("cpp_linter_hooks.util.DEFAULT_CLANG_FORMAT_VERSION", None),
Expand All @@ -273,7 +273,7 @@ def test_resolve_install_with_none_default_version():
return_value=Path("/usr/bin/clang-format"),
) as mock_install,
):
result = _resolve_install("clang-format", None)
result = resolve_install("clang-format", None)
assert result == Path("/usr/bin/clang-format")

# Should fallback to hardcoded version when DEFAULT is None
Expand Down
Loading