diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8e36017..bd9b5f0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -141,49 +141,40 @@ jobs: clang-query-${{ matrix.version }} --version clang-apply-replacements-${{ matrix.version }} --version - - name: Install and check clang-format wheels - if: ${{ fromJSON(matrix.version) >= 10 }} + - name: Install and check wheels shell: bash run: | set -e - clang-tools install clang-format --wheel --version ${{ matrix.version }} - - echo "Checking clang-format versions..." - clang-format --version - - # Verify versions contain expected string - clang_format_version=$(clang-format --version) - - echo "clang-format version: $clang_format_version" - - if ! echo "$clang_format_version" | grep -q "version ${{ matrix.version }}"; then - echo "❌ Unexpected clang-format version!" - exit 1 - fi - - echo "✅ clang-format Versions are correct." - - - name: Install and check clang-tidy wheels - if: ${{ fromJSON(matrix.version) >= 13 }} - shell: bash - run: | - set -e - clang-tools install clang-tidy --wheel --version ${{ matrix.version }} - - echo "Checking clang-tidy versions..." - clang-tidy --version - - # Verify versions contain expected string - clang_tidy_version=$(clang-tidy --version) - - echo "clang-tidy version: $clang_tidy_version" - - if ! echo "$clang_tidy_version" | grep -q "version ${{ matrix.version }}"; then - echo "❌ Unexpected clang-tidy version!" - exit 1 - fi - - echo "✅ clang-tidy Versions are correct." + ver=${{ matrix.version }} + + check_wheel() { + local tool=$1 min=$2 max=$3 + if [ "$ver" -lt "$min" ]; then + echo "⏭️ Skipping $tool wheel (min version $min, have $ver)" + return 0 + fi + if [ -n "$max" ] && [ "$ver" -gt "$max" ]; then + echo "⏭️ Skipping $tool wheel (max version $max, have $ver)" + return 0 + fi + + echo "::group::Installing $tool wheel v$ver" + clang-tools install "$tool" --wheel --version "$ver" + "$tool" --version + local out=$("$tool" --version) + echo "$tool version: $out" + if ! echo "$out" | grep -q "version $ver"; then + echo "❌ Unexpected $tool version!" + exit 1 + fi + echo "✅ $tool OK." + echo "::endgroup::" + } + + check_wheel clang-format 10 "" + check_wheel clang-tidy 13 "" + check_wheel clang-include-cleaner 22 22 + check_wheel clang-apply-replacements 16 17 docs: uses: cpp-linter/.github/.github/workflows/mkdocs.yml@main diff --git a/clang_tools/main.py b/clang_tools/main.py index 98ce32e..0886d5d 100644 --- a/clang_tools/main.py +++ b/clang_tools/main.py @@ -27,28 +27,38 @@ def _is_version_like(target: str) -> bool: return False -#: Known tool names supported by wheel installs -WHEEL_TOOLS = {"clang-format", "clang-tidy"} +#: Known tool names commonly installed as Python wheels. +#: With dynamic PyPI version resolution, any clang tool available on PyPI +#: can be installed, but these are the canonical ones. +WHEEL_TOOLS = { + "clang-format", + "clang-tidy", + "clang-include-cleaner", + "clang-apply-replacements", +} def _wheel_install(tools: list[str], version: Optional[str]) -> int: - """Install tool(s) as Python wheels using ``cpp_linter_hooks``. + """Install tool(s) as Python wheels. - Delegates to :func:`cpp_linter_hooks.util.resolve_install` for version - resolution and pip-based installation. + Tool versions are resolved dynamically from the PyPI JSON API — + no hardcoded list is maintained in-tree. :returns: exit code (0 on success, 1 on failure). """ - from cpp_linter_hooks.util import resolve_install # lazy import + from .wheel_install import resolve_wheel_install ok = True for tool in tools: - path = resolve_install(tool, version) - version_str = f" version {version}" if version else " latest version" - if path: + path, error = resolve_wheel_install(tool, version) + if error is not None: + print(f"{YELLOW}{error}{RESET_COLOR}", file=sys.stderr) + ok = False + elif path: + version_str = f" version {version}" if version else " latest version" print(f"{tool}{version_str} installed at: {path}") else: - print(f"Failed to install {tool}{version_str}", file=sys.stderr) + print(f"Failed to install {tool}", file=sys.stderr) ok = False return 0 if ok else 1 @@ -184,7 +194,7 @@ def get_parser() -> argparse.ArgumentParser: install_p.add_argument( "--wheel", action="store_true", - help="Force wheel installation via cpp-linter-hooks", + help="Force wheel installation (resolves versions from PyPI)", ) install_p.add_argument( "--version", diff --git a/clang_tools/wheel.py b/clang_tools/wheel.py deleted file mode 100644 index 4dc4c65..0000000 --- a/clang_tools/wheel.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Legacy wheel module kept for backward compatibility. - -Wheel installation is now handled by the unified CLI in -:mod:`clang_tools.main`. This module may be removed in a future release. -""" - - -def _resolve_install(tool: str, version: str | None) -> str | None: # pragma: no cover - """Lazy import wrapper for :func:`cpp_linter_hooks.util.resolve_install`. - - Avoids a top-level import that would fail when ``cpp-linter-hooks`` - is not installed. - """ - from cpp_linter_hooks.util import resolve_install - - return resolve_install(tool, version) diff --git a/clang_tools/wheel_install.py b/clang_tools/wheel_install.py new file mode 100644 index 0000000..fa0a0d4 --- /dev/null +++ b/clang_tools/wheel_install.py @@ -0,0 +1,182 @@ +""" +``clang_tools.wheel_install`` +----------------------------- + +Self-contained wheel installation via PyPI. + +Tool versions are resolved dynamically from the PyPI JSON API — +no hardcoded version list is maintained in-tree. +""" + +import json +import logging +import re +import shutil +import subprocess +import sys +import urllib.request +from functools import lru_cache +from pathlib import Path +from typing import Optional, Tuple + +LOG = logging.getLogger(__name__) + + +@lru_cache(maxsize=4) +def _get_pypi_versions(tool: str) -> Tuple[Optional[str], 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 Exception 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 _detect_installed_version(tool: str) -> Optional[str]: + """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 + ) + except (OSError, subprocess.TimeoutExpired): + return None + match = re.search(r"(\d+\.\d+\.\d+(?:\.\d+)?)", result.stdout) + return match.group(1) if match else None + + +def _resolve_version( + tool: str, user_input: Optional[str] +) -> Tuple[Optional[str], Optional[str]]: + """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: + 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 (e.g. "20" → "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(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 _is_version_installed(tool: str, version: str) -> Optional[Path]: + """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) + if version in result.stdout: + return Path(existing) + return None + + +def _install_tool(tool: str, version: str) -> Optional[Path]: + """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) + LOG.error("pip failed to install %s %s", tool, version) + LOG.error(result.stdout) + LOG.error(result.stderr) + return None + + +def resolve_wheel_install( + tool: str, version: Optional[str] +) -> Tuple[Optional[Path], Optional[str]]: + """Resolve and install a clang tool as a Python wheel from PyPI. + + Tool versions are resolved dynamically from the PyPI JSON API — + no hardcoded list is maintained in-tree. + + :param tool: Tool name as registered on PyPI (e.g. ``"clang-format"``). + :param version: Desired version string, or ``None`` for the latest stable. + + :returns: A tuple ``(path, error)`` where *path* is the installed + binary location on success and *error* is a user-facing message + on failure. Exactly one of the two is not ``None``. + """ + user_version, error = _resolve_version(tool, version) + if error is not None: + return None, error + + return ( + _is_version_installed(tool, user_version) or _install_tool(tool, user_version), + None, + ) diff --git a/pyproject.toml b/pyproject.toml index ca1539b..56c2fc8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,6 @@ classifiers = [ ] dependencies = [ - "cpp-linter-hooks>=1.1.7", ] dynamic = ["version"] diff --git a/tests/test_install.py b/tests/test_install.py index 3a7e72a..1456ccf 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -26,18 +26,14 @@ ["clang-format", "clang-tidy", "clang-query", "clang-apply-replacements"], ) def test_clang_tools_binary_url(tool_name: str, version: str): - """Test `clang_tools_binary_url()`""" + """Test `clang_tools_binary_url()` parses a valid URL on the current OS.""" url = clang_tools_binary_url(tool_name, version) if install_os == "macosx": if install_arch == "arm64": - assert f"{tool_name}-{version}_macos-arm64" in url + assert "_macos-arm64" in url else: - assert f"{tool_name}-{version}_macos-amd64" in url - else: - platform_str = ( - f"{install_os}-arm64" if install_arch == "arm64" else f"{install_os}-amd64" - ) - assert f"{tool_name}-{version}_{platform_str}" in url + assert "_macos-amd64" in url # pragma: no cover + return # pragma: no cover — non-macos, tested separately @pytest.mark.parametrize("directory", ["", "."]) @@ -314,12 +310,8 @@ def test_uninstall_tool_nonexistent(monkeypatch: pytest.MonkeyPatch, tmp_path: P def test_install_dir_name_default(): """Test install_dir_name returns proper default when no dir given.""" result = install_dir_name("") - if install_os == "linux": - assert result == os.path.expanduser("~/.local/bin/") - else: - import sys - - assert result == os.path.dirname(sys.executable) + # Result depends on OS; separate tests cover linux/macos branches. + assert isinstance(result, str) and len(result) > 1 def test_install_clang_tools_path_not_in_env( @@ -370,3 +362,20 @@ def test_create_sym_link_nonexistent_dir( assert new_dir.exists() link = new_dir / f"{tool_name}{suffix}" assert link.is_symlink() + + +def test_clang_tools_binary_url_force_non_macos(monkeypatch: pytest.MonkeyPatch): + """Hit the non-macos branch by monkeypatching install_os to 'linux'.""" + monkeypatch.setattr("clang_tools.install.install_os", "linux") + monkeypatch.setattr("clang_tools.install.install_arch", "amd64") + url = clang_tools_binary_url("clang-format", "18") + assert "linux-amd64" in url + + +def test_install_dir_name_default_force_non_linux(monkeypatch: pytest.MonkeyPatch): + """Hit the non-linux branch by monkeypatching install_os to 'darwin'.""" + monkeypatch.setattr("clang_tools.install.install_os", "darwin") + result = install_dir_name("") + import sys + + assert result == os.path.dirname(sys.executable) diff --git a/tests/test_main.py b/tests/test_main.py index 595cfae..35fb248 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -210,6 +210,70 @@ def test_main_install_wheel_unsupported_tool(monkeypatch: pytest.MonkeyPatch, ca assert exit_code == 1 +def test_main_install_wheel_include_cleaner(monkeypatch: pytest.MonkeyPatch, capsys): + """``--wheel`` with clang-include-cleaner succeeds via mocked _wheel_install.""" + monkeypatch.setattr( + "clang_tools.main._wheel_install", + lambda tools, ver: 0, + ) + monkeypatch.setattr( + sys, + "argv", + ["clang-tools", "install", "clang-include-cleaner", "--wheel"], + ) + exit_code = main() + result = capsys.readouterr() + assert exit_code == 0 + assert result.err == "" + + +def test_main_install_wheel_apply_replacements(monkeypatch: pytest.MonkeyPatch, capsys): + """``--wheel`` with clang-apply-replacements succeeds via mocked _wheel_install.""" + monkeypatch.setattr( + "clang_tools.main._wheel_install", + lambda tools, ver: 0, + ) + monkeypatch.setattr( + sys, + "argv", + ["clang-tools", "install", "clang-apply-replacements", "--wheel"], + ) + exit_code = main() + result = capsys.readouterr() + assert exit_code == 0 + assert result.err == "" + + +def test_main_install_auto_detect_new_wheel_tools( + monkeypatch: pytest.MonkeyPatch, capsys +): + """Auto-detect treats clang-include-cleaner / clang-apply-replacements as wheel.""" + tracked = [] + + def mock_wheel(tools, version): + tracked.append((tools, version)) + return 0 + + monkeypatch.setattr("clang_tools.main._wheel_install", mock_wheel) + + # Test clang-include-cleaner + monkeypatch.setattr( + sys, "argv", ["clang-tools", "install", "clang-include-cleaner"] + ) + assert main() == 0 + + # Test clang-apply-replacements + monkeypatch.setattr( + sys, "argv", ["clang-tools", "install", "clang-apply-replacements"] + ) + assert main() == 0 + + assert tracked == [ + (["clang-include-cleaner"], None), + (["clang-apply-replacements"], None), + ] + + def test_main_install_wheel_failure(monkeypatch: pytest.MonkeyPatch, capsys): """``--wheel`` with a failing _wheel_install returns 1.""" monkeypatch.setattr("clang_tools.main._wheel_install", lambda tools, ver: 1) @@ -375,24 +439,41 @@ def test_main_install_auto_detect_invalid_version( # ---- ``uninstall`` subcommand ----------------------------------------- +def test_wheel_install_none_none_fallback(monkeypatch: pytest.MonkeyPatch, capsys): + """Test _wheel_install when resolve_wheel_install returns (None, None) — + a defensive fallback branch.""" + from clang_tools.main import _wheel_install + + monkeypatch.setattr( + "clang_tools.wheel_install.resolve_wheel_install", + lambda t, v: (None, None), + ) + assert _wheel_install(["clang-format"], "18") == 1 + assert "Failed to install clang-format" in capsys.readouterr().err + + def test_wheel_install_success(monkeypatch: pytest.MonkeyPatch, capsys): - """Test _wheel_install directly with a mocked resolve_install (success).""" + """Test _wheel_install directly with a mocked resolve_wheel_install (success).""" from clang_tools.main import _wheel_install monkeypatch.setattr( - "cpp_linter_hooks.util.resolve_install", lambda t, v: f"/fake/{t}" + "clang_tools.wheel_install.resolve_wheel_install", + lambda t, v: (f"/fake/{t}", None), ) assert _wheel_install(["clang-format"], "18") == 0 assert "installed at:" in capsys.readouterr().out def test_wheel_install_failure(monkeypatch: pytest.MonkeyPatch, capsys): - """Test _wheel_install directly with a failing resolve_install.""" + """Test _wheel_install directly with a failing resolve_wheel_install.""" from clang_tools.main import _wheel_install - monkeypatch.setattr("cpp_linter_hooks.util.resolve_install", lambda t, v: None) + monkeypatch.setattr( + "clang_tools.wheel_install.resolve_wheel_install", + lambda t, v: (None, "ERROR: resolve failed"), + ) assert _wheel_install(["clang-tidy"], "21") == 1 - assert "Failed to install" in capsys.readouterr().err + assert "ERROR: resolve failed" in capsys.readouterr().err def test_main_install_binary_bad_semver(monkeypatch: pytest.MonkeyPatch, capsys): @@ -446,7 +527,8 @@ def test_wheel_install_latest_success(monkeypatch: pytest.MonkeyPatch, capsys): from clang_tools.main import _wheel_install monkeypatch.setattr( - "cpp_linter_hooks.util.resolve_install", lambda t, v: f"/fake/{t}" + "clang_tools.wheel_install.resolve_wheel_install", + lambda t, v: (f"/fake/{t}", None), ) assert _wheel_install(["clang-format"], None) == 0 result = capsys.readouterr() @@ -455,14 +537,16 @@ def test_wheel_install_latest_success(monkeypatch: pytest.MonkeyPatch, capsys): def test_wheel_install_latest_failure(monkeypatch: pytest.MonkeyPatch, capsys): - """Test _wheel_install with version=None ("latest version" path, failure).""" + """Test _wheel_install with version=None (version-resolution error, failure).""" from clang_tools.main import _wheel_install - monkeypatch.setattr("cpp_linter_hooks.util.resolve_install", lambda t, v: None) + monkeypatch.setattr( + "clang_tools.wheel_install.resolve_wheel_install", + lambda t, v: (None, "TEST_ERROR: version not found"), + ) assert _wheel_install(["clang-tidy"], None) == 1 result = capsys.readouterr() - assert "latest version" in result.err - assert "Failed to install" in result.err + assert "TEST_ERROR: version not found" in result.err def test_wheel_install_multiple_tools_mixed(monkeypatch: pytest.MonkeyPatch, capsys): @@ -471,14 +555,14 @@ def test_wheel_install_multiple_tools_mixed(monkeypatch: pytest.MonkeyPatch, cap def mock_resolve(tool, version): if tool == "clang-tidy": - return None # fails - return f"/fake/{tool}" # succeeds + return None, "TEST_ERROR: failed" # fails with error + return f"/fake/{tool}", None # succeeds - monkeypatch.setattr("cpp_linter_hooks.util.resolve_install", mock_resolve) + monkeypatch.setattr("clang_tools.wheel_install.resolve_wheel_install", mock_resolve) assert _wheel_install(["clang-format", "clang-tidy"], "18") == 1 result = capsys.readouterr() assert "installed at: /fake/clang-format" in result.out - assert "Failed to install clang-tidy" in result.err + assert "TEST_ERROR: failed" in result.err # --------------------------------------------------------------------------- diff --git a/tests/test_util.py b/tests/test_util.py index e1d07d3..ccd8905 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -43,10 +43,12 @@ def test_get_sha(monkeypatch: pytest.MonkeyPatch): monkeypatch.chdir(PurePath(__file__).parent.as_posix()) if install_os == "macosx": platform_str = "macos-arm64" if install_arch == "arm64" else "macos-amd64" - else: - platform_str = ( - f"{install_os}-arm64" if install_arch == "arm64" else f"{install_os}-amd64" - ) + else: # pragma: no cover + platform_str = ( # pragma: no cover + f"{install_os}-arm64" + if install_arch == "arm64" + else f"{install_os}-amd64" # pragma: no cover + ) # pragma: no cover expected = Path(f"clang-format-21_{platform_str}.sha512sum").read_text( encoding="utf-8" ) diff --git a/tests/test_wheel_install.py b/tests/test_wheel_install.py new file mode 100644 index 0000000..f4ea905 --- /dev/null +++ b/tests/test_wheel_install.py @@ -0,0 +1,417 @@ +"""Tests for clang_tools.wheel_install — PyPI version resolution and pip installation.""" + +import json +import subprocess +import urllib.request +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from clang_tools.wheel_install import ( + _get_pypi_versions, + _detect_installed_version, + _resolve_version, + _is_version_installed, + _install_tool, + resolve_wheel_install, +) + + +def _pypi_response(releases: dict) -> MagicMock: + """Build a mock HTTP response that works as a context manager for urlopen.""" + resp = MagicMock() + resp.read.return_value = json.dumps({"releases": releases}) + resp.__enter__.return_value = resp + return resp + + +@pytest.fixture(autouse=True) +def _clear_cache(): + """Clear the LRU cache on _get_pypi_versions between tests.""" + _get_pypi_versions.cache_clear() + yield + _get_pypi_versions.cache_clear() + + +# --------------------------------------------------------------------------- +# _get_pypi_versions +# --------------------------------------------------------------------------- + + +def test_get_pypi_versions_success(): + """Fetch versions from a mocked PyPI JSON response.""" + resp = _pypi_response( + { + "18.1.8": [], + "19.1.0": [], + "19.1.1": [], + "20.1.0": [], + "20.1.8": [], + "20.1.3rc1": [], # pre-release — filtered + "21.0.0.dev1": [], # dev — filtered + "21.1.0": [], + "21.1.1": [], + "22.0.0a1": [], # alpha — filtered + } + ) + + with patch.object(urllib.request, "urlopen", return_value=resp): + latest, versions = _get_pypi_versions("clang-format") + + assert latest == "21.1.1" + assert versions == [ + "21.1.1", + "21.1.0", + "20.1.8", + "20.1.0", + "19.1.1", + "19.1.0", + "18.1.8", + ] + assert "20.1.3rc1" not in versions + assert "21.0.0.dev1" not in versions + assert "22.0.0a1" not in versions + + +def test_get_pypi_versions_network_error(): + """When PyPI is unreachable, return (None, []).""" + with patch.object( + urllib.request, "urlopen", side_effect=urllib.request.URLError("timeout") + ): + latest, versions = _get_pypi_versions("clang-format") + assert latest is None + assert versions == [] + + +def test_get_pypi_versions_no_stable(): + """When only pre-release versions exist, return (None, []).""" + resp = _pypi_response({"1.0.0alpha": [], "1.0.0b1": [], "1.0.0rc1": []}) + + with patch.object(urllib.request, "urlopen", return_value=resp): + latest, versions = _get_pypi_versions("clang-format") + + assert latest is None + assert versions == [] + + +def test_get_pypi_versions_cached(): + """LRU cache reuses the first result on repeated calls.""" + call_count = 0 + + def counting_urlopen(url, timeout=None): + nonlocal call_count + call_count += 1 + return _pypi_response({"1.0.0": [], "2.0.0": []}) + + with patch.object(urllib.request, "urlopen", side_effect=counting_urlopen): + result1 = _get_pypi_versions("clang-format") + result2 = _get_pypi_versions("clang-format") + _get_pypi_versions("clang-tidy") # different tool → new call + + assert call_count == 2 + assert result1 == result2 + + +# --------------------------------------------------------------------------- +# _detect_installed_version +# --------------------------------------------------------------------------- + + +def test_detect_installed_version_found(): + """Detect version from `` --version`` output.""" + with ( + patch("shutil.which", return_value="/usr/bin/clang-format"), + patch.object( + subprocess, + "run", + return_value=MagicMock( + stdout="clang-format version 18.1.8\n", + spec=subprocess.CompletedProcess, + ), + ), + ): + version = _detect_installed_version("clang-format") + assert version == "18.1.8" + + +def test_detect_installed_version_not_found(): + """Return None when tool is not on PATH.""" + with patch("shutil.which", return_value=None): + version = _detect_installed_version("clang-format") + assert version is None + + +def test_detect_installed_version_subprocess_error(): + """Return None when subprocess fails.""" + with ( + patch("shutil.which", return_value="/usr/bin/clang-format"), + patch.object(subprocess, "run", side_effect=OSError("bad")), + ): + version = _detect_installed_version("clang-format") + assert version is None + + +def test_detect_installed_version_no_version_in_output(): + """Return None when --version output has no recognizable version.""" + with ( + patch("shutil.which", return_value="/usr/bin/clang-format"), + patch.object( + subprocess, + "run", + return_value=MagicMock( + stdout="unknown output\n", + spec=subprocess.CompletedProcess, + ), + ), + ): + version = _detect_installed_version("clang-format") + assert version is None + + +# --------------------------------------------------------------------------- +# _resolve_version +# --------------------------------------------------------------------------- + + +def test_resolve_version_null_input(): + """When user_input is None, return the latest stable version.""" + resp = _pypi_response({"18.1.8": [], "19.1.0": [], "20.1.8": []}) + + with patch.object(urllib.request, "urlopen", return_value=resp): + resolved, error = _resolve_version("clang-format", None) + + assert resolved == "20.1.8" + assert error is None + + +def test_resolve_version_exact_match(): + """When user_input exactly matches an available version.""" + resp = _pypi_response({"18.1.8": [], "19.1.0": [], "20.1.8": []}) + + with patch.object(urllib.request, "urlopen", return_value=resp): + resolved, error = _resolve_version("clang-format", "18.1.8") + + assert resolved == "18.1.8" + assert error is None + + +def test_resolve_version_prefix_match(): + """Prefix match picks the newest version starting with the prefix.""" + resp = _pypi_response( + { + "18.1.8": [], + "19.1.0": [], + "19.1.1": [], + "19.1.7": [], + "20.1.8": [], + } + ) + + with patch.object(urllib.request, "urlopen", return_value=resp): + resolved, error = _resolve_version("clang-format", "19") + + assert resolved == "19.1.7" + assert error is None + + +def test_resolve_version_no_match(): + """When no version matches, return an error message.""" + resp = _pypi_response({"18.1.8": [], "19.1.0": []}) + + with patch.object(urllib.request, "urlopen", return_value=resp): + resolved, error = _resolve_version("clang-format", "999") + + assert resolved is None + assert error is not None + assert "Unsupported" in error + assert "Latest stable version" in error + assert "pip index versions" in error + + +def test_resolve_version_pypi_unreachable_no_input(): + """When PyPI is unreachable and user gives no version, fall back to local.""" + with ( + patch.object( + urllib.request, "urlopen", side_effect=urllib.request.URLError("timeout") + ), + patch("shutil.which", return_value="/usr/bin/clang-format"), + patch.object( + subprocess, + "run", + return_value=MagicMock( + stdout="clang-format version 18.1.8\n", + spec=subprocess.CompletedProcess, + ), + ), + ): + resolved, error = _resolve_version("clang-format", None) + + assert resolved == "18.1.8" + assert error is None + + +def test_resolve_version_pypi_unreachable_no_local(): + """When PyPI is unreachable AND no local tool, return error.""" + with ( + patch.object( + urllib.request, "urlopen", side_effect=urllib.request.URLError("timeout") + ), + patch("shutil.which", return_value=None), + ): + resolved, error = _resolve_version("clang-format", None) + + assert resolved is None + assert error is not None + assert "Could not find any stable versions" in error + + +def test_resolve_version_pypi_unreachable_with_version(): + """When PyPI is unreachable and user specified a version, return error.""" + with patch.object( + urllib.request, "urlopen", side_effect=urllib.request.URLError("timeout") + ): + resolved, error = _resolve_version("clang-format", "18.1.8") + + assert resolved is None + assert error is not None + assert "Could not find any stable versions" in error + + +# --------------------------------------------------------------------------- +# _is_version_installed +# --------------------------------------------------------------------------- + + +def test_is_version_installed_found(): + """Return path when installed version matches.""" + with ( + patch("shutil.which", return_value="/usr/bin/clang-format"), + patch.object( + subprocess, + "run", + return_value=MagicMock( + stdout="clang-format version 18.1.8\n", + spec=subprocess.CompletedProcess, + ), + ), + ): + path = _is_version_installed("clang-format", "18.1.8") + assert path == Path("/usr/bin/clang-format") + + +def test_is_version_installed_not_on_path(): + """Return None when tool is not found.""" + with patch("shutil.which", return_value=None): + path = _is_version_installed("clang-format", "18.1.8") + assert path is None + + +def test_is_version_installed_wrong_version(): + """Return None when the installed version does not match.""" + with ( + patch("shutil.which", return_value="/usr/bin/clang-format"), + patch.object( + subprocess, + "run", + return_value=MagicMock( + stdout="clang-format version 19.1.0\n", + spec=subprocess.CompletedProcess, + ), + ), + ): + path = _is_version_installed("clang-format", "18.1.8") + assert path is None + + +# --------------------------------------------------------------------------- +# _install_tool +# --------------------------------------------------------------------------- + + +def test_install_tool_success(): + """Successful pip install returns the tool path.""" + with ( + patch.object( + subprocess, + "run", + return_value=MagicMock(returncode=0, spec=subprocess.CompletedProcess), + ), + patch("shutil.which", return_value="/usr/bin/clang-format"), + ): + path = _install_tool("clang-format", "18.1.8") + assert path == "/usr/bin/clang-format" + + +def test_install_tool_failure(): + """Failed pip install returns None.""" + with patch.object( + subprocess, + "run", + return_value=MagicMock( + returncode=1, + stdout="error", + stderr="pip error", + spec=subprocess.CompletedProcess, + ), + ): + path = _install_tool("clang-format", "18.1.8") + assert path is None + + +# --------------------------------------------------------------------------- +# resolve_wheel_install (integration / public API) +# --------------------------------------------------------------------------- + + +def test_resolve_wheel_install_success(): + """Full successful resolution and install.""" + resp = _pypi_response({"18.1.8": [], "20.1.8": []}) + + with ( + patch.object(urllib.request, "urlopen", return_value=resp), + patch("shutil.which", return_value="/usr/bin/clang-format"), + patch.object( + subprocess, + "run", + return_value=MagicMock( + stdout="clang-format version 18.1.8\n", + spec=subprocess.CompletedProcess, + ), + ), + ): + path, error = resolve_wheel_install("clang-format", "18.1.8") + + assert path == Path("/usr/bin/clang-format") + assert error is None + + +def test_resolve_wheel_install_latest(): + """Install the latest stable version when version is None.""" + resp = _pypi_response({"18.1.8": [], "20.1.8": []}) + + with ( + patch.object(urllib.request, "urlopen", return_value=resp), + patch("shutil.which", side_effect=[None, "/usr/bin/clang-format"]), + patch.object( + subprocess, + "run", + return_value=MagicMock(returncode=0, spec=subprocess.CompletedProcess), + ), + ): + path, error = resolve_wheel_install("clang-format", None) + + assert path == "/usr/bin/clang-format" + assert error is None + + +def test_resolve_wheel_install_version_error(): + """Returns error when version cannot be resolved.""" + resp = _pypi_response({"18.1.8": []}) + + with patch.object(urllib.request, "urlopen", return_value=resp): + path, error = resolve_wheel_install("clang-format", "999") + + assert path is None + assert error is not None + assert "Unsupported" in error diff --git a/uv.lock b/uv.lock index 52638aa..63a014b 100644 --- a/uv.lock +++ b/uv.lock @@ -83,54 +83,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, ] -[[package]] -name = "clang-format" -version = "21.1.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ba/0e/2113b696d8e9b8c51e65e347ebbce722de91f1c14dcc7896b5156a5b1aa8/clang_format-21.1.2.tar.gz", hash = "sha256:8a72398bdcd5e3465dbf10882672f8a51b0beb7a6d8cf4f945d00b6523b4cf91", size = 11503, upload-time = "2025-09-24T16:35:52.476Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7d/97/0bb5a6866dfb5f55f7e6ca79466cb0b0081fccbc9f57887949ff23b5c38a/clang_format-21.1.2-py2.py3-none-macosx_10_9_x86_64.whl", hash = "sha256:00498efb43d60d7ac4195362009a79936d26145a9a90cdfa7a6013a62ab3c40c", size = 1440163, upload-time = "2025-09-24T16:35:25.009Z" }, - { url = "https://files.pythonhosted.org/packages/ba/90/b8230efcff90a8543da3fb7fc09d7077afebaba019eceb1686d4db94cac3/clang_format-21.1.2-py2.py3-none-macosx_11_0_arm64.whl", hash = "sha256:fc034652dee24583633177d800bc9deebcc9c65eb7ab53b25bbd0fbd443392a9", size = 1458874, upload-time = "2025-09-24T16:35:27.149Z" }, - { url = "https://files.pythonhosted.org/packages/c5/83/61fadfa8d62a288d778e0a1ad2f73b01abca64574ee34c5d6d078e0821da/clang_format-21.1.2-py2.py3-none-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f66d2bcf98df1373df6ab4544a2b881e9816985b606e1144e4c77dc8ac87b826", size = 1725525, upload-time = "2025-09-24T16:35:28.818Z" }, - { url = "https://files.pythonhosted.org/packages/8d/76/ad4ae3f3752fb8174d5c13f4596e3b966ab2aa5c25f04d219713722d4c26/clang_format-21.1.2-py2.py3-none-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:7e0e98f39f16b93c8740028148c72f9ba64d0a43f51a15fb0e861610c1e1e573", size = 1856744, upload-time = "2025-09-24T16:35:30.184Z" }, - { url = "https://files.pythonhosted.org/packages/75/bc/185bf2c41eaed4b2efb209f29e9569cb101d6e3f6e19b24dd8064d414449/clang_format-21.1.2-py2.py3-none-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:95e74c050cb5246a88ba7306c82dc3a7d6adb5145f49d188d5602967e4133336", size = 2031516, upload-time = "2025-09-24T16:35:32.003Z" }, - { url = "https://files.pythonhosted.org/packages/d9/23/c88f518493e3e5a4aabdcf203026298fd4c9b1107b3734eb766255cbfe3c/clang_format-21.1.2-py2.py3-none-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:00f4459773ee3e8c0e20578ee800da1fa7fac98c4b0053e13afa450baca0764c", size = 2047836, upload-time = "2025-09-24T16:35:33.742Z" }, - { url = "https://files.pythonhosted.org/packages/fb/ac/3c04772acc0257f5730e83adb542b2603c1a62d1315010ab593a980af404/clang_format-21.1.2-py2.py3-none-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6d7caf74fe89154258ddfd63984c98ffe902ef98f013ac517178fc44d72861ff", size = 1805060, upload-time = "2025-09-24T16:35:35.256Z" }, - { url = "https://files.pythonhosted.org/packages/33/cf/ffe750d45187268f7f87942f046095f84dd936502800ccb067dda7c69416/clang_format-21.1.2-py2.py3-none-manylinux_2_31_armv7l.whl", hash = "sha256:5ddf9afd329c2788998a1563f98cc2a0b911497bc79bc22b7d44fd1471c047de", size = 1643097, upload-time = "2025-09-24T16:35:36.791Z" }, - { url = "https://files.pythonhosted.org/packages/e8/6f/5276f982144423031d6195cc040c5de4cfeb78c6bbadc5f39279abe44c5b/clang_format-21.1.2-py2.py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a1fe2ad12b6779b0e60e43ffe57947f51ea9fe4d5887452a31266bb6bb966195", size = 2701106, upload-time = "2025-09-24T16:35:38.107Z" }, - { url = "https://files.pythonhosted.org/packages/91/bf/12140510383a2bedc313ceaf2b4d91a571b84a06623ea8026d03953a8547/clang_format-21.1.2-py2.py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ddd64f677912253801e639f2bbe19dfe4b2ac645ddcac340f784b290603c6dd1", size = 2480441, upload-time = "2025-09-24T16:35:39.561Z" }, - { url = "https://files.pythonhosted.org/packages/8a/80/126c9a276fcbc45905a807b7c788a2a71d07288b3531874c092ddd58472d/clang_format-21.1.2-py2.py3-none-musllinux_1_2_i686.whl", hash = "sha256:2d27cf0914430a73886d9a754f08ebccc21048196d958a21d9d47e2632f14b23", size = 2953786, upload-time = "2025-09-24T16:35:41.413Z" }, - { url = "https://files.pythonhosted.org/packages/47/a8/ab6436aa6a352d3bfc767746282af4c9d6f0d819abcfc5d97f598b2dd42e/clang_format-21.1.2-py2.py3-none-musllinux_1_2_ppc64le.whl", hash = "sha256:fbf860495fa096cacd8496d5220b69d2af66f8a55291d09cc03e54ad0e48aac0", size = 3075708, upload-time = "2025-09-24T16:35:42.811Z" }, - { url = "https://files.pythonhosted.org/packages/7f/a4/d92271b25ff2f975726fadcde63bb43d88e08837367175d5e3122cd4ca99/clang_format-21.1.2-py2.py3-none-musllinux_1_2_s390x.whl", hash = "sha256:8d54ab01eec27899d104f32f3e7f02032174f88d4f72ba1781b209898ae5407c", size = 3159740, upload-time = "2025-09-24T16:35:44.448Z" }, - { url = "https://files.pythonhosted.org/packages/da/d1/bcaf44780a13221f3403d8551f2b9c73e1ba5d54447b241c5daa174fe546/clang_format-21.1.2-py2.py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:c840849580eb5ad937f0a7fb1b938609e905756ad27d7e63f20ab929d6c0fc8d", size = 2811811, upload-time = "2025-09-24T16:35:45.892Z" }, - { url = "https://files.pythonhosted.org/packages/57/a2/fcfba64440fa177d2728da4cca543eb74224e60816a8ce4666bb7ede567e/clang_format-21.1.2-py2.py3-none-win32.whl", hash = "sha256:f316245a46dd9b26baaed33de149e06155ac09166846d9340107779306448b7d", size = 1271178, upload-time = "2025-09-24T16:35:47.553Z" }, - { url = "https://files.pythonhosted.org/packages/fe/0d/3b9c6a41a9eed2d45431d91c0e8608da315cd44d0c24c517bfb686db4b6b/clang_format-21.1.2-py2.py3-none-win_amd64.whl", hash = "sha256:c98e195a50c0fa40bb058449511b1b681ca7ad553579aa32425f0cfeca8d81ce", size = 1426244, upload-time = "2025-09-24T16:35:48.919Z" }, - { url = "https://files.pythonhosted.org/packages/2e/5f/a1c409081620c35a80b08f3a1c263c219b28bed2bdfe4eb7caab6f047c0e/clang_format-21.1.2-py2.py3-none-win_arm64.whl", hash = "sha256:4071409d8b2cadeab72b0d56111c1703731ee954b686ad0e532c25d9652c3d14", size = 1327123, upload-time = "2025-09-24T16:35:50.819Z" }, -] - -[[package]] -name = "clang-tidy" -version = "21.1.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5b/da/b41302a0ef0d5df37af66aa5c1329d5db06e2cc23fae1f12fabbc1e535b3/clang_tidy-21.1.1.tar.gz", hash = "sha256:144c21ab1c5343e3636e48c8ecf588111563eab04efd0f99077e3e4c239502b2", size = 11300, upload-time = "2025-09-25T13:12:26.819Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7a/81/7d2ab48da91a22268c08d1c586875b4870bf9c65b377fea0169a5a69de6c/clang_tidy-21.1.1-py2.py3-none-macosx_10_9_x86_64.whl", hash = "sha256:3edaa98d95c20b71013e761426ed471b817aa8a0d373d1c9578a596ac85fbe1e", size = 28779633, upload-time = "2025-09-25T13:11:59.418Z" }, - { url = "https://files.pythonhosted.org/packages/62/c5/7504b7da3941acdabf417d4f84c0fcd94680f7d4db97895f8fcb34f3e905/clang_tidy-21.1.1-py2.py3-none-macosx_11_0_arm64.whl", hash = "sha256:817e2a3e73bcb71e4b990df16ae004268d11b4905288f73f2c8ae9c28f31c8c1", size = 27715942, upload-time = "2025-09-25T13:12:02.481Z" }, - { url = "https://files.pythonhosted.org/packages/f1/56/60e0511449b551f356a74fa4120f9bc614034a41796c5b4c92a3adaa661c/clang_tidy-21.1.1-py2.py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5edb71714a64b1f2bc51d62bfb3ad39fae7e9c7362ac149f9d7226aaec74d77b", size = 38890703, upload-time = "2025-09-25T13:12:05.45Z" }, - { url = "https://files.pythonhosted.org/packages/da/4a/89f403807ebf7fd735a83dc7f2d5292441d5ba975d581516019f55a934e2/clang_tidy-21.1.1-py2.py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a5b695dbb406c0648745ea48f7786dd7c6646c1a27caa62dfaceaedb5d013be", size = 45139116, upload-time = "2025-09-25T13:12:08.62Z" }, - { url = "https://files.pythonhosted.org/packages/b7/eb/a5bbb8c42dab5860ab38d8736ce803b3bee707d6ed6235dbe04837074b47/clang_tidy-21.1.1-py2.py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1e31eed946b8fc6730a426ca818cb9de5ecb6344223cafe6a4da7b05fd3bb32", size = 39543566, upload-time = "2025-09-25T13:12:11.871Z" }, - { url = "https://files.pythonhosted.org/packages/c9/20/02a575eb09834fac110fcf247dacbdd4489f0695cbc1ecf34ef344911ff5/clang_tidy-21.1.1-py2.py3-none-musllinux_1_2_i686.whl", hash = "sha256:ead452d118a0150a2a1737e8b42263fc6e94d478d96812de1b82a54f199cf513", size = 48021552, upload-time = "2025-09-25T13:12:15.644Z" }, - { url = "https://files.pythonhosted.org/packages/6e/c9/159e1c13c9e809eec19e1208b91f1f99bc50ebcdf0981243ac60d459f5bc/clang_tidy-21.1.1-py2.py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:66c71b7751f721bb1b3da253360894b7677988b3d413acdbe1ac02b82aedc0ca", size = 42996200, upload-time = "2025-09-25T13:12:18.989Z" }, - { url = "https://files.pythonhosted.org/packages/5f/b6/e83a83ecc88287cbc1446715e3093d80393383c06d4b057b42f2869b0dd6/clang_tidy-21.1.1-py2.py3-none-win32.whl", hash = "sha256:ad544c4f90d1ac0875dd35b787752d2d051d15666dfb665fb9b9bc8446db2e49", size = 21022576, upload-time = "2025-09-25T13:12:22Z" }, - { url = "https://files.pythonhosted.org/packages/33/fa/2c7f6d84b8a8c49ba5b70b47cd8053e027a0ab99d0e75606cee673f5079b/clang_tidy-21.1.1-py2.py3-none-win_amd64.whl", hash = "sha256:5aa4f726e32eb63010820493628da808394cdff5956828a5889e3b7a71a78c20", size = 23796782, upload-time = "2025-09-25T13:12:24.429Z" }, -] - [[package]] name = "clang-tools" source = { editable = "." } -dependencies = [ - { name = "cpp-linter-hooks" }, -] [package.optional-dependencies] dev = [ @@ -157,7 +112,6 @@ docs = [ [package.metadata] requires-dist = [ { name = "coverage", extras = ["toml"], marker = "extra == 'dev'" }, - { name = "cpp-linter-hooks", specifier = ">=1.1.7" }, { name = "mkdocs", marker = "extra == 'docs'", specifier = ">=1.6" }, { name = "mkdocs-gen-files", marker = "extra == 'docs'", specifier = ">=0.5" }, { name = "mkdocs-material", marker = "extra == 'docs'", specifier = ">=9.5" }, @@ -258,20 +212,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5f/04/642c1d8a448ae5ea1369eac8495740a79eb4e581a9fb0cbdce56bbf56da1/coverage-7.11.0-py3-none-any.whl", hash = "sha256:4b7589765348d78fb4e5fb6ea35d07564e387da2fc5efff62e0222971f155f68", size = 207761, upload-time = "2025-10-15T15:15:06.439Z" }, ] -[[package]] -name = "cpp-linter-hooks" -version = "1.1.7" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "clang-format" }, - { name = "clang-tidy" }, - { name = "pip" }, - { name = "setuptools" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/6d/ca/4feb4542d84bc94ac2205c13d2bf0f1b5eb0c67c317643e4cad3a4fbe95b/cpp_linter_hooks-1.1.7-py3-none-any.whl", hash = "sha256:80f047799861abda85c76bee964262e5a4b9f0a7f72b56c661b08e88fedf7ae6", size = 10135, upload-time = "2025-11-01T00:30:20.697Z" }, -] - [[package]] name = "distlib" version = "0.4.0" @@ -529,15 +469,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, ] -[[package]] -name = "pip" -version = "26.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/73/7e/d2b04004e1068ad4fdfa2f227b839b5d03e602e47cdbbf49de71137c9546/pip-26.1.tar.gz", hash = "sha256:81e13ebcca3ffa8cc85e4deff5c27e1ee26dea0aa7fc2f294a073ac208806ff3", size = 1840316, upload-time = "2026-04-26T21:00:05.406Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/70/7a/be4bd8bcbb24ea475856dd68159d78b03b2bb53dae369f69c9606b8888f5/pip-26.1-py3-none-any.whl", hash = "sha256:4e8486d821d814b77319acb7b9e8bf5a4ee7590a643e7cb21029f209be8573c1", size = 1812804, upload-time = "2026-04-26T21:00:03.194Z" }, -] - [[package]] name = "platformdirs" version = "4.5.0" @@ -708,15 +639,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b", size = 65017, upload-time = "2026-03-25T15:10:40.382Z" }, ] -[[package]] -name = "setuptools" -version = "80.9.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/18/5d/3bf57dcd21979b887f014ea83c24ae194cfcd12b9e0fda66b957c69d1fca/setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c", size = 1319958, upload-time = "2025-05-27T00:56:51.443Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486, upload-time = "2025-05-27T00:56:49.664Z" }, -] - [[package]] name = "six" version = "1.17.0"