Skip to content

Commit c03ae0f

Browse files
committed
fix utils.py tests and others
1 parent 9579df6 commit c03ae0f

File tree

6 files changed

+215
-73
lines changed

6 files changed

+215
-73
lines changed

MIGRATION.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ No action required! Your existing configuration will continue to work.
5050
from cpp_linter_hooks.util import ensure_installed
5151
path = ensure_installed("clang-format", "18")
5252
command = [str(path), "--version"]
53-
53+
5454
# After
5555
from cpp_linter_hooks.util import ensure_installed
5656
tool_name = ensure_installed("clang-format", "18")

cpp_linter_hooks/clang_format.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,11 @@
33
from argparse import ArgumentParser
44
from typing import Tuple
55

6-
from .util import ensure_installed, DEFAULT_CLANG_VERSION
6+
from .util import ensure_installed, DEFAULT_CLANG_FORMAT_VERSION
77

88

99
parser = ArgumentParser()
10-
parser.add_argument("--version", default=DEFAULT_CLANG_VERSION)
10+
parser.add_argument("--version", default=DEFAULT_CLANG_FORMAT_VERSION)
1111
parser.add_argument(
1212
"-v", "--verbose", action="store_true", help="Enable verbose output"
1313
)

cpp_linter_hooks/clang_tidy.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@
22
from argparse import ArgumentParser
33
from typing import Tuple
44

5-
from .util import ensure_installed, DEFAULT_CLANG_VERSION
5+
from .util import ensure_installed, DEFAULT_CLANG_TIDY_VERSION
66

77

88
parser = ArgumentParser()
9-
parser.add_argument("--version", default=DEFAULT_CLANG_VERSION)
9+
parser.add_argument("--version", default=DEFAULT_CLANG_TIDY_VERSION)
1010

1111

1212
def run_clang_tidy(args=None) -> Tuple[int, str]:

cpp_linter_hooks/util.py

Lines changed: 188 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,64 +1,200 @@
11
import sys
22
import shutil
3+
import toml
4+
import subprocess
35
from pathlib import Path
46
import logging
5-
from typing import Optional
7+
from typing import Optional, List
8+
from packaging.version import Version, InvalidVersion
69

710
LOG = logging.getLogger(__name__)
811

9-
DEFAULT_CLANG_VERSION = "20" # Default version for clang tools, can be overridden
10-
11-
12-
def is_installed(tool_name: str, version: str = "") -> Optional[Path]:
13-
"""Check if tool is installed.
14-
15-
With wheel packages, the tools are installed as regular Python packages
16-
and available via shutil.which().
17-
"""
18-
# Check if tool is available in PATH
19-
tool_path = shutil.which(tool_name)
20-
if tool_path is not None:
21-
return Path(tool_path)
22-
23-
# Check if tool is available in current Python environment
24-
if sys.executable:
25-
python_dir = Path(sys.executable).parent
26-
tool_path = python_dir / tool_name
27-
if tool_path.is_file():
28-
return tool_path
29-
30-
# Also check Scripts directory on Windows
31-
scripts_dir = python_dir / "Scripts"
32-
if scripts_dir.exists():
33-
tool_path = scripts_dir / tool_name
34-
if tool_path.is_file():
35-
return tool_path
36-
# Try with .exe extension on Windows
37-
tool_path = scripts_dir / f"{tool_name}.exe"
38-
if tool_path.is_file():
39-
return tool_path
4012

13+
def get_version_from_dependency(tool: str) -> Optional[str]:
14+
pyproject_path = Path(__file__).parent.parent / "pyproject.toml"
15+
if not pyproject_path.exists():
16+
return None
17+
data = toml.load(pyproject_path)
18+
dependencies = data.get("project", {}).get("dependencies", [])
19+
for dep in dependencies:
20+
if dep.startswith(f"{tool}=="):
21+
return dep.split("==")[1]
4122
return None
4223

4324

44-
def ensure_installed(tool_name: str, version: str = "") -> str:
45-
"""
46-
Ensure tool is available. With wheel packages, we assume the tools are
47-
installed as dependencies and available in PATH.
48-
49-
Returns the tool name (not path) since the wheel packages install the tools
50-
as executables that can be called directly.
51-
"""
52-
LOG.info("Checking for %s", tool_name)
53-
path = is_installed(tool_name, version)
54-
if path is not None:
55-
LOG.info("%s is available at %s", tool_name, path)
56-
return tool_name # Return tool name for direct execution
57-
58-
# If not found, we'll still return the tool name and let subprocess handle the error
59-
LOG.warning(
60-
"%s not found in PATH. Make sure the %s wheel package is installed.",
61-
tool_name,
62-
tool_name,
25+
DEFAULT_CLANG_FORMAT_VERSION = get_version_from_dependency("clang-format") or "20.1.7"
26+
DEFAULT_CLANG_TIDY_VERSION = get_version_from_dependency("clang-tidy") or "20.1.0"
27+
28+
29+
CLANG_FORMAT_VERSIONS = [
30+
"6.0.1",
31+
"7.1.0",
32+
"8.0.1",
33+
"9.0.0",
34+
"10.0.1",
35+
"10.0.1.1",
36+
"11.0.1",
37+
"11.0.1.1",
38+
"11.0.1.2",
39+
"11.1.0",
40+
"11.1.0.1",
41+
"11.1.0.2",
42+
"12.0.1",
43+
"12.0.1.1",
44+
"12.0.1.2",
45+
"13.0.0",
46+
"13.0.1",
47+
"13.0.1.1",
48+
"14.0.0",
49+
"14.0.1",
50+
"14.0.3",
51+
"14.0.4",
52+
"14.0.5",
53+
"14.0.6",
54+
"15.0.4",
55+
"15.0.6",
56+
"15.0.7",
57+
"16.0.0",
58+
"16.0.1",
59+
"16.0.2",
60+
"16.0.3",
61+
"16.0.4",
62+
"16.0.5",
63+
"16.0.6",
64+
"17.0.1",
65+
"17.0.2",
66+
"17.0.3",
67+
"17.0.4",
68+
"17.0.5",
69+
"17.0.6",
70+
"18.1.0",
71+
"18.1.1",
72+
"18.1.2",
73+
"18.1.3",
74+
"18.1.4",
75+
"18.1.5",
76+
"18.1.6",
77+
"18.1.7",
78+
"18.1.8",
79+
"19.1.0",
80+
"19.1.1",
81+
"19.1.2",
82+
"19.1.3",
83+
"19.1.4",
84+
"19.1.5",
85+
"19.1.6",
86+
"19.1.7",
87+
"20.1.0",
88+
"20.1.3",
89+
"20.1.4",
90+
"20.1.5",
91+
"20.1.6",
92+
"20.1.7",
93+
]
94+
95+
CLANG_TIDY_VERSIONS = [
96+
"13.0.1.1",
97+
"14.0.6",
98+
"15.0.2",
99+
"15.0.2.1",
100+
"16.0.4",
101+
"17.0.1",
102+
"18.1.1",
103+
"18.1.8",
104+
"19.1.0",
105+
"19.1.0.1",
106+
"20.1.0",
107+
]
108+
109+
110+
def _resolve_version(versions: List[str], user_input: Optional[str]) -> Optional[str]:
111+
if user_input is None:
112+
return None
113+
try:
114+
user_ver = Version(user_input)
115+
except InvalidVersion:
116+
return None
117+
118+
candidates = [Version(v) for v in versions]
119+
if user_input.count(".") == 0:
120+
matches = [v for v in candidates if v.major == user_ver.major]
121+
elif user_input.count(".") == 1:
122+
matches = [
123+
v
124+
for v in candidates
125+
if f"{v.major}.{v.minor}" == f"{user_ver.major}.{user_ver.minor}"
126+
]
127+
else:
128+
return str(user_ver) if user_ver in candidates else None
129+
130+
return str(max(matches)) if matches else None
131+
132+
133+
def _get_runtime_version(tool: str) -> Optional[str]:
134+
try:
135+
output = subprocess.check_output([tool, "--version"], text=True)
136+
if tool == "clang-tidy":
137+
lines = output.strip().splitlines()
138+
if len(lines) > 1:
139+
return lines[1].split()[-1]
140+
elif tool == "clang-format":
141+
return output.strip().split()[-1]
142+
except Exception:
143+
return None
144+
145+
146+
def _install_tool(tool: str, version: str) -> Optional[Path]:
147+
try:
148+
subprocess.check_call(
149+
[sys.executable, "-m", "pip", "install", f"{tool}=={version}"]
150+
)
151+
return shutil.which(tool)
152+
except subprocess.CalledProcessError:
153+
LOG.error("Failed to install %s==%s", tool, version)
154+
return None
155+
156+
157+
def _resolve_install(tool: str, version: Optional[str]) -> Optional[Path]:
158+
user_version = _resolve_version(
159+
CLANG_FORMAT_VERSIONS if tool == "clang-format" else CLANG_TIDY_VERSIONS,
160+
version,
63161
)
64-
return tool_name
162+
if user_version is None:
163+
user_version = (
164+
DEFAULT_CLANG_FORMAT_VERSION
165+
if tool == "clang-format"
166+
else DEFAULT_CLANG_TIDY_VERSION
167+
)
168+
169+
path = shutil.which(tool)
170+
if path:
171+
runtime_version = _get_runtime_version(tool)
172+
if runtime_version and user_version not in runtime_version:
173+
LOG.info(
174+
"%s version mismatch (%s != %s), reinstalling...",
175+
tool,
176+
runtime_version,
177+
user_version,
178+
)
179+
return _install_tool(tool, user_version)
180+
return Path(path)
181+
182+
return _install_tool(tool, user_version)
183+
184+
185+
def is_installed(tool: str) -> Optional[Path]:
186+
"""Check if a tool is installed and return its path."""
187+
path = shutil.which(tool)
188+
if path:
189+
return Path(path)
190+
return None
191+
192+
193+
def ensure_installed(tool: str, version: Optional[str] = None) -> str:
194+
LOG.info("Ensuring %s is installed", tool)
195+
tool_path = _resolve_install(tool, version)
196+
if tool_path:
197+
LOG.info("%s available at %s", tool, tool_path)
198+
return tool
199+
LOG.warning("%s not found and could not be installed", tool)
200+
return tool

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ classifiers = [
3434
dependencies = [
3535
"clang-format==20.1.7",
3636
"clang-tidy==20.1.0",
37+
"toml>=0.10.2",
3738
]
3839
dynamic = ["version"]
3940

tests/test_util.py

Lines changed: 21 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import logging
2-
import sys
32
import pytest
43
from itertools import product
54
from unittest.mock import patch
@@ -15,24 +14,27 @@
1514
@pytest.mark.parametrize(("tool", "version"), list(product(TOOLS, VERSIONS)))
1615
def test_ensure_installed(tool, version, tmp_path, monkeypatch, caplog):
1716
"""Test that ensure_installed returns the tool name for wheel packages."""
18-
with monkeypatch.context() as m:
19-
m.setattr(sys, "executable", str(tmp_path / "bin" / "python"))
20-
17+
with monkeypatch.context():
2118
# Mock shutil.which to simulate the tool being available
2219
with patch("shutil.which", return_value=str(tmp_path / tool)):
23-
caplog.clear()
24-
caplog.set_level(logging.INFO, logger="cpp_linter_hooks.util")
20+
# Mock _get_runtime_version to return a matching version
21+
mock_version = "20.1.7" if tool == "clang-format" else "20.1.0"
22+
with patch(
23+
"cpp_linter_hooks.util._get_runtime_version", return_value=mock_version
24+
):
25+
caplog.clear()
26+
caplog.set_level(logging.INFO, logger="cpp_linter_hooks.util")
2527

26-
if version is None:
27-
result = ensure_installed(tool)
28-
else:
29-
result = ensure_installed(tool, version=version)
28+
if version is None:
29+
result = ensure_installed(tool)
30+
else:
31+
result = ensure_installed(tool, version=version)
3032

31-
# Should return the tool name for direct execution
32-
assert result == tool
33+
# Should return the tool name for direct execution
34+
assert result == tool
3335

34-
# Check that we logged checking for the tool
35-
assert any("Checking for" in record.message for record in caplog.records)
36+
# Check that we logged ensuring the tool is installed
37+
assert any("Ensuring" in record.message for record in caplog.records)
3638

3739

3840
def test_is_installed_with_shutil_which(tmp_path):
@@ -59,7 +61,7 @@ def test_ensure_installed_tool_not_found(caplog):
5961
"""Test ensure_installed when tool is not found."""
6062
with (
6163
patch("shutil.which", return_value=None),
62-
patch("sys.executable", "/nonexistent/python"),
64+
patch("cpp_linter_hooks.util._install_tool", return_value=None),
6365
):
6466
caplog.clear()
6567
caplog.set_level(logging.WARNING, logger="cpp_linter_hooks.util")
@@ -70,4 +72,7 @@ def test_ensure_installed_tool_not_found(caplog):
7072
assert result == "clang-format"
7173

7274
# Should log a warning
73-
assert any("not found in PATH" in record.message for record in caplog.records)
75+
assert any(
76+
"not found and could not be installed" in record.message
77+
for record in caplog.records
78+
)

0 commit comments

Comments
 (0)