Repository: cpp-linter/cpp-linter-hooks

Issue:
`_install_tool()` needs to output pip's stderr and stdout
https://github.com/cpp-linter/cpp-linter-hooks/blob/069417d0de26fa6123ae7fdbe04555e7779e4122/cpp_linter_hooks/util.py#L62-L72

It seems this function fails with `_install_tool("clang-format", "9")`. It would be nice to know why, but this function does not forward any output from pip when invoking pip exits with non-zero code.

## Proposal
For a full descriptiopn of what happened:
```py
    try:
        subprocess.run(
            [sys.executable, "-m", "pip", "install", f"{tool}=={version}"],
            capture=True,
            check=True,
        )
        return shutil.which(tool)
    except subprocess.CalledProcessError as exc: 
        LOG.error("pip failed to install %s %s", tool, version)
        LOG.error(exc.stdout.decode(encoding="utf-8"))
        LOG.error(exc.stderr.decode(encoding="utf-8"))
        return None
```

Discussion:

Fix (patch):
diff --git a/cpp_linter_hooks/util.py b/cpp_linter_hooks/util.py
index ebd48a3..cfd4209 100644
--- a/cpp_linter_hooks/util.py
+++ b/cpp_linter_hooks/util.py
@@ -5,9 +5,9 @@
 import logging
 from typing import Optional, List
 
-try:
+if sys.version_info >= (3, 11):
     import tomllib
-except ModuleNotFoundError:
+else:
     import tomli as tomllib
 
 from cpp_linter_hooks.versions import CLANG_FORMAT_VERSIONS, CLANG_TIDY_VERSIONS
@@ -60,16 +60,18 @@ def parse_version(v: str):
 
 
 def _install_tool(tool: str, version: str) -> Optional[Path]:
-    """Install a tool using pip, suppressing output."""
-    try:
-        subprocess.check_call(
-            [sys.executable, "-m", "pip", "install", f"{tool}=={version}"],
-            stdout=subprocess.DEVNULL,
-            stderr=subprocess.DEVNULL,
-        )
+    """Install a tool using pip, logging output on failure."""
+    result = subprocess.run(
+        [sys.executable, "-m", "pip", "install", f"{tool}=={version}"],
+        capture_output=True,
+        text=True,
+    )
+    if result.returncode == 0:
         return shutil.which(tool)
-    except subprocess.CalledProcessError:
-        return None
+    LOG.error("pip failed to install %s %s", tool, version)
+    LOG.error(result.stdout)
+    LOG.error(result.stderr)
+    return None
 
 
 def resolve_install(tool: str, version: Optional[str]) -> Optional[Path]:


Test changes:
diff --git a/tests/test_util.py b/tests/test_util.py
index 8305ba0..3414f53 100644
--- a/tests/test_util.py
+++ b/tests/test_util.py
@@ -123,28 +123,34 @@ def test_install_tool_success():
     """Test _install_tool successful installation."""
     mock_path = "/usr/bin/clang-format"
 
+    def patched_run(*args, **kwargs):
+        return subprocess.CompletedProcess(args, returncode=0)
+
     with (
-        patch("subprocess.check_call") as mock_check_call,
+        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_check_call.assert_called_once_with(
+        mock_run.assert_called_once_with(
             [sys.executable, "-m", "pip", "install", "clang-format==20.1.7"],
-            stdout=subprocess.DEVNULL,
-            stderr=subprocess.DEVNULL,
+            capture_output=True,
+            text=True,
         )
 
 
 @pytest.mark.benchmark
 def test_install_tool_failure():
     """Test _install_tool when pip install fails."""
+
+    def patched_run(*args, **kwargs):
+        return subprocess.CompletedProcess(
+            args, returncode=1, stderr="Error", stdout="Installation failed"
+        )
+
     with (
-        patch(
-            "subprocess.check_call",
-            side_effect=subprocess.CalledProcessError(1, ["pip"]),
-        ),
+        patch("subprocess.run", side_effect=patched_run),
         patch("cpp_linter_hooks.util.LOG"),
     ):
         result = _install_tool("clang-format", "20.1.7")
@@ -154,7 +160,14 @@ def test_install_tool_failure():
 @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):
+
+    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