Skip to content

Commit 1ddca97

Browse files
committed
fix: add offline fallback when PyPI is unreachable
When no explicit --version is requested and PyPI is unreachable, fall back to whatever version of the tool is already installed locally instead of failing immediately. This preserves the pre-existing behavior where pre-installed tools worked offline. Only applies when version=None (auto-detect). When the user specifies a specific --version, PyPI must be reachable to verify it exists and resolve prefix matches. Also fix EN DASH characters in test docstrings (Ruff RUF002). New: - _detect_installed_version() helper with version regex extraction - 5 new tests: offline fallback (happy path, no tool, with version), detect installed version tests
1 parent a0d1939 commit 1ddca97

2 files changed

Lines changed: 109 additions & 2 deletions

File tree

cpp_linter_hooks/util.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,10 +56,24 @@ def _resolve_version_from_pypi(
5656
5757
Returns (resolved_version, error_message). The error_message is
5858
suitable for displaying directly to the end user.
59+
60+
When PyPI is unreachable and no explicit version was requested,
61+
falls back to whatever version is already installed on the host
62+
so that pre-installed tools keep working offline.
5963
"""
6064
latest, versions = _get_pypi_versions(tool)
6165

6266
if not versions:
67+
if user_input is None:
68+
# PyPI is unreachable, but the user didn't ask for a specific
69+
# version – try the locally installed tool as a fallback.
70+
installed = _detect_installed_version(tool)
71+
if installed:
72+
LOG.info(
73+
"PyPI unreachable; using locally installed %s %s",
74+
tool, installed,
75+
)
76+
return installed, None
6377
return (
6478
None,
6579
f"Could not find any stable versions of {tool} on PyPI. "
@@ -90,6 +104,26 @@ def _resolve_version_from_pypi(
90104
)
91105

92106

107+
def _detect_installed_version(tool: str) -> Optional[str]:
108+
"""Return the version of *tool* already on PATH, or None.
109+
110+
Used as a fallback when PyPI is unreachable and no explicit version
111+
was requested. Extracts the version string from ``<tool> --version``
112+
output (e.g. ``"clang-format version 18.1.8"`` → ``"18.1.8"``).
113+
"""
114+
existing = shutil.which(tool)
115+
if not existing:
116+
return None
117+
try:
118+
result = subprocess.run(
119+
[existing, "--version"], capture_output=True, text=True, timeout=10
120+
)
121+
except (OSError, subprocess.TimeoutExpired):
122+
return None
123+
match = re.search(r"(\d+\.\d+\.\d+(?:\.\d+)?)", result.stdout)
124+
return match.group(1) if match else None
125+
126+
93127
def _is_version_installed(tool: str, version: str) -> Optional[Path]:
94128
"""Return the tool path if the installed version matches, otherwise None."""
95129
existing = shutil.which(tool)

tests/test_util.py

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from cpp_linter_hooks.util import (
1010
_get_pypi_versions,
1111
_resolve_version_from_pypi,
12+
_detect_installed_version,
1213
_is_version_installed,
1314
_install_tool,
1415
resolve_install_with_diagnostics,
@@ -153,14 +154,86 @@ def test_resolve_version_from_pypi_not_found(tool, user_input):
153154

154155
@pytest.mark.benchmark
155156
def test_resolve_version_from_pypi_network_down():
156-
"""When PyPI is unreachable, return a user-friendly error."""
157-
with patch("cpp_linter_hooks.util._get_pypi_versions", return_value=(None, [])):
157+
"""When PyPI is unreachable and no tool is installed, return an error."""
158+
with (
159+
patch("cpp_linter_hooks.util._get_pypi_versions", return_value=(None, [])),
160+
patch("cpp_linter_hooks.util._detect_installed_version", return_value=None),
161+
):
158162
version, error = _resolve_version_from_pypi("clang-format", None)
159163
assert version is None
160164
assert "Could not find any stable versions" in error
161165
assert "network" in error.lower()
162166

163167

168+
@pytest.mark.benchmark
169+
def test_resolve_version_from_pypi_offline_fallback():
170+
"""When PyPI is unreachable but the tool is pre-installed, use it."""
171+
with (
172+
patch("cpp_linter_hooks.util._get_pypi_versions", return_value=(None, [])),
173+
patch(
174+
"cpp_linter_hooks.util._detect_installed_version",
175+
return_value="18.1.8",
176+
),
177+
):
178+
version, error = _resolve_version_from_pypi("clang-format", None)
179+
assert version == "18.1.8"
180+
assert error is None
181+
182+
183+
@pytest.mark.benchmark
184+
def test_resolve_version_from_pypi_offline_no_fallback_with_version():
185+
"""When PyPI is unreachable AND user specified a version, fail."""
186+
with (
187+
patch("cpp_linter_hooks.util._get_pypi_versions", return_value=(None, [])),
188+
patch(
189+
"cpp_linter_hooks.util._detect_installed_version",
190+
return_value="18.1.8",
191+
),
192+
):
193+
version, error = _resolve_version_from_pypi("clang-format", "20")
194+
assert version is None
195+
assert "Could not find any stable versions" in error
196+
197+
198+
# ═══════════════════════════════════════════════════════════════════════
199+
# _detect_installed_version
200+
# ═══════════════════════════════════════════════════════════════════════
201+
202+
203+
@pytest.mark.benchmark
204+
def test_detect_installed_version_success():
205+
"""Extract version from --version output."""
206+
207+
def patched_run(*args, **_kwargs):
208+
return subprocess.CompletedProcess(
209+
args, returncode=0, stdout="clang-format version 18.1.8\n"
210+
)
211+
212+
with (
213+
patch("shutil.which", return_value="/usr/bin/clang-format"),
214+
patch("subprocess.run", side_effect=patched_run),
215+
):
216+
version = _detect_installed_version("clang-format")
217+
assert version == "18.1.8"
218+
219+
220+
@pytest.mark.benchmark
221+
def test_detect_installed_version_not_found():
222+
with patch("shutil.which", return_value=None):
223+
version = _detect_installed_version("clang-format")
224+
assert version is None
225+
226+
227+
@pytest.mark.benchmark
228+
def test_detect_installed_version_subprocess_error():
229+
with (
230+
patch("shutil.which", return_value="/usr/bin/clang-format"),
231+
patch("subprocess.run", side_effect=OSError),
232+
):
233+
version = _detect_installed_version("clang-format")
234+
assert version is None
235+
236+
164237
# ═══════════════════════════════════════════════════════════════════════
165238
# _is_version_installed
166239
# ═══════════════════════════════════════════════════════════════════════

0 commit comments

Comments
 (0)