diff --git a/README.md b/README.md index b950643..d7207f4 100644 --- a/README.md +++ b/README.md @@ -62,18 +62,20 @@ Choose the clang tool version with `--version`: clang-tidy wheels are released separately from clang-format wheels and skip some releases, so give clang-tidy the major version. - clang-format wheels cover LLVM 6 to 23 and clang-tidy wheels LLVM 13 to 23. For a version without - a wheel, the hook fails and lists some of the versions that exist. + a wheel, the hook fails and shows pip's list of the versions that exist. - Each version is installed once, the first time the hook runs with it, and later runs use it - without contacting pypi.org. pre-commit shares the hook between all your projects that use the + without network access. pre-commit shares the hook between all your projects that use the same `rev`, and projects that ask for different versions each keep their own copy. +- pip installs the wheels, so a package index set in your pip configuration (`PIP_INDEX_URL`, + `pip.conf`) is used, for example a company mirror. #### Without network access clang-format without `--version` is installed together with the hook and runs offline. clang-tidy without `--version`, and any `--version` that is not installed yet, is installed the first time the hook runs, which needs network access. On [pre-commit.ci](https://pre-commit.ci/), which runs -hooks without network access, or where pypi.org is blocked, pin the version with pre-commit's own -`additional_dependencies` instead of `--version`. pre-commit installs it together with the hook, through the package index it uses for +hooks without network access, pin the version with pre-commit's own `additional_dependencies` +instead of `--version`. pre-commit installs it together with the hook, through the package index it uses for the hook itself: ```yaml @@ -232,7 +234,8 @@ to clang-tidy automatically — no configuration needed for most projects. It lo `build-*/`, `build/*/` and `out/build/*/` (CMake presets, Visual Studio), where the most recently written one wins. Without `-p`, clang-tidy also finds a `compile_commands.json` or `compile_flags.txt` next to each file or in its parent directories, so a database in the -repository root works too. +repository root works too. With only a `compile_flags.txt`, clang reads `.h` files as C, so a C++ +project adds `-xc++` to it; otherwise a flag such as `-std=c++17` fails on every header. ```yaml repos: @@ -258,8 +261,8 @@ problems in your code, so the hook: - **fails in CI**, when the `CI` environment variable is set (GitHub Actions, GitLab CI and most CI services set it), so a broken setup does not pass unnoticed. -The hook runs clang-tidy anyway when `args` tell it how to compile: `-p`, `--extra-arg` or -`--extra-arg-before`. +The hook runs clang-tidy anyway when `args` tell it how to compile: `-p`, `--extra-arg`, +`--extra-arg-before`, or `--` followed by the compiler flags, as in `args: ["--", "-std=c++17"]`. To disable auto-detection and always run clang-tidy without a database (e.g. in a monorepo where auto-detect might pick the wrong database, or for code that needs no compiler flags): diff --git a/cpp_linter_hooks/clang_tidy.py b/cpp_linter_hooks/clang_tidy.py index bbdcf40..96e0851 100644 --- a/cpp_linter_hooks/clang_tidy.py +++ b/cpp_linter_hooks/clang_tidy.py @@ -49,6 +49,7 @@ ".ipp", ".inl", ".ixx", + ".m", ".mm", ".tpp", ".txx", @@ -595,15 +596,23 @@ def _looks_like_source_file(path: str) -> bool: def _split_source_files(args: list[str]) -> tuple[list[str], list[str]]: - """Split clang-tidy options from trailing source file arguments.""" + """Split clang-tidy options from the source files pre-commit appends. + + Going back from the end, an argument is a source file when it has a C or + C++ suffix, or when it is an existing file that does not follow an option, + which could take it as its value (``--export-fixes fixes.yaml``). The + second case covers suffixes added with ``types_or`` or ``files``. + """ split_idx = len(args) - source_files: list[str] = [] - for idx in range(len(args) - 1, -1, -1): - if not _looks_like_source_file(args[idx]): + while split_idx > 0: + arg = args[split_idx - 1] + previous = args[split_idx - 2] if split_idx > 1 else "" + if not _looks_like_source_file(arg) and ( + previous.startswith("-") or not os.path.isfile(arg) + ): break - source_files.append(args[idx]) - split_idx = idx - return args[:split_idx], list(reversed(source_files)) + split_idx -= 1 + return args[:split_idx], args[split_idx:] def _combine_outputs(results: list[tuple[int, str]]) -> str: @@ -621,16 +630,23 @@ def _exec_serial_clang_tidy( command_prefix: list[str], source_files: list[str], lines: dict[str, LineRanges] | None, + compiler_args: tuple[str, ...] = (), ) -> tuple[int, str]: - """Run clang-tidy over all source files, in one command when possible.""" + """Run clang-tidy over all source files, in one command when possible. + + *compiler_args* is ``--`` and the compiler arguments after it, which go + after the source files. + """ if lines is None: - return _exec_clang_tidy(command_prefix + source_files) + return _exec_clang_tidy([*command_prefix, *source_files, *compiler_args]) # Several commands only for very large commits; they run one after another, # so -fix stays safe. return _combine_results( [ - _exec_clang_tidy(command_prefix + batch) - for batch in _line_filter_batches(command_prefix, source_files, lines) + _exec_clang_tidy([*command_prefix, *batch, *compiler_args]) + for batch in _line_filter_batches( + [*command_prefix, *compiler_args], source_files, lines + ) ] ) @@ -640,6 +656,7 @@ def _exec_parallel_clang_tidy( source_files: list[str], jobs: int, lines: dict[str, LineRanges] | None = None, + compiler_args: tuple[str, ...] = (), ) -> tuple[int, str]: """Run clang-tidy over source files in parallel and combine the results.""" @@ -650,7 +667,9 @@ def run_file(source_file: str) -> tuple[int, str]: if lines is None else [_line_filter_option({source_file: lines[source_file]})] ) - return _exec_clang_tidy(command_prefix + line_filter + [source_file]) + return _exec_clang_tidy( + [*command_prefix, *line_filter, source_file, *compiler_args] + ) with ThreadPoolExecutor(max_workers=min(jobs, len(source_files))) as executor: results = list(executor.map(run_file, source_files)) @@ -669,6 +688,13 @@ def run_clang_tidy(args=None) -> tuple[int, str]: clang_tidy_args, source_files = _split_source_files(other_args) source_files = [file_argument(f) for f in source_files] clang_tidy_args, notes = _repair_check_args(clang_tidy_args) + # clang-tidy reads everything after "--" as compiler arguments, so the + # source files and the hook's own options go before it. + compiler_args: tuple[str, ...] = () + if "--" in clang_tidy_args: + split_idx = clang_tidy_args.index("--") + compiler_args = tuple(clang_tidy_args[split_idx:]) + clang_tidy_args = clang_tidy_args[:split_idx] lines = None if not hook_args.all_lines and not _has_option( @@ -695,7 +721,7 @@ def run_clang_tidy(args=None) -> tuple[int, str]: clang_tidy_args = ["-p", compile_db_path] + clang_tidy_args elif ( not hook_args.no_compile_commands - and not _gives_compile_flags(clang_tidy_args) + and not _gives_compile_flags([*clang_tidy_args, *compiler_args]) and not _has_compilation_database(source_files) ): retval, message = _missing_compile_db_result() @@ -733,11 +759,15 @@ def run_clang_tidy(args=None) -> tuple[int, str]: if hook_args.jobs > 1 and len(source_files) > 1 and not unsafe_parallel: retval, output = _exec_parallel_clang_tidy( - [executable] + clang_tidy_args, source_files, hook_args.jobs, lines + [executable] + clang_tidy_args, + source_files, + hook_args.jobs, + lines, + compiler_args, ) else: retval, output = _exec_serial_clang_tidy( - [executable] + clang_tidy_args, source_files, lines + [executable] + clang_tidy_args, source_files, lines, compiler_args ) if NO_CHECKS_ERROR in output: diff --git a/cpp_linter_hooks/util.py b/cpp_linter_hooks/util.py index fa01629..5919eee 100644 --- a/cpp_linter_hooks/util.py +++ b/cpp_linter_hooks/util.py @@ -1,7 +1,6 @@ -"""Shared helpers for resolving and installing clang tool wheels.""" +"""Shared helpers for finding and installing clang tool wheels.""" import contextlib -import json import logging import os import re @@ -9,8 +8,6 @@ import subprocess import sys import sysconfig -import urllib.error -import urllib.request from functools import lru_cache from pathlib import Path @@ -30,139 +27,6 @@ def file_argument(arg: str) -> str: return arg -@lru_cache(maxsize=4) -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 - process reuse the last HTTP response. - """ - try: - url = f"https://pypi.org/pypi/{tool}/json" - with urllib.request.urlopen(url, timeout=10) as response: - data = json.loads(response.read()) - except (OSError, json.JSONDecodeError) as exc: - LOG.warning("Failed to fetch versions for %s from PyPI: %s", tool, exc) - return None, [] - - all_versions = list(data["releases"].keys()) - - # Filter out pre-release versions - pre_release_pattern = re.compile( - r".*(alpha|beta|rc|dev|a\d+|b\d+).*", re.IGNORECASE - ) - stable = [v for v in all_versions if not pre_release_pattern.match(v)] - - if not stable: - LOG.warning("No stable versions found for %s on PyPI", tool) - return None, [] - - # Sort ascending by version tuple - stable.sort(key=lambda x: tuple(map(int, x.split(".")))) - - latest = stable[-1] - # Return descending for prefix matching (newest first) - return latest, list(reversed(stable)) - - -def _resolve_version_from_pypi( - 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 - suitable for displaying directly to the end user. - - When PyPI is unreachable and no explicit version was requested, - falls back to whatever version is already installed on the host - so that pre-installed tools keep working offline. - """ - latest, versions = _get_pypi_versions(tool) - - if not versions: - if user_input is None: - # PyPI is unreachable, but the user didn't ask for a specific - # version – try the locally installed tool as a fallback. - installed = _detect_installed_version(tool) - if installed: - LOG.info( - "PyPI unreachable; using locally installed %s %s", - tool, - installed, - ) - return installed, None - return ( - None, - ( - f"Could not find any stable versions of {tool} on PyPI. " - "Check your network connection." - ), - ) - - if user_input is None: - return latest, None - - # Exact match - if user_input in versions: - return user_input, None - - # Prefix match on whole version components (e.g. "20" → "20.1.8", while - # "2" must not match "20.1.8"). Versions are newest-first, so the first - # matching entry is the latest for that prefix. - matched = [v for v in versions if v.startswith(f"{user_input}.")] - if matched: - return matched[0], None - - # No match – help the user - 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." - ), - ) - - -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 - was requested. Extracts the version string from `` --version`` - output (e.g. ``"clang-format version 18.1.8"`` → ``"18.1.8"``). - """ - existing = shutil.which(tool) - if not existing: - return None - try: - result = subprocess.run( - [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) -> 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, check=False - ) - if version in result.stdout: - return Path(existing) - return None - - @lru_cache(maxsize=1) def _env_paths() -> dict[str, str]: """Return this Python environment's install paths, which never change at runtime.""" @@ -174,21 +38,17 @@ def _dist_name(tool: str) -> str: return re.sub(r"[-_.]+", "_", tool).lower() -def _installed_wheel_version(tool: str) -> str | None: - """Return the version of the *tool* wheel installed in this Python environment. +def _wheel_version(tool: str, site_dirs) -> str | None: + """Return the version of the *tool* wheel installed in one of *site_dirs*. - pre-commit installs ``additional_dependencies`` (and every wheel this hook - installed on an earlier run) into the hook's own environment. The version - comes from the wheel's ``-.dist-info`` directory name, which - needs neither the network nor a subprocess, and avoids parsing the large - METADATA file the way ``importlib.metadata.version`` does. A wheel counts - only once its ``RECORD`` file exists: pip writes it last, so a wheel another - hook process is still installing is not used half-written. + The version comes from the wheel's ``-.dist-info`` directory + name, which needs neither the network nor a subprocess, and avoids parsing + the large METADATA file the way ``importlib.metadata.version`` does. A wheel + counts only once its ``RECORD`` file exists: pip writes it last, so a wheel + another hook process is still installing is not used half-written. """ prefix = f"{_dist_name(tool)}-" suffix = ".dist-info" - paths = _env_paths() - site_dirs = dict.fromkeys((paths["purelib"], paths["platlib"])) for site_dir in site_dirs: try: names = os.listdir(site_dir) @@ -205,6 +65,17 @@ def _installed_wheel_version(tool: str) -> str | None: return None +def _installed_wheel_version(tool: str) -> str | None: + """Return the version of the *tool* wheel installed in this Python environment. + + pre-commit installs ``additional_dependencies`` (and every wheel this hook + installed on an earlier run without ``--version``) into the hook's own + environment. + """ + paths = _env_paths() + return _wheel_version(tool, dict.fromkeys((paths["purelib"], paths["platlib"]))) + + def _version_satisfies(installed: str, requested: str | None) -> bool: """Return whether *installed* matches ``--version`` (exact or prefix).""" if requested is None: @@ -266,22 +137,27 @@ def _find_versioned_install(tool: str, version: str) -> tuple[Path, str] | None: behind by an interrupted ``pip install`` is never used. """ base = _versions_dir() - prefix = f"{tool}-" try: names = os.listdir(base) except OSError: return None + prefix = f"{tool}-" found = [] for name in names: - installed = name[len(prefix) :] - if not name.startswith(prefix) or not _version_satisfies(installed, version): + # Each directory is named after the --version it was installed for, + # so only directories for a related version need to be read. + requested = name[len(prefix) :] + if not name.startswith(prefix) or not ( + _version_satisfies(requested, version) + or _version_satisfies(version, requested) + ): continue target = os.path.join(base, name) - record = os.path.join( - target, f"{_dist_name(tool)}-{installed}.dist-info", "RECORD" - ) + installed = _wheel_version(tool, [target]) + if installed is None or not _version_satisfies(installed, version): + continue executable = _versioned_executable(tool, target) - if os.path.isfile(record) and executable: + if executable: found.append((_version_key(installed), Path(executable), installed)) if not found: return None @@ -334,94 +210,83 @@ def _install_lock(): lock.release() -def _install_tool(tool: str, version: str, versioned: bool = False) -> Path | None: - """Install a tool using pip, logging output on failure. +def _install_tool(tool: str, version: str | None) -> str | None: + """Install *tool* with pip and return an error message if that fails. - With *versioned*, the wheel goes to its own directory under - :func:`_versions_dir` instead of the hook environment. + pip picks the version, so it uses the package index from the user's pip + configuration (``PIP_INDEX_URL``, ``pip.conf``). Without *version* the + newest wheel goes into the hook environment. ``--version=21`` installs the + newest 21.x wheel (``clang-tidy==21.*``), and ``--version=21.1.8`` that + release, into its own directory under :func:`_versions_dir`. """ - command = [sys.executable, "-m", "pip", "install", f"{tool}=={version}"] - if versioned: + command = [sys.executable, "-m", "pip", "install", "--disable-pip-version-check"] + if version is None: + requirement = tool + command.append(requirement) + else: + # A full release such as 21.1.8 is installed exactly; a shorter one + # such as 21 picks the newest matching release. + exact = version.count(".") >= 2 + requirement = f"{tool}=={version}" if exact else f"{tool}=={version}.*" target = os.path.join(_versions_dir(), f"{tool}-{version}") # Remove what an interrupted install left behind; pip --target does # not replace an existing directory. shutil.rmtree(target, ignore_errors=True) - command += ["--target", target] + command += [requirement, "--target", target] result = subprocess.run(command, capture_output=True, text=True, check=False) if result.returncode == 0: - if versioned: - return _versioned_executable(tool, target) - return _env_executable(tool) or shutil.which(tool) - LOG.error("pip failed to install %s %s", tool, version) - LOG.error(result.stdout) - LOG.error(result.stderr) - return None + return None + return ( + f"pip could not install {requirement}:\n{result.stderr.strip()}\n" + f"Run `pip index versions {tool}` to see the available versions." + ) def resolve_install_with_diagnostics( 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. + """Find or install a tool, returning a user-facing error if that fails. A wheel already installed in the hook's environment is used as is when it matches ``--version`` (or when no version was given), so pinned ``additional_dependencies`` are kept and the hook works offline. So is an - earlier per-version install that matches ``--version``. Otherwise the - version is resolved dynamically from PyPI — no hardcoded list is - maintained in-tree — and a ``--version`` is installed into its own - directory. + earlier per-version install that matches ``--version``. Otherwise pip + installs it. Installing runs under :func:`_install_lock`, so when several hook processes start at once, one installs and the others wait and then use its wheel. """ - installed_wheel = _find_installed(tool, version) - if installed_wheel is None: + # The version also names a directory that is deleted before installing. + if version is not None and not re.fullmatch(r"\d+(\.\d+)*", version): + return None, ( + f"Invalid {tool} --version '{version}': use a version number such as " + "21 or 21.1.8." + ) + installed = _find_installed(tool, version) + action = "Using installed" + if installed is None: with _install_lock(): # Another hook process may have installed it while this one waited. - installed_wheel = _find_installed(tool, version) - if installed_wheel is None: - return _resolve_and_install(tool, version, verbose) - path, installed = installed_wheel + installed = _find_installed(tool, version) + if installed is None: + error = _install_tool(tool, version) + if error is not None: + return None, error + installed = _find_installed(tool, version) + if installed is None: + return None, f"pip installed {tool}, but its executable is missing." + action = "Installed" + path, installed_version = installed if verbose: print( - f"Using installed {tool} Python wheel version {installed}", + f"{action} {tool} Python wheel version {installed_version}", file=sys.stderr, ) return path, None -def _resolve_and_install( - tool: str, version: str | None, verbose: bool -) -> tuple[Path | None, str | None]: - """Resolve *version* on PyPI and install it, unless it is already on PATH.""" - user_version, error = _resolve_version_from_pypi(tool, version) - if error is not None: - return None, error - - if verbose: - if version is None: - print( - f"Using latest {tool} Python wheel version {user_version}", - file=sys.stderr, - ) - elif version == user_version: - print(f"Using {tool} Python wheel version {user_version}", file=sys.stderr) - else: - print( - f"Resolved {tool} --version={version} to Python wheel version " - f"{user_version}", - file=sys.stderr, - ) - - return ( - _is_version_installed(tool, user_version) - or _install_tool(tool, user_version, versioned=version is not None), - None, - ) - - def resolve_install(tool: str, version: str | None) -> Path | None: - """Resolve/install a tool, logging bad-version diagnostics.""" + """Find or install a tool, logging the error if that fails.""" path, error = resolve_install_with_diagnostics(tool, version) if error is not None: LOG.error(error) diff --git a/tests/test_clang_tidy.py b/tests/test_clang_tidy.py index d758f96..2b52736 100644 --- a/tests/test_clang_tidy.py +++ b/tests/test_clang_tidy.py @@ -1611,3 +1611,71 @@ def test_set_macos_sdkroot_without_sdk(monkeypatch, outcome): _set_macos_sdkroot(verbose=True) assert "SDKROOT" not in os.environ + + +def test_split_source_files_keeps_files_with_other_suffixes(tmp_path, monkeypatch): + # types_or or files can add suffixes the hook does not know; such a file + # must not hide the files before it. + monkeypatch.chdir(tmp_path) + (tmp_path / "x.metal").write_text("") + + assert _split_source_files(["-quiet", "a.cpp", "x.metal", "b.cpp"]) == ( + ["-quiet"], + ["a.cpp", "x.metal", "b.cpp"], + ) + + +def test_split_source_files_keeps_an_option_value_that_is_a_file(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "fixes.yaml").write_text("") + + assert _split_source_files(["--export-fixes", "fixes.yaml", "a.cpp"]) == ( + ["--export-fixes", "fixes.yaml"], + ["a.cpp"], + ) + + +def test_objective_c_files_are_checked_on_changed_lines(monkeypatch, mock_exec): + monkeypatch.setattr(clang_tidy.os, "sep", "/") + monkeypatch.setattr( + clang_tidy, "changed_lines", lambda paths: {f: [[1, 1]] for f in paths} + ) + + run_clang_tidy(["-p", "./build", "a.cpp", "x.m", "b.cpp"]) + + command = mock_exec.call_args.args[0] + assert command[-3:] == ["a.cpp", "x.m", "b.cpp"] + names = {entry["name"] for entry in json.loads(command[-4].split("=", 1)[1])} + assert names == {"a.cpp", "x.m", "b.cpp"} + + +def test_source_files_go_before_compiler_arguments(tmp_path, monkeypatch, mock_exec): + # pre-commit appends the files after args: ["--", "-std=c++17"]. + monkeypatch.chdir(tmp_path) + run_clang_tidy(["--fix", "--all-lines", "--", "-std=c++17", "a.cpp", "b.cpp"]) + + mock_exec.assert_called_once_with( + ["clang-tidy", "-quiet", "-fix", "a.cpp", "b.cpp", "--", "-std=c++17"] + ) + + +def test_source_files_go_before_compiler_arguments_in_every_command( + tmp_path, monkeypatch, mock_exec +): + monkeypatch.chdir(tmp_path) + monkeypatch.setattr( + clang_tidy, "changed_lines", lambda paths: {f: [[1, 1]] for f in paths} + ) + monkeypatch.setattr(clang_tidy, "COMMAND_LINE_BUDGET", 0) + + run_clang_tidy(["--", "-std=c++17", "a.cpp", "b.cpp"]) + run_clang_tidy(["--jobs=2", "--", "-std=c++17", "a.cpp", "b.cpp"]) + + commands = [call.args[0] for call in mock_exec.call_args_list] + assert len(commands) == 4 + for run in (commands[:2], commands[2:]): + assert sorted(command[-3] for command in run) == ["a.cpp", "b.cpp"] + for command in commands: + assert command[-4][:14] == "--line-filter=" + assert command[-3] in ("a.cpp", "b.cpp") + assert command[-2:] == ["--", "-std=c++17"] diff --git a/tests/test_util.py b/tests/test_util.py index 3279970..10f7c24 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -1,7 +1,6 @@ -"""Tests for cpp_linter_hooks.util -- dynamic PyPI version resolution.""" +"""Tests for cpp_linter_hooks.util -- finding and installing clang tool wheels.""" import errno -import json import logging import os import subprocess @@ -15,17 +14,13 @@ from cpp_linter_hooks import util from cpp_linter_hooks.util import ( - _detect_installed_version, _env_paths, _find_installed, _find_installed_wheel, _find_versioned_install, - _get_pypi_versions, _install_lock, _install_tool, _installed_wheel_version, - _is_version_installed, - _resolve_version_from_pypi, _version_key, _version_satisfies, _versioned_executable, @@ -34,10 +29,7 @@ resolve_install_with_diagnostics, ) -# ── sample PyPI responses for consistent test data ────────────────────── - -MOCK_PYPI_FORMAT = ("22.1.5", ["22.1.5", "22.1.4", "20.1.8", "20.1.7", "18.1.8"]) -MOCK_PYPI_TIDY = ("21.1.6", ["21.1.6", "21.1.1", "20.1.0", "19.1.0.1", "18.1.8"]) +PIP = [sys.executable, "-m", "pip", "install", "--disable-pip-version-check"] @pytest.fixture(autouse=True) @@ -58,307 +50,60 @@ def no_installed_wheel(tmp_path): yield -def _pypi_side_effect(tool: str): - """Side-effect that maps tool names to canned PyPI responses.""" - mapping = { - "clang-format": MOCK_PYPI_FORMAT, - "clang-tidy": MOCK_PYPI_TIDY, - } - return mapping.get(tool, (None, [])) - - # ═══════════════════════════════════════════════════════════════════════ -# _get_pypi_versions +# _install_tool: pip picks the version, from the user's package index # ═══════════════════════════════════════════════════════════════════════ -@pytest.mark.benchmark -def test_get_pypi_versions_success(): - """Fetch versions from PyPI JSON API -- happy path.""" - _get_pypi_versions.cache_clear() - mock_data = { - "releases": { - "22.1.5": [], - "22.1.4": [], - "20.1.8": [], - "22.1.0-rc1": [], # pre-release, should be filtered - "22.1.0a3": [], # alpha, should be filtered - } - } - with patch("urllib.request.urlopen") as mock_urlopen: - mock_urlopen.return_value.__enter__.return_value.read.return_value = ( - __import__("json").dumps(mock_data).encode() - ) - latest, versions = _get_pypi_versions("clang-format") - - assert latest == "22.1.5" - assert "22.1.0-rc1" not in versions - assert "22.1.0a3" not in versions - assert versions == ["22.1.5", "22.1.4", "20.1.8"] - # Verify cache works - latest2, versions2 = _get_pypi_versions("clang-format") - assert latest2 == latest - assert versions2 == versions - - -@pytest.mark.benchmark -def test_get_pypi_versions_network_failure(): - """PyPI is unreachable -- return (None, []).""" - _get_pypi_versions.cache_clear() - with patch("urllib.request.urlopen", side_effect=OSError("network down")): - latest, versions = _get_pypi_versions("clang-format") - assert latest is None - assert versions == [] - - -@pytest.mark.benchmark -def test_get_pypi_versions_all_prerelease(): - """Only pre-release versions exist on PyPI.""" - _get_pypi_versions.cache_clear() - mock_data = {"releases": {"22.1.0-rc1": [], "22.1.0a3": []}} - with patch("urllib.request.urlopen") as mock_urlopen: - mock_urlopen.return_value.__enter__.return_value.read.return_value = ( - __import__("json").dumps(mock_data).encode() - ) - latest, versions = _get_pypi_versions("clang-tidy") - assert latest is None - assert versions == [] - - -# ═══════════════════════════════════════════════════════════════════════ -# _resolve_version_from_pypi -# ═══════════════════════════════════════════════════════════════════════ - +def test_install_tool_without_version_installs_into_environment(): + completed = subprocess.CompletedProcess(args=[], returncode=0) + with patch("subprocess.run", return_value=completed) as run: + assert _install_tool("clang-tidy", None) is None -@pytest.mark.benchmark -@pytest.mark.parametrize( - "tool,user_input,expected", - [ - # No version → latest - ("clang-format", None, "22.1.5"), - ("clang-tidy", None, "21.1.6"), - # Exact match - ("clang-format", "20.1.8", "20.1.8"), - ("clang-tidy", "19.1.0.1", "19.1.0.1"), - # Prefix match (latest for that prefix) - ("clang-format", "20", "20.1.8"), - ("clang-format", "20.1", "20.1.8"), - ("clang-tidy", "21", "21.1.6"), - ("clang-tidy", "21.1", "21.1.6"), - ], -) -def test_resolve_version_from_pypi_success(tool, user_input, expected): - with patch( - "cpp_linter_hooks.util._get_pypi_versions", side_effect=_pypi_side_effect - ): - version, error = _resolve_version_from_pypi(tool, user_input) - assert error is None - assert version == expected + run.assert_called_once_with( + [*PIP, "clang-tidy"], capture_output=True, text=True, check=False + ) -@pytest.mark.benchmark @pytest.mark.parametrize( - "tool,user_input", + ("version", "requirement"), [ - ("clang-format", "99"), - ("clang-format", "20.99"), - ("clang-tidy", "99"), - ("clang-tidy", "22.99"), + ("21", "clang-tidy==21.*"), + ("21.1", "clang-tidy==21.1.*"), + ("19.1.0", "clang-tidy==19.1.0"), ], ) -def test_resolve_version_from_pypi_not_found(tool, user_input): - with patch( - "cpp_linter_hooks.util._get_pypi_versions", side_effect=_pypi_side_effect - ): - version, error = _resolve_version_from_pypi(tool, user_input) - assert version is None - assert error is not None - assert f"Unsupported {tool} version '{user_input}'" in error - assert "Latest stable version:" in error - assert "Available versions (sample):" in error - - -@pytest.mark.benchmark -def test_resolve_version_from_pypi_network_down(): - """When PyPI is unreachable and no tool is installed, return an error.""" - with ( - patch("cpp_linter_hooks.util._get_pypi_versions", return_value=(None, [])), - patch("cpp_linter_hooks.util._detect_installed_version", return_value=None), - ): - version, error = _resolve_version_from_pypi("clang-format", None) - assert version is None - assert "Could not find any stable versions" in error - assert "network" in error.lower() - - -@pytest.mark.benchmark -def test_resolve_version_from_pypi_offline_fallback(): - """When PyPI is unreachable but the tool is pre-installed, use it.""" - with ( - patch("cpp_linter_hooks.util._get_pypi_versions", return_value=(None, [])), - patch( - "cpp_linter_hooks.util._detect_installed_version", - return_value="18.1.8", - ), - ): - version, error = _resolve_version_from_pypi("clang-format", None) - assert version == "18.1.8" - assert error is None - - -@pytest.mark.benchmark -def test_resolve_version_from_pypi_offline_no_fallback_with_version(): - """When PyPI is unreachable AND user specified a version, fail.""" - with ( - patch("cpp_linter_hooks.util._get_pypi_versions", return_value=(None, [])), - patch( - "cpp_linter_hooks.util._detect_installed_version", - return_value="18.1.8", - ), - ): - version, error = _resolve_version_from_pypi("clang-format", "20") - assert version is None - assert "Could not find any stable versions" in error - - -# ═══════════════════════════════════════════════════════════════════════ -# _detect_installed_version -# ═══════════════════════════════════════════════════════════════════════ - - -@pytest.mark.benchmark -def test_detect_installed_version_success(): - """Extract version from --version output.""" - - def patched_run(*args, **_kwargs): - return subprocess.CompletedProcess( - args, returncode=0, stdout="clang-format version 18.1.8\n" - ) - - with ( - patch("shutil.which", return_value="/usr/bin/clang-format"), - patch("subprocess.run", side_effect=patched_run), - ): - version = _detect_installed_version("clang-format") - assert version == "18.1.8" - - -@pytest.mark.benchmark -def test_detect_installed_version_not_found(): - with patch("shutil.which", return_value=None): - version = _detect_installed_version("clang-format") - assert version is None - - -@pytest.mark.benchmark -def test_detect_installed_version_subprocess_error(): - with ( - patch("shutil.which", return_value="/usr/bin/clang-format"), - patch("subprocess.run", side_effect=OSError), - ): - version = _detect_installed_version("clang-format") - assert version is None - - -# ═══════════════════════════════════════════════════════════════════════ -# _is_version_installed -# ═══════════════════════════════════════════════════════════════════════ - - -@pytest.mark.benchmark -def test_is_version_installed_not_in_path(): - with patch("shutil.which", return_value=None): - result = _is_version_installed("clang-format", "20.1.7") - assert result is None - - -@pytest.mark.benchmark -def test_is_version_installed_version_matches(): - mock_path = "/usr/bin/clang-format" - - def patched_run(*args, **kwargs): - return subprocess.CompletedProcess( - args, returncode=0, stdout="clang-format version 20.1.7" - ) - - with ( - patch("shutil.which", return_value=mock_path), - patch("subprocess.run", side_effect=patched_run), - ): - result = _is_version_installed("clang-format", "20.1.7") - assert result == Path(mock_path) - - -@pytest.mark.benchmark -def test_is_version_installed_version_mismatch(): - mock_path = "/usr/bin/clang-format" - - def patched_run(*args, **kwargs): - return subprocess.CompletedProcess( - args, returncode=0, stdout="clang-format version 22.1.0" - ) - - with ( - patch("shutil.which", return_value=mock_path), - patch("subprocess.run", side_effect=patched_run), - ): - result = _is_version_installed("clang-format", "20.1.7") - assert result is None - +def test_install_tool_lets_pip_pick_the_version(version, requirement): + """pip resolves the version, so PIP_INDEX_URL and pip.conf mirrors work.""" + target = _base() / f"clang-tidy-{version}" + leftover = target / "leftover" + leftover.mkdir(parents=True) -# ═══════════════════════════════════════════════════════════════════════ -# _install_tool -# ═══════════════════════════════════════════════════════════════════════ + def pip(command, **kwargs): + assert not leftover.exists() + return subprocess.CompletedProcess(command, returncode=0) + with patch("subprocess.run", side_effect=pip) as run: + assert _install_tool("clang-tidy", version) is None -@pytest.mark.benchmark -def test_install_tool_success(): - mock_path = "/usr/bin/clang-format" + assert run.call_args.args[0] == [*PIP, requirement, "--target", str(target)] - def patched_run(*args, **kwargs): - return subprocess.CompletedProcess(args, returncode=0) - with ( - patch("subprocess.run", side_effect=patched_run) as mock_run, - patch("shutil.which", return_value=mock_path), - ): - result = _install_tool("clang-format", "20.1.7") - assert result == mock_path - - mock_run.assert_called_once_with( - [sys.executable, "-m", "pip", "install", "clang-format==20.1.7"], - capture_output=True, - text=True, - check=False, +def test_install_tool_failure_returns_pip_error(): + completed = subprocess.CompletedProcess( + args=[], + returncode=1, + stdout="", + stderr="ERROR: No matching distribution found for clang-tidy==99.*\n", ) + with patch("subprocess.run", return_value=completed): + error = _install_tool("clang-tidy", "99") - -@pytest.mark.benchmark -def test_install_tool_failure(): - def patched_run(*args, **kwargs): - return subprocess.CompletedProcess( - args, returncode=1, stderr="Error", stdout="Installation failed" - ) - - with ( - patch("subprocess.run", side_effect=patched_run), - patch("cpp_linter_hooks.util.LOG"), - ): - result = _install_tool("clang-format", "20.1.7") - assert result is None - - -@pytest.mark.benchmark -def test_install_tool_success_but_not_found(): - def patched_run(*args, **kwargs): - return subprocess.CompletedProcess(args, returncode=0) - - with ( - patch("subprocess.run", side_effect=patched_run), - patch("shutil.which", return_value=None), - ): - result = _install_tool("clang-format", "20.1.7") - assert result is None + assert error == ( + "pip could not install clang-tidy==99.*:\n" + "ERROR: No matching distribution found for clang-tidy==99.*\n" + "Run `pip index versions clang-tidy` to see the available versions." + ) # ═══════════════════════════════════════════════════════════════════════ @@ -366,362 +111,84 @@ def patched_run(*args, **kwargs): # ═══════════════════════════════════════════════════════════════════════ -@pytest.mark.benchmark -def test_resolve_install_tool_already_installed_correct_version(): - mock_path = "/usr/bin/clang-format" - - def patched_run(*args, **kwargs): - return subprocess.CompletedProcess( - args, returncode=0, stdout="clang-format version 20.1.8" - ) - +def test_resolve_install_reports_pip_error(caplog): with ( - patch("shutil.which", return_value=mock_path), - patch("subprocess.run", side_effect=patched_run), - patch( - "cpp_linter_hooks.util._get_pypi_versions", side_effect=_pypi_side_effect - ), + patch("cpp_linter_hooks.util._install_tool", return_value="pip failed"), + caplog.at_level(logging.ERROR, logger="cpp_linter_hooks.util"), ): - result = resolve_install("clang-format", "20.1.8") - assert Path(result) == Path(mock_path) - - -@pytest.mark.benchmark -def test_resolve_install_tool_version_mismatch_reinstalls(): - mock_path = "/usr/bin/clang-format" - - def patched_run(*args, **kwargs): - return subprocess.CompletedProcess( - args, returncode=0, stdout="clang-format version 22.1.0" + assert resolve_install_with_diagnostics("clang-tidy", "99") == ( + None, + "pip failed", ) + assert resolve_install("clang-tidy", "99") is None - with ( - patch("shutil.which", return_value=mock_path), - patch("subprocess.run", side_effect=patched_run), - patch( - "cpp_linter_hooks.util._install_tool", return_value=Path(mock_path) - ) as mock_install, - patch( - "cpp_linter_hooks.util._get_pypi_versions", side_effect=_pypi_side_effect - ), - ): - result = resolve_install("clang-format", "20.1.8") - assert result == Path(mock_path) - mock_install.assert_called_once_with("clang-format", "20.1.8", versioned=True) + assert "pip failed" in caplog.text -@pytest.mark.benchmark -def test_resolve_install_tool_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, - patch( - "cpp_linter_hooks.util._get_pypi_versions", side_effect=_pypi_side_effect - ), - ): - result = resolve_install("clang-format", "20.1.8") - assert result == Path("/usr/bin/clang-format") - mock_install.assert_called_once_with("clang-format", "20.1.8", versioned=True) +def test_resolve_install_installs_wheel_once_even_with_tool_on_path(): + """A clang-tidy elsewhere on PATH is not used; the wheel is installed once.""" + def install(tool, version): + _fake_versioned_install(_base(), "clang-tidy", "18.1.8", dirname="18") -@pytest.mark.benchmark -def test_resolve_install_no_version_uses_latest(): with ( - patch("shutil.which", return_value=None), - patch( - "cpp_linter_hooks.util._install_tool", - return_value=Path("/usr/bin/clang-format"), - ) as mock_install, + patch("shutil.which", return_value="/usr/bin/clang-tidy"), + patch("cpp_linter_hooks.util._install_tool", side_effect=install) as mock, patch( - "cpp_linter_hooks.util._get_pypi_versions", side_effect=_pypi_side_effect + "cpp_linter_hooks.util._versioned_executable", + side_effect=lambda tool, target: os.path.join(target, tool), ), ): - result = resolve_install("clang-format", None) - assert result == Path("/usr/bin/clang-format") - mock_install.assert_called_once_with("clang-format", "22.1.5", versioned=False) - - -@pytest.mark.benchmark -def test_resolve_install_invalid_version(): - with ( - patch("shutil.which", return_value=None), - patch("cpp_linter_hooks.util._install_tool") as mock_install, - patch( - "cpp_linter_hooks.util._get_pypi_versions", side_effect=_pypi_side_effect - ), - ): - result = resolve_install("clang-format", "99.0.0") - assert result is None - mock_install.assert_not_called() - - -@pytest.mark.benchmark -def test_resolve_install_with_diagnostics_invalid_version(): - with patch( - "cpp_linter_hooks.util._get_pypi_versions", side_effect=_pypi_side_effect - ): - path, error = resolve_install_with_diagnostics("clang-tidy", "99") + first, _ = resolve_install_with_diagnostics("clang-tidy", "18") + second, _ = resolve_install_with_diagnostics("clang-tidy", "18") - assert path is None - assert error is not None - assert "Unsupported clang-tidy version '99'" in error - assert "Latest stable version: 21.1.6" in error - assert "Available versions (sample):" in error + assert first == second == _base() / "clang-tidy-18" / "clang-tidy" + mock.assert_called_once_with("clang-tidy", "18") -@pytest.mark.benchmark -def test_resolve_install_with_diagnostics_verbose_resolved(capsys): +def test_resolve_install_without_version_reports_installed_wheel(capsys): + found = [None, None, (Path("/venv/bin/clang-tidy"), "23.1.4")] with ( - patch("shutil.which", return_value=None), - patch( - "cpp_linter_hooks.util._install_tool", - return_value=Path("/usr/bin/clang-tidy"), - ), - patch( - "cpp_linter_hooks.util._get_pypi_versions", side_effect=_pypi_side_effect - ), + patch("cpp_linter_hooks.util._find_installed_wheel", side_effect=found), + patch("cpp_linter_hooks.util._install_tool", return_value=None) as mock, ): - path, error = resolve_install_with_diagnostics("clang-tidy", "21", True) + result = resolve_install_with_diagnostics("clang-tidy", None, True) - assert path == Path("/usr/bin/clang-tidy") - assert error is None - assert ( - "Resolved clang-tidy --version=21 to Python wheel version 21.1.6" - in capsys.readouterr().err - ) + assert result == (Path("/venv/bin/clang-tidy"), None) + mock.assert_called_once_with("clang-tidy", None) + assert "Installed clang-tidy Python wheel version 23.1.4" in capsys.readouterr().err -@pytest.mark.benchmark -def test_resolve_install_with_diagnostics_verbose_latest(capsys): +def test_resolve_install_is_quiet_by_default(capsys): + found = [None, None, (Path("/venv/bin/clang-tidy"), "23.1.4")] with ( - patch("shutil.which", return_value=None), - patch( - "cpp_linter_hooks.util._install_tool", - return_value=Path("/usr/bin/clang-format"), - ), - patch( - "cpp_linter_hooks.util._get_pypi_versions", side_effect=_pypi_side_effect - ), + patch("cpp_linter_hooks.util._find_installed_wheel", side_effect=found), + patch("cpp_linter_hooks.util._install_tool", return_value=None), ): - path, error = resolve_install_with_diagnostics("clang-format", None, True) + resolve_install_with_diagnostics("clang-tidy", None) - assert path == Path("/usr/bin/clang-format") - assert error is None - assert ( - "Using latest clang-format Python wheel version 22.1.5" - in capsys.readouterr().err - ) - - -# ═══════════════════════════════════════════════════════════════════════ -# Additional edge cases (offline: PyPI, pip and tool binaries are mocked) -# ═══════════════════════════════════════════════════════════════════════ - - -@pytest.fixture -def clear_pypi_cache(): - """Isolate tests from the per-process PyPI response cache.""" - _get_pypi_versions.cache_clear() - yield - _get_pypi_versions.cache_clear() - - -def _mock_pypi_payload(mock_urlopen, payload: bytes) -> None: - mock_urlopen.return_value.__enter__.return_value.read.return_value = payload + assert capsys.readouterr().err == "" -def test_get_pypi_versions_sorts_numerically(clear_pypi_cache): - releases = ["9.0.0", "10.0.1", "6.0.1", "19.1.0", "19.1.0.1", "18.1.8"] - with patch("urllib.request.urlopen") as mock_urlopen: - _mock_pypi_payload( - mock_urlopen, - json.dumps({"releases": {version: [] for version in releases}}).encode(), - ) - latest, versions = _get_pypi_versions("clang-tidy") +@pytest.mark.parametrize("version", ["v21", "21/../../victim", "21.*", "latest", ""]) +def test_resolve_install_rejects_invalid_version(version): + with patch("cpp_linter_hooks.util._install_tool") as mock: + path, error = resolve_install_with_diagnostics("clang-tidy", version) - mock_urlopen.assert_called_once_with( - "https://pypi.org/pypi/clang-tidy/json", timeout=10 + assert path is None + assert error == ( + f"Invalid clang-tidy --version '{version}': use a version number such as " + "21 or 21.1.8." ) - assert latest == "19.1.0.1" - assert versions == ["19.1.0.1", "19.1.0", "18.1.8", "10.0.1", "9.0.0", "6.0.1"] + mock.assert_not_called() -@pytest.mark.parametrize( - "pre_release", - ["23.1.0rc1", "23.1.0a2", "23.1.0b3", "23.1.0.dev1", "23.1.0-beta", "23.1.0alpha"], -) -def test_get_pypi_versions_skips_pre_releases(clear_pypi_cache, pre_release): - with patch("urllib.request.urlopen") as mock_urlopen: - _mock_pypi_payload( - mock_urlopen, - json.dumps({"releases": {"22.1.8": [], pre_release: []}}).encode(), +def test_resolve_install_reports_missing_executable(): + with patch("cpp_linter_hooks.util._install_tool", return_value=None): + assert resolve_install_with_diagnostics("clang-tidy", "21") == ( + None, + "pip installed clang-tidy, but its executable is missing.", ) - assert _get_pypi_versions("clang-format") == ("22.1.8", ["22.1.8"]) - - -def test_get_pypi_versions_invalid_json(clear_pypi_cache, caplog): - with ( - patch("urllib.request.urlopen") as mock_urlopen, - caplog.at_level(logging.WARNING, logger="cpp_linter_hooks.util"), - ): - _mock_pypi_payload(mock_urlopen, b"Service Unavailable") - assert _get_pypi_versions("clang-format") == (None, []) - - assert "Failed to fetch versions for clang-format from PyPI" in caplog.text - - -@pytest.mark.parametrize( - ("stdout", "expected"), - [ - ("Ubuntu clang-format version 18.1.3 (1ubuntu1)\n", "18.1.3"), - ("LLVM (http://llvm.org/):\n LLVM version 19.1.0\n", "19.1.0"), - ("clang-tidy version 22.1.0.1\n", "22.1.0.1"), - ("clang-format version unknown\n", None), - ], -) -def test_detect_installed_version_parses_version_banner(stdout, expected): - completed = subprocess.CompletedProcess(args=[], returncode=0, stdout=stdout) - with ( - patch("shutil.which", return_value="/usr/bin/clang-format"), - patch("subprocess.run", return_value=completed) as mock_run, - ): - assert _detect_installed_version("clang-format") == expected - - mock_run.assert_called_once_with( - ["/usr/bin/clang-format", "--version"], - capture_output=True, - text=True, - timeout=10, - check=False, - ) - - -def test_detect_installed_version_timeout(): - with ( - patch("shutil.which", return_value="/usr/bin/clang-format"), - patch( - "subprocess.run", - side_effect=subprocess.TimeoutExpired(cmd="clang-format", timeout=10), - ), - ): - assert _detect_installed_version("clang-format") is None - - -def test_install_tool_failure_logs_pip_output(caplog): - completed = subprocess.CompletedProcess( - args=[], - returncode=1, - stdout="Collecting clang-format==20.1.7", - stderr="ERROR: No matching distribution found for clang-format==20.1.7", - ) - with ( - patch("subprocess.run", return_value=completed), - caplog.at_level(logging.ERROR, logger="cpp_linter_hooks.util"), - ): - assert _install_tool("clang-format", "20.1.7") is None - - assert "pip failed to install clang-format 20.1.7" in caplog.text - assert "Collecting clang-format==20.1.7" in caplog.text - assert "No matching distribution found" in caplog.text - - -def test_resolve_install_with_diagnostics_verbose_exact_version(capsys): - with ( - patch( - "cpp_linter_hooks.util._get_pypi_versions", side_effect=_pypi_side_effect - ), - patch( - "cpp_linter_hooks.util._is_version_installed", - return_value=Path("/usr/bin/clang-format"), - ) as mock_installed, - patch("cpp_linter_hooks.util._install_tool") as mock_install, - ): - result = resolve_install_with_diagnostics("clang-format", "20.1.8", True) - - assert result == (Path("/usr/bin/clang-format"), None) - assert "Using clang-format Python wheel version 20.1.8" in capsys.readouterr().err - mock_installed.assert_called_once_with("clang-format", "20.1.8") - mock_install.assert_not_called() - - -def test_resolve_install_with_diagnostics_is_quiet_by_default(capsys): - with ( - patch( - "cpp_linter_hooks.util._get_pypi_versions", side_effect=_pypi_side_effect - ), - patch("cpp_linter_hooks.util._is_version_installed", return_value=None), - patch( - "cpp_linter_hooks.util._install_tool", - return_value=Path("/usr/bin/clang-tidy"), - ) as mock_install, - ): - result = resolve_install_with_diagnostics("clang-tidy", "21") - - assert result == (Path("/usr/bin/clang-tidy"), None) - assert capsys.readouterr().err == "" - mock_install.assert_called_once_with("clang-tidy", "21.1.6", versioned=True) - - -def test_resolve_install_with_diagnostics_offline_uses_installed_tool(): - with ( - patch("cpp_linter_hooks.util._get_pypi_versions", return_value=(None, [])), - patch("cpp_linter_hooks.util._detect_installed_version", return_value="18.1.8"), - patch( - "cpp_linter_hooks.util._is_version_installed", - return_value=Path("/usr/bin/clang-format"), - ) as mock_installed, - patch("cpp_linter_hooks.util._install_tool") as mock_install, - ): - result = resolve_install_with_diagnostics("clang-format", None) - - assert result == (Path("/usr/bin/clang-format"), None) - mock_installed.assert_called_once_with("clang-format", "18.1.8") - mock_install.assert_not_called() - - -def test_resolve_install_logs_unsupported_version(caplog): - with ( - patch( - "cpp_linter_hooks.util._get_pypi_versions", side_effect=_pypi_side_effect - ), - caplog.at_level(logging.ERROR, logger="cpp_linter_hooks.util"), - ): - assert resolve_install("clang-format", "99") is None - - assert "Unsupported clang-format version '99'" in caplog.text - - -@pytest.mark.parametrize( - ("tool", "user_input"), - [("clang-format", "1"), ("clang-format", "2"), ("clang-tidy", "2")], -) -def test_resolve_version_from_pypi_prefix_requires_whole_components(tool, user_input): - """A major-version prefix must not match a longer major ("2" is not "22").""" - with patch( - "cpp_linter_hooks.util._get_pypi_versions", side_effect=_pypi_side_effect - ): - version, error = _resolve_version_from_pypi(tool, user_input) - - assert version is None - assert f"Unsupported {tool} version '{user_input}'" in error - - -@pytest.mark.parametrize( - ("user_input", "expected"), - [("21.1", "21.1.6"), ("21", "21.10.0"), ("19.1.0", "19.1.0.1")], -) -def test_resolve_version_from_pypi_prefix_stops_at_component_boundary( - user_input, expected -): - versions = ["21.10.0", "21.1.6", "19.1.0.1"] - with patch( - "cpp_linter_hooks.util._get_pypi_versions", - return_value=(versions[0], versions), - ): - assert _resolve_version_from_pypi("clang-tidy", user_input) == (expected, None) # ═══════════════════════════════════════════════════════════════════════ @@ -830,21 +297,6 @@ def test_find_installed_wheel_ignores_other_tools_on_path(): assert _find_installed_wheel("clang-tidy", None) is None -def test_install_tool_prefers_environment_scripts_dir(): - completed = subprocess.CompletedProcess(args=[], returncode=0) - with ( - patch("subprocess.run", return_value=completed), - patch("cpp_linter_hooks.util._env_paths", return_value=_paths("/venv/bin")), - patch( - "shutil.which", - side_effect=lambda tool, path=None: ( - "/venv/bin/clang-tidy" if path == "/venv/bin" else "/usr/bin/clang-tidy" - ), - ), - ): - assert _install_tool("clang-tidy", "21.1.6") == "/venv/bin/clang-tidy" - - @pytest.mark.parametrize( ("installed", "requested"), [(None, "21"), ("23.1.3", "21"), ("21.1.6", "21.1.1")] ) @@ -865,70 +317,32 @@ def test_find_installed_wheel_without_executable(): @pytest.mark.parametrize("requested", [None, "21", "21.1.6"]) -def test_resolve_install_uses_matching_wheel_without_pypi(requested): +def test_resolve_install_uses_matching_wheel_without_network(requested): """A matching wheel (e.g. from additional_dependencies) needs no network.""" with ( patch( "cpp_linter_hooks.util._find_installed_wheel", return_value=(Path("/venv/bin/clang-tidy"), "21.1.6"), ), - patch("cpp_linter_hooks.util._get_pypi_versions") as mock_pypi, patch("cpp_linter_hooks.util._install_tool") as mock_install, ): result = resolve_install_with_diagnostics("clang-tidy", requested) assert result == (Path("/venv/bin/clang-tidy"), None) - mock_pypi.assert_not_called() mock_install.assert_not_called() -def test_resolve_install_unpinned_keeps_installed_wheel_when_pypi_is_newer(): +def test_resolve_install_unpinned_keeps_installed_wheel(): """Without --version an installed wheel is not upgraded behind the user's back.""" with ( patch("cpp_linter_hooks.util._installed_wheel_version", return_value="21.1.6"), patch("shutil.which", return_value="/venv/bin/clang-tidy"), - patch( - "cpp_linter_hooks.util._get_pypi_versions", - return_value=("23.1.3", ["23.1.3", "21.1.6"]), - ) as mock_pypi, - patch("cpp_linter_hooks.util._install_tool") as mock_install, + patch("subprocess.run") as run, ): path, error = resolve_install_with_diagnostics("clang-tidy", None) assert (path, error) == (Path("/venv/bin/clang-tidy"), None) - mock_pypi.assert_not_called() - mock_install.assert_not_called() - - -def test_resolve_install_offline_with_pinned_installed_wheel(): - """--version works offline when the hook environment already has the wheel.""" - with ( - patch("cpp_linter_hooks.util._installed_wheel_version", return_value="21.1.6"), - patch("shutil.which", return_value="/venv/bin/clang-tidy"), - patch("urllib.request.urlopen", side_effect=OSError("network down")), - ): - assert resolve_install_with_diagnostics("clang-tidy", "21") == ( - Path("/venv/bin/clang-tidy"), - None, - ) - - -def test_resolve_install_other_installed_wheel_resolves_from_pypi(): - with ( - patch("cpp_linter_hooks.util._installed_wheel_version", return_value="23.1.3"), - patch("shutil.which", return_value=None), - patch( - "cpp_linter_hooks.util._get_pypi_versions", side_effect=_pypi_side_effect - ), - patch( - "cpp_linter_hooks.util._install_tool", - return_value=Path("/venv/bin/clang-tidy"), - ) as mock_install, - ): - result = resolve_install_with_diagnostics("clang-tidy", "21") - - assert result == (Path("/venv/bin/clang-tidy"), None) - mock_install.assert_called_once_with("clang-tidy", "21.1.6", versioned=True) + run.assert_not_called() def test_resolve_install_verbose_reports_installed_wheel(capsys): @@ -1037,21 +451,18 @@ def __enter__(self): def __exit__(self, *exc): events.append("unlock") - def install(tool, version, versioned): + def install(tool, version): events.append("install") - return Path("/venv/bin/clang-format") + _fake_versioned_install(_base(), "clang-format", "20.1.8", dirname="20") with ( patch("cpp_linter_hooks.util._install_lock", return_value=Lock()), - patch("shutil.which", return_value=None), - patch( - "cpp_linter_hooks.util._get_pypi_versions", side_effect=_pypi_side_effect - ), patch("cpp_linter_hooks.util._install_tool", side_effect=install), ): - result = resolve_install_with_diagnostics("clang-format", "20") + path, error = resolve_install_with_diagnostics("clang-format", "20") - assert result == (Path("/venv/bin/clang-format"), None) + assert error is None + assert path.parts[-5:-3] == ("clang-format-20", "clang_format") assert events == ["lock", "install", "unlock"] @@ -1076,13 +487,11 @@ def test_resolve_install_uses_wheel_installed_while_waiting(): "cpp_linter_hooks.util._find_installed_wheel", side_effect=[None, (Path("/venv/bin/clang-format"), "20.1.8")], ), - patch("cpp_linter_hooks.util._get_pypi_versions") as pypi, patch("cpp_linter_hooks.util._install_tool") as install, ): result = resolve_install_with_diagnostics("clang-format", "20") assert result == (Path("/venv/bin/clang-format"), None) - pypi.assert_not_called() install.assert_not_called() @@ -1099,10 +508,19 @@ def _base() -> Path: def _fake_versioned_install( - base: Path, tool: str, version: str, bin_dir=("data", "bin"), record=True + base: Path, + tool: str, + version: str, + bin_dir=("data", "bin"), + record=True, + dirname=None, ) -> Path: - """Lay out what ``pip install --target`` leaves for a clang tool wheel.""" - target = base / f"{tool}-{version}" + """Lay out what ``pip install --target`` leaves for a clang tool wheel. + + The directory is named after the requested version (*dirname*), which + defaults to the installed one. + """ + target = base / f"{tool}-{dirname or version}" package = target / tool.replace("-", "_") executable = package.joinpath(*bin_dir) / f"{tool}{EXE}" executable.parent.mkdir(parents=True) @@ -1188,45 +606,21 @@ def test_find_installed_prefers_environment_wheel(): ) -def test_install_tool_versioned_uses_own_directory(): - base = _base() - target = base / "clang-format-18.1.8" - leftover = target / "leftover" - leftover.mkdir(parents=True) +def test_find_versioned_install_reads_version_from_dist_info(): + """--version=21 installs into clang-tidy-21; a later --version=21.1.6 reuses it.""" + executable = _fake_versioned_install(_base(), "clang-tidy", "21.1.6", dirname="21") + assert _find_versioned_install("clang-tidy", "21") == (executable, "21.1.6") + assert _find_versioned_install("clang-tidy", "21.1.6") == (executable, "21.1.6") + assert _find_versioned_install("clang-tidy", "21.1.1") is None - def pip(command, **kwargs): - assert not leftover.exists() - _fake_versioned_install(base, "clang-format", "18.1.8") - return subprocess.CompletedProcess(command, returncode=0) - with patch("subprocess.run", side_effect=pip) as run: - result = _install_tool("clang-format", "18.1.8", versioned=True) - - assert Path(result) == ( - target / "clang_format" / "data" / "bin" / f"clang-format{EXE}" - ) - assert run.call_args.args[0] == [ - sys.executable, - "-m", - "pip", - "install", - "clang-format==18.1.8", - "--target", - str(target), - ] - - -def test_resolve_install_versions_coexist_without_pypi(): +def test_resolve_install_versions_coexist_without_network(): """Switching --version between projects reuses each install, offline.""" base = _base() v18 = _fake_versioned_install(base, "clang-format", "18.1.8") - v21 = _fake_versioned_install(base, "clang-format", "21.1.8") - with ( - patch("cpp_linter_hooks.util._get_pypi_versions") as pypi, - patch("cpp_linter_hooks.util._install_tool") as install, - ): + v21 = _fake_versioned_install(base, "clang-format", "21.1.8", dirname="21") + with patch("subprocess.run") as run: assert resolve_install("clang-format", "18") == v18 assert resolve_install("clang-format", "21") == v21 assert resolve_install("clang-format", "18.1.8") == v18 - pypi.assert_not_called() - install.assert_not_called() + run.assert_not_called()