diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 1810369..56a1133 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -14,10 +14,9 @@ Pre-commit hooks wrapper that auto-installs and runs clang-format and clang-tidy - **`clang_format.py`**: Wraps clang-format with `-i` (in-place), supports `--verbose` and `--dry-run` modes - Returns `-1` for dry-run to distinguish from actual failures - **`clang_tidy.py`**: Wraps clang-tidy, forces exit code 1 if "warning:" or "error:" in output -- **`util.py`**: Version resolution and pip-based tool installation - - `_resolve_version()`: Supports partial matches (e.g., "20" → "20.1.7") - - `DEFAULT_CLANG_FORMAT_VERSION` and `DEFAULT_CLANG_TIDY_VERSION` read from `pyproject.toml` -- **`versions.py`**: Auto-generated by `scripts/update_versions.py` (runs weekly via GitHub Actions) +- **`util.py`**: Dynamic version resolution via PyPI JSON API + pip-based tool installation + - `_resolve_version_from_pypi()`: Supports partial matches (e.g., "20" → "20.1.8"), resolves against PyPI in real time + - No hardcoded version lists — versions are always up-to-date from PyPI ### Version Management Pattern ```python @@ -49,8 +48,8 @@ uv run pytest -m benchmark # Performance tests only ### Dependency Management - **Uses `uv`** for all dev operations (not pip directly) -- **Pin versions**: Default tool versions in `pyproject.toml` dependencies section -- **Update versions**: Run `python scripts/update_versions.py` (auto-runs weekly on Monday 2 AM UTC) +- **Tool versions**: Resolved dynamically from PyPI at hook runtime — no manual updates needed +- **Version caching**: `_get_pypi_versions()` uses `lru_cache` to avoid repeated PyPI requests within a single run ## Project-Specific Conventions @@ -78,8 +77,8 @@ command = ["tool-name"] + other_args # Pass through unknown args ## Critical Files -- **`pyproject.toml`**: Defines entry points, dependencies, default versions -- **`versions.py`**: Auto-updated; DO NOT edit manually (see comment) +- **`pyproject.toml`**: Defines entry points and dependencies +- **`util.py`**: Core version resolution + tool installation logic - **`.pre-commit-hooks.yaml`**: Hook metadata for pre-commit framework - **`testing/run.sh`**: Integration test script used in CI @@ -102,10 +101,7 @@ command = ["tool-name"] + other_args # Pass through unknown args 2. Pass to subprocess command or handle in Python 3. Add test case in `tests/test_*.py` -**Update default tool versions:** -1. Edit `dependencies` in `pyproject.toml` -2. Run tests to ensure compatibility -3. Update version in README examples +**No manual version updates needed:** Tool versions are resolved dynamically from PyPI at runtime. When new versions are published to PyPI, hooks automatically discover them — no code changes required. **Debug hook failures:** - Add `--verbose` to clang-format args for detailed output diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index 7915465..5d2a136 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -19,26 +19,17 @@ jobs: - name: Checkout repository uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - - name: Extract default tool versions - id: versions + - name: Generate release notes header run: | - # Get versions directly from versions.py (no package install needed) - CLANG_FORMAT_VERSION=$(python3 -c "import sys; sys.path.insert(0, 'cpp_linter_hooks'); from versions import CLANG_FORMAT_VERSIONS; print(CLANG_FORMAT_VERSIONS[-1])") - CLANG_TIDY_VERSION=$(python3 -c "import sys; sys.path.insert(0, 'cpp_linter_hooks'); from versions import CLANG_TIDY_VERSIONS; print(CLANG_TIDY_VERSIONS[-1])") - - # Export to GitHub Actions environment for subsequent steps - echo "CLANG_FORMAT_VERSION=$CLANG_FORMAT_VERSION" >> $GITHUB_ENV - echo "CLANG_TIDY_VERSION=$CLANG_TIDY_VERSION" >> $GITHUB_ENV - - # Log for debug - echo "Default clang-format version: $CLANG_FORMAT_VERSION" - echo "Default clang-tidy version: $CLANG_TIDY_VERSION" - - # Generate release notes file - echo "## 💡 Default Clang Tool Version" > release_notes.md - echo "clang-format: \`$CLANG_FORMAT_VERSION\` · clang-tidy: \`$CLANG_TIDY_VERSION\`" >> release_notes.md + echo "## 💡 Default Clang Tool Versions" > release_notes.md + echo "" >> release_notes.md + echo "Versions are resolved **dynamically from PyPI** at hook runtime. " >> release_notes.md + echo "The latest stable versions are always used by default — " >> release_notes.md + echo "no manual updates required." >> release_notes.md echo "" >> release_notes.md - echo "You can override the default versions for by adding the \`--version\` argument under \`args\` in your pre-commit config. See [Custom Clang Tool Version](https://github.com/cpp-linter/cpp-linter-hooks?tab=readme-ov-file#custom-clang-tool-version) for details." >> release_notes.md + echo "You can pin a specific version by adding the \`--version\` argument " >> release_notes.md + echo "under \`args\` in your pre-commit config. " >> release_notes.md + echo "See [Custom Clang Tool Version](https://github.com/cpp-linter/cpp-linter-hooks?tab=readme-ov-file#custom-clang-tool-version) for details." >> release_notes.md echo "" >> release_notes.md cat release_notes.md diff --git a/.github/workflows/update-versions.yml b/.github/workflows/update-versions.yml deleted file mode 100644 index 4098456..0000000 --- a/.github/workflows/update-versions.yml +++ /dev/null @@ -1,86 +0,0 @@ -name: Update Tool Versions - -on: - schedule: - # Run every Monday at 2 AM UTC - - cron: '0 2 * * 1' - workflow_dispatch: # Allow manual trigger - -jobs: - update-versions: - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v6 - with: - token: ${{ secrets.GITHUB_TOKEN }} - - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: '3.11' - - - name: Update versions - run: | - python3 scripts/update_versions.py - - - name: Check for changes - id: verify-changed-files - run: | - if [ -n "$(git status --porcelain)" ]; then - echo "changed=true" >> $GITHUB_OUTPUT - else - echo "changed=false" >> $GITHUB_OUTPUT - fi - - - name: Get latest versions for PR description - if: steps.verify-changed-files.outputs.changed == 'true' - id: get-versions - run: | - CLANG_FORMAT_VERSION=$(python3 -c 'from cpp_linter_hooks.versions import CLANG_FORMAT_VERSIONS; print(CLANG_FORMAT_VERSIONS[-1])') - CLANG_TIDY_VERSION=$(python3 -c 'from cpp_linter_hooks.versions import CLANG_TIDY_VERSIONS; print(CLANG_TIDY_VERSIONS[-1])') - echo "clang_format_version=$CLANG_FORMAT_VERSION" >> $GITHUB_OUTPUT - echo "clang_tidy_version=$CLANG_TIDY_VERSION" >> $GITHUB_OUTPUT - - - name: Create Pull Request - if: steps.verify-changed-files.outputs.changed == 'true' - uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 - with: - token: ${{ secrets.GITHUB_TOKEN }} - commit-message: | - chore: update clang-format and clang-tidy versions - - - Updated versions automatically from PyPI - title: "chore: update tool versions to latest" - body: | - ## 🔄 Automated Version Update - - This PR updates the hardcoded versions for clang-format and clang-tidy tools to the latest available versions from PyPI. - - ### 📦 Updated Versions - - **clang-format**: `${{ steps.get-versions.outputs.clang_format_version }}` - - **clang-tidy**: `${{ steps.get-versions.outputs.clang_tidy_version }}` - - ### 🤖 Automation Details - - This update was triggered automatically by the scheduled workflow - - Versions are fetched from the official PyPI APIs - - Only stable versions (no pre-releases) are included - - ### ✅ What's Changed - - Updated `cpp_linter_hooks/versions.py` with latest tool versions - - No breaking changes expected - - Maintains backward compatibility - - ### 🔍 Review Checklist - - [ ] Verify the new versions are stable releases - - [ ] Check that no pre-release versions were included - - [ ] Confirm the version format matches expectations - - --- - - *This PR was created automatically by the `update-versions.yml` workflow.* - branch: update-tool-versions - delete-branch: true - base: main - labels: dependencies diff --git a/cpp_linter_hooks/util.py b/cpp_linter_hooks/util.py index 57db142..e03c03c 100644 --- a/cpp_linter_hooks/util.py +++ b/cpp_linter_hooks/util.py @@ -5,98 +5,124 @@ import subprocess from pathlib import Path import logging -from typing import Optional, List, Tuple - -if sys.version_info >= (3, 11): - import tomllib -else: - import tomli as tomllib - -from cpp_linter_hooks.versions import CLANG_FORMAT_VERSIONS, CLANG_TIDY_VERSIONS +from typing import Optional, Tuple +from functools import lru_cache +import json +import urllib.request +import re LOG = logging.getLogger(__name__) -def get_version_from_dependency(tool: str) -> Optional[str]: - """Get the version of a tool from the pyproject.toml dependencies.""" - pyproject_path = Path(__file__).parent.parent / "pyproject.toml" - if not pyproject_path.exists(): - return None - with open(pyproject_path, "rb") as f: - data = tomllib.load(f) - # Check [project].dependencies - dependencies = data.get("project", {}).get("dependencies", []) - for dep in dependencies: - if dep.startswith(f"{tool}=="): - return dep.split("==")[1] - return None - +@lru_cache(maxsize=4) +def _get_pypi_versions(tool: str) -> Tuple[Optional[str], list]: + """Fetch (latest_version, [stable_versions_descending]) from PyPI JSON API. -DEFAULT_CLANG_FORMAT_VERSION = CLANG_FORMAT_VERSIONS[-1] # latest from versions.py -DEFAULT_CLANG_TIDY_VERSION = CLANG_TIDY_VERSIONS[-1] # latest from versions.py + 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, [] -def _versions_for_tool(tool: str) -> List[str]: - """Return supported Python wheel versions for a clang tool.""" - return CLANG_FORMAT_VERSIONS if tool == "clang-format" else CLANG_TIDY_VERSIONS + # 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 _default_version_for_tool(tool: str) -> Optional[str]: - """Return the default Python wheel version for a clang tool.""" - return ( - DEFAULT_CLANG_FORMAT_VERSION - if tool == "clang-format" - else DEFAULT_CLANG_TIDY_VERSION - ) - - -def _supported_versions_message(tool: str) -> str: - """Build a user-facing list of supported wheel versions for a clang tool.""" - versions = ", ".join(_versions_for_tool(tool)) - return f"Supported {tool} wheel versions: {versions}" +def _resolve_version_from_pypi( + 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: + # 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.", + ) -def _resolve_version(versions: List[str], user_input: Optional[str]) -> Optional[str]: - """Resolve the latest matching version based on user input and available versions.""" if user_input is None: - return None - if user_input in versions: - return user_input + return latest, None - try: - # filter versions that start with the user input - matched_versions = [v for v in versions if v.startswith(user_input)] - if not matched_versions: - raise ValueError - - # define a function to parse version strings into tuples for comparison - def parse_version(v: str): - """Convert a dotted version string into an integer tuple.""" - return tuple(map(int, v.split("."))) + # Exact match + if user_input in versions: + return user_input, None - # return the latest version - return max(matched_versions, key=parse_version) + # 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 - except ValueError: - LOG.warning("Version %s not found in available versions", user_input) - return 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 resolve_tool_version( - tool: str, version: Optional[str] -) -> Tuple[Optional[str], Optional[str]]: - """Resolve a requested tool version or return a user-facing error message.""" - if version is None: - return _default_version_for_tool(tool), None +def _detect_installed_version(tool: str) -> Optional[str]: + """Return the version of *tool* already on PATH, or None. - resolved = _resolve_version(_versions_for_tool(tool), version) - if resolved is None: - return ( - None, - f"Unsupported {tool} version '{version}'.\n" - f"{_supported_versions_message(tool)}", + 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 ) - return resolved, None + 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 _is_version_installed(tool: str, version: str) -> Optional[Path]: @@ -128,15 +154,19 @@ def _install_tool(tool: str, version: str) -> Optional[Path]: def resolve_install_with_diagnostics( tool: str, version: Optional[str], verbose: bool = False ) -> Tuple[Optional[Path], Optional[str]]: - """Resolve/install a tool, returning a user-facing error for bad versions.""" - user_version, error = resolve_tool_version(tool, version) + """Resolve/install a tool, returning a user-facing error for bad versions. + + Tool versions are resolved dynamically from PyPI — no hardcoded + list is maintained in-tree. + """ + 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 default {tool} Python wheel version {user_version}", + f"Using latest {tool} Python wheel version {user_version}", file=sys.stderr, ) elif version == user_version: diff --git a/cpp_linter_hooks/versions.py b/cpp_linter_hooks/versions.py deleted file mode 100644 index e5a9226..0000000 --- a/cpp_linter_hooks/versions.py +++ /dev/null @@ -1,106 +0,0 @@ -""" -Version management for clang-format and clang-tidy. - -This module provides hardcoded version lists that are updated periodically via GitHub Actions. -""" - -# Updated automatically by GitHub Actions - DO NOT EDIT MANUALLY -CLANG_FORMAT_VERSIONS = [ - "6.0.1", - "7.1.0", - "8.0.1", - "9.0.0", - "10.0.1", - "10.0.1.1", - "11.0.1", - "11.0.1.1", - "11.0.1.2", - "11.1.0", - "11.1.0.1", - "11.1.0.2", - "12.0.1", - "12.0.1.1", - "12.0.1.2", - "13.0.0", - "13.0.1", - "13.0.1.1", - "14.0.0", - "14.0.1", - "14.0.3", - "14.0.4", - "14.0.5", - "14.0.6", - "15.0.4", - "15.0.6", - "15.0.7", - "16.0.0", - "16.0.1", - "16.0.2", - "16.0.3", - "16.0.4", - "16.0.5", - "16.0.6", - "17.0.1", - "17.0.2", - "17.0.3", - "17.0.4", - "17.0.5", - "17.0.6", - "18.1.0", - "18.1.1", - "18.1.2", - "18.1.3", - "18.1.4", - "18.1.5", - "18.1.6", - "18.1.7", - "18.1.8", - "19.1.0", - "19.1.1", - "19.1.2", - "19.1.3", - "19.1.4", - "19.1.5", - "19.1.6", - "19.1.7", - "20.1.0", - "20.1.3", - "20.1.4", - "20.1.5", - "20.1.6", - "20.1.7", - "20.1.8", - "21.1.0", - "21.1.1", - "21.1.2", - "21.1.5", - "21.1.6", - "21.1.7", - "21.1.8", - "22.1.0", - "22.1.1", - "22.1.2", - "22.1.3", - "22.1.4", - "22.1.5", -] - -# Updated automatically by GitHub Actions - DO NOT EDIT MANUALLY -CLANG_TIDY_VERSIONS = [ - "13.0.1.1", - "14.0.6", - "15.0.2", - "15.0.2.1", - "16.0.4", - "17.0.1", - "18.1.1", - "18.1.8", - "19.1.0", - "19.1.0.1", - "20.1.0", - "21.1.0", - "21.1.1", - "21.1.6", - "22.1.0", - "22.1.0.1", -] diff --git a/scripts/update_versions.py b/scripts/update_versions.py deleted file mode 100644 index 4afc3af..0000000 --- a/scripts/update_versions.py +++ /dev/null @@ -1,86 +0,0 @@ -#!/usr/bin/env python3 -""" -Script to update clang-format and clang-tidy versions from PyPI API. - -Usage: - python scripts/update_versions.py -""" - -import json -import urllib.request -from pathlib import Path -import re -from typing import List - - -def fetch_versions_from_pypi(package_name: str) -> List[str]: - """Fetch available versions for a package from PyPI API.""" - url = f"https://pypi.org/pypi/{package_name}/json" - try: - with urllib.request.urlopen(url, timeout=10) as response: - data = json.loads(response.read()) - versions = list(data["releases"].keys()) - # Filter out pre-release versions using proper regex patterns - pre_release_pattern = re.compile( - r".*(alpha|beta|rc|dev|a\d+|b\d+).*", re.IGNORECASE - ) - stable_versions = [v for v in versions if not pre_release_pattern.match(v)] - return sorted(stable_versions, key=lambda x: tuple(map(int, x.split(".")))) - except Exception as e: - print(f"Failed to fetch versions for {package_name}: {e}") - return [] - - -def update_versions_file(): - """Update the versions.py file with latest versions from PyPI.""" - clang_format_versions = fetch_versions_from_pypi("clang-format") - clang_tidy_versions = fetch_versions_from_pypi("clang-tidy") - - if not clang_format_versions or not clang_tidy_versions: - print("Failed to fetch versions from PyPI") - return False - - versions_file = Path(__file__).parent.parent / "cpp_linter_hooks" / "versions.py" - - with open(versions_file, "r") as f: - content = f.read() - - # Update clang-format versions - clang_format_list = ( - "[\n" + "\n".join(f' "{v}",' for v in clang_format_versions) + "\n]" - ) - content = re.sub( - r"(CLANG_FORMAT_VERSIONS = )\[[^\]]*\]", - rf"\1{clang_format_list}", - content, - flags=re.DOTALL, - ) - - # Update clang-tidy versions - clang_tidy_list = ( - "[\n" + "\n".join(f' "{v}",' for v in clang_tidy_versions) + "\n]" - ) - content = re.sub( - r"(CLANG_TIDY_VERSIONS = )\[[^\]]*\]", - rf"\1{clang_tidy_list}", - content, - flags=re.DOTALL, - ) - - with open(versions_file, "w") as f: - f.write(content) - - print("Updated versions:") - print( - f" clang-format: {len(clang_format_versions)} versions (latest: {clang_format_versions[-1]})" - ) - print( - f" clang-tidy: {len(clang_tidy_versions)} versions (latest: {clang_tidy_versions[-1]})" - ) - - return True - - -if __name__ == "__main__": - success = update_versions_file() - exit(0 if success else 1) diff --git a/tests/test_util.py b/tests/test_util.py index a326df3..01b8c81 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -1,3 +1,5 @@ +"""Tests for cpp_linter_hooks.util -- dynamic PyPI version resolution.""" + import pytest from unittest.mock import patch from pathlib import Path @@ -5,132 +7,247 @@ import sys from cpp_linter_hooks.util import ( - get_version_from_dependency, - _resolve_version, + _get_pypi_versions, + _resolve_version_from_pypi, + _detect_installed_version, _is_version_installed, _install_tool, resolve_install_with_diagnostics, resolve_install, - DEFAULT_CLANG_FORMAT_VERSION, - DEFAULT_CLANG_TIDY_VERSION, ) -from cpp_linter_hooks.versions import CLANG_FORMAT_VERSIONS, CLANG_TIDY_VERSIONS +# ── sample PyPI responses for consistent test data ────────────────────── -VERSIONS = [None, "20"] -TOOLS = ["clang-format", "clang-tidy"] +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"]) +MOCK_PYPI_INCLUDE_CLEANER = ("22.1.7", ["22.1.7", "22.1.5"]) +MOCK_PYPI_APPLY_REPLACEMENTS = ("17.0.6", ["17.0.6", "16.0.0"]) -# Tests for get_version_from_dependency -@pytest.mark.benchmark -def test_get_version_from_dependency_success(): - """Test get_version_from_dependency with valid pyproject.toml.""" - mock_toml_content = { - "project": { - "dependencies": [ - "clang-format==20.1.7", - "clang-tidy==20.1.0", - "other-package==1.0.0", - ] - } +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, + "clang-include-cleaner": MOCK_PYPI_INCLUDE_CLEANER, + "clang-apply-replacements": MOCK_PYPI_APPLY_REPLACEMENTS, } + return mapping.get(tool, (None, [])) - with ( - patch("pathlib.Path.exists", return_value=True), - patch("cpp_linter_hooks.util.tomllib.load", return_value=mock_toml_content), - ): - result = get_version_from_dependency("clang-format") - assert result == "20.1.7" - result = get_version_from_dependency("clang-tidy") - assert result == "20.1.0" +# ═══════════════════════════════════════════════════════════════════════ +# _get_pypi_versions +# ═══════════════════════════════════════════════════════════════════════ @pytest.mark.benchmark -def test_get_version_from_dependency_missing_file(): - """Test get_version_from_dependency when pyproject.toml doesn't exist.""" - with patch("pathlib.Path.exists", return_value=False): - result = get_version_from_dependency("clang-format") - assert result is None +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_version_from_dependency_missing_dependency(): - """Test get_version_from_dependency with missing dependency.""" - mock_toml_content = {"project": {"dependencies": ["other-package==1.0.0"]}} - with ( - patch("pathlib.Path.exists", return_value=True), - patch("cpp_linter_hooks.util.tomllib.load", return_value=mock_toml_content), - ): - result = get_version_from_dependency("clang-format") - assert result is None +@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_version_from_dependency_malformed_toml(): - """Test get_version_from_dependency with malformed toml.""" - mock_toml_content = {} +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 == [] - with ( - patch("pathlib.Path.exists", return_value=True), - patch("cpp_linter_hooks.util.tomllib.load", return_value=mock_toml_content), - ): - result = get_version_from_dependency("clang-format") - assert result is None + +# ═══════════════════════════════════════════════════════════════════════ +# _resolve_version_from_pypi +# ═══════════════════════════════════════════════════════════════════════ -# Tests for _resolve_version @pytest.mark.benchmark @pytest.mark.parametrize( - "user_input,expected", + "tool,user_input,expected", [ - (None, None), - ("20", "20.1.8"), # Should find latest 20.x - ("20.1", "20.1.8"), # Should find latest 20.1.x - ("20.1.7", "20.1.7"), # Exact match - ("18", "18.1.8"), # Should find latest 18.x - ("18.1", "18.1.8"), # Should find latest 18.1.x - ("99", None), # Non-existent major version - ("20.99", None), # Non-existent minor version - ("invalid", None), # Invalid version string + # No version → latest + ("clang-format", None, "22.1.5"), + ("clang-tidy", None, "21.1.6"), + ("clang-include-cleaner", None, "22.1.7"), + ("clang-apply-replacements", None, "17.0.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"), + ("clang-include-cleaner", "22", "22.1.7"), + ("clang-apply-replacements", "16", "16.0.0"), ], ) -def test_resolve_version_clang_format(user_input, expected): - """Test _resolve_version with various inputs for clang-format.""" - result = _resolve_version(CLANG_FORMAT_VERSIONS, user_input) - assert result == expected +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 @pytest.mark.benchmark @pytest.mark.parametrize( - "user_input,expected", + "tool,user_input", [ - (None, None), - ("20", "20.1.0"), # Should find latest 20.x - ("18", "18.1.8"), # Should find latest 18.x - ("19", "19.1.0.1"), # Should find latest 19.x - ("99", None), # Non-existent major version + ("clang-format", "99"), + ("clang-format", "20.99"), + ("clang-tidy", "99"), + ("clang-tidy", "22.99"), + ("clang-include-cleaner", "99"), + ("clang-apply-replacements", "99"), ], ) -def test_resolve_version_clang_tidy(user_input, expected): - """Test _resolve_version with various inputs for clang-tidy.""" - result = _resolve_version(CLANG_TIDY_VERSIONS, user_input) - assert result == expected +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 +# ═══════════════════════════════════════════════════════════════════════ -# Tests for _is_version_installed @pytest.mark.benchmark def test_is_version_installed_not_in_path(): - """Test _is_version_installed when tool is not in PATH.""" with patch("shutil.which", return_value=None): result = _is_version_installed("clang-format", "20.1.7") - assert result is None + assert result is None @pytest.mark.benchmark def test_is_version_installed_version_matches(): - """Test _is_version_installed when installed version matches.""" mock_path = "/usr/bin/clang-format" def patched_run(*args, **kwargs): @@ -143,12 +260,11 @@ def patched_run(*args, **kwargs): patch("subprocess.run", side_effect=patched_run), ): result = _is_version_installed("clang-format", "20.1.7") - assert result == Path(mock_path) + assert result == Path(mock_path) @pytest.mark.benchmark def test_is_version_installed_version_mismatch(): - """Test _is_version_installed when installed version doesn't match.""" mock_path = "/usr/bin/clang-format" def patched_run(*args, **kwargs): @@ -161,13 +277,16 @@ def patched_run(*args, **kwargs): patch("subprocess.run", side_effect=patched_run), ): result = _is_version_installed("clang-format", "20.1.7") - assert result is None + assert result is None + + +# ═══════════════════════════════════════════════════════════════════════ +# _install_tool +# ═══════════════════════════════════════════════════════════════════════ -# Tests for _install_tool @pytest.mark.benchmark def test_install_tool_success(): - """Test _install_tool successful installation.""" mock_path = "/usr/bin/clang-format" def patched_run(*args, **kwargs): @@ -180,17 +299,15 @@ def patched_run(*args, **kwargs): 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, - ) + mock_run.assert_called_once_with( + [sys.executable, "-m", "pip", "install", "clang-format==20.1.7"], + 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" @@ -201,13 +318,11 @@ def patched_run(*args, **kwargs): patch("cpp_linter_hooks.util.LOG"), ): result = _install_tool("clang-format", "20.1.7") - assert result is None + assert result is None @pytest.mark.benchmark def test_install_tool_success_but_not_found(): - """Test _install_tool when install succeeds but tool not found in PATH.""" - def patched_run(*args, **kwargs): return subprocess.CompletedProcess(args, returncode=0) @@ -216,31 +331,36 @@ def patched_run(*args, **kwargs): patch("shutil.which", return_value=None), ): result = _install_tool("clang-format", "20.1.7") - assert result is None + assert result is None + + +# ═══════════════════════════════════════════════════════════════════════ +# resolve_install / resolve_install_with_diagnostics +# ═══════════════════════════════════════════════════════════════════════ -# Tests for resolve_install @pytest.mark.benchmark def test_resolve_install_tool_already_installed_correct_version(): - """Test resolve_install when tool is already installed with 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.7" + args, returncode=0, stdout="clang-format version 20.1.8" ) 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 + ), ): - result = resolve_install("clang-format", "20.1.7") - assert Path(result) == Path(mock_path) + result = resolve_install("clang-format", "20.1.8") + assert Path(result) == Path(mock_path) @pytest.mark.benchmark -def test_resolve_install_tool_version_mismatch(): - """Test resolve_install when tool has wrong version, triggering reinstall.""" +def test_resolve_install_tool_version_mismatch_reinstalls(): mock_path = "/usr/bin/clang-format" def patched_run(*args, **kwargs): @@ -254,82 +374,88 @@ def patched_run(*args, **kwargs): 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.7") - assert result == Path(mock_path) - - mock_install.assert_called_once_with("clang-format", "20.1.7") + result = resolve_install("clang-format", "20.1.8") + assert result == Path(mock_path) + mock_install.assert_called_once_with("clang-format", "20.1.8") @pytest.mark.benchmark def test_resolve_install_tool_not_installed(): - """Test resolve_install when tool is 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.7") - assert result == Path("/usr/bin/clang-format") - - mock_install.assert_called_once_with("clang-format", "20.1.7") + 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") @pytest.mark.benchmark -def test_resolve_install_no_version_specified(): - """Test resolve_install when no version is specified.""" +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( + "cpp_linter_hooks.util._get_pypi_versions", side_effect=_pypi_side_effect + ), ): result = resolve_install("clang-format", None) - assert result == Path("/usr/bin/clang-format") - - mock_install.assert_called_once_with( - "clang-format", DEFAULT_CLANG_FORMAT_VERSION - ) + assert result == Path("/usr/bin/clang-format") + mock_install.assert_called_once_with("clang-format", "22.1.5") @pytest.mark.benchmark def test_resolve_install_invalid_version(): - """Test resolve_install with invalid version.""" with ( patch("shutil.which", return_value=None), + patch("cpp_linter_hooks.util._install_tool") as mock_install, patch( - "cpp_linter_hooks.util._install_tool", - return_value=Path("/usr/bin/clang-format"), - ) as mock_install, + "cpp_linter_hooks.util._get_pypi_versions", side_effect=_pypi_side_effect + ), ): - result = resolve_install("clang-format", "invalid.version") - assert result is None - - mock_install.assert_not_called() + 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_lists_supported_versions(): - path, error = resolve_install_with_diagnostics("clang-tidy", "99") +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") assert path is None assert error is not None assert "Unsupported clang-tidy version '99'" in error - assert "Supported clang-tidy wheel versions:" in error - assert CLANG_TIDY_VERSIONS[-1] in error + assert "Latest stable version: 21.1.6" in error + assert "Available versions (sample):" in error @pytest.mark.benchmark -def test_resolve_install_with_diagnostics_verbose_prints_resolved_version(capsys): +def test_resolve_install_with_diagnostics_verbose_resolved(capsys): 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 + ), ): path, error = resolve_install_with_diagnostics("clang-tidy", "21", True) @@ -341,39 +467,174 @@ def test_resolve_install_with_diagnostics_verbose_prints_resolved_version(capsys ) -# Tests for constants and defaults @pytest.mark.benchmark -def test_default_versions(): - """Test that default versions are set correctly.""" - assert DEFAULT_CLANG_FORMAT_VERSION is not None - assert DEFAULT_CLANG_TIDY_VERSION is not None - assert isinstance(DEFAULT_CLANG_FORMAT_VERSION, str) - assert isinstance(DEFAULT_CLANG_TIDY_VERSION, str) +def test_resolve_install_with_diagnostics_verbose_latest(capsys): + 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 + ), + ): + path, error = resolve_install_with_diagnostics("clang-format", None, True) + + 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 + ) + + +# ═══════════════════════════════════════════════════════════════════════ +# New wheel tools +# ═══════════════════════════════════════════════════════════════════════ + + +@pytest.mark.benchmark +def test_resolve_install_include_cleaner(): + mock_path = "/usr/bin/clang-include-cleaner" + with ( + patch("shutil.which", return_value=None), + 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-include-cleaner", "22.1.7") + assert Path(result) == Path(mock_path) + mock_install.assert_called_once_with("clang-include-cleaner", "22.1.7") + + +@pytest.mark.benchmark +def test_resolve_install_include_cleaner_default(): + with ( + patch("shutil.which", return_value=None), + patch( + "cpp_linter_hooks.util._install_tool", + return_value=Path("/usr/bin/clang-include-cleaner"), + ) as mock_install, + patch( + "cpp_linter_hooks.util._get_pypi_versions", side_effect=_pypi_side_effect + ), + ): + result = resolve_install("clang-include-cleaner", None) + assert result == Path("/usr/bin/clang-include-cleaner") + mock_install.assert_called_once_with("clang-include-cleaner", "22.1.7") @pytest.mark.benchmark -def test_version_lists_not_empty(): - """Test that version lists are not empty.""" - assert len(CLANG_FORMAT_VERSIONS) > 0 - assert len(CLANG_TIDY_VERSIONS) > 0 - assert all(isinstance(v, str) for v in CLANG_FORMAT_VERSIONS) - assert all(isinstance(v, str) for v in CLANG_TIDY_VERSIONS) +def test_resolve_install_include_cleaner_invalid(): + 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-include-cleaner", "99.0.0") + assert result is None + mock_install.assert_not_called() + + +@pytest.mark.benchmark +def test_resolve_install_apply_replacements(): + mock_path = "/usr/bin/clang-apply-replacements" + with ( + patch("shutil.which", return_value=None), + 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-apply-replacements", "17.0.6") + assert Path(result) == Path(mock_path) + mock_install.assert_called_once_with("clang-apply-replacements", "17.0.6") @pytest.mark.benchmark -def test_resolve_install_with_none_default_version(): - """Test resolve_install when DEFAULT versions are None.""" +def test_resolve_install_apply_replacements_default(): with ( patch("shutil.which", return_value=None), - patch("cpp_linter_hooks.util.DEFAULT_CLANG_FORMAT_VERSION", None), - patch("cpp_linter_hooks.util.DEFAULT_CLANG_TIDY_VERSION", None), patch( "cpp_linter_hooks.util._install_tool", - return_value=Path("/usr/bin/clang-format"), + return_value=Path("/usr/bin/clang-apply-replacements"), ) as mock_install, + patch( + "cpp_linter_hooks.util._get_pypi_versions", side_effect=_pypi_side_effect + ), ): - result = resolve_install("clang-format", None) - assert result == Path("/usr/bin/clang-format") + result = resolve_install("clang-apply-replacements", None) + assert result == Path("/usr/bin/clang-apply-replacements") + mock_install.assert_called_once_with("clang-apply-replacements", "17.0.6") + + +@pytest.mark.benchmark +def test_resolve_install_apply_replacements_invalid(): + 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-apply-replacements", "99.0.0") + assert result is None + mock_install.assert_not_called() + + +@pytest.mark.benchmark +def test_resolve_install_with_diagnostics_include_cleaner_invalid(): + with patch( + "cpp_linter_hooks.util._get_pypi_versions", side_effect=_pypi_side_effect + ): + path, error = resolve_install_with_diagnostics("clang-include-cleaner", "99") + + assert path is None + assert error is not None + assert "Unsupported clang-include-cleaner version '99'" in error + assert "Latest stable version: 22.1.7" in error - # Should fallback to hardcoded version when DEFAULT is None - mock_install.assert_called_once_with("clang-format", None) + +@pytest.mark.benchmark +def test_resolve_install_with_diagnostics_apply_replacements_invalid(): + with patch( + "cpp_linter_hooks.util._get_pypi_versions", side_effect=_pypi_side_effect + ): + path, error = resolve_install_with_diagnostics("clang-apply-replacements", "99") + + assert path is None + assert error is not None + assert "Unsupported clang-apply-replacements version '99'" in error + assert "Latest stable version: 17.0.6" in error + + +@pytest.mark.benchmark +def test_resolve_install_with_diagnostics_include_cleaner_verbose(capsys): + with ( + patch("shutil.which", return_value=None), + patch( + "cpp_linter_hooks.util._install_tool", + return_value=Path("/usr/bin/clang-include-cleaner"), + ), + patch( + "cpp_linter_hooks.util._get_pypi_versions", side_effect=_pypi_side_effect + ), + ): + path, error = resolve_install_with_diagnostics( + "clang-include-cleaner", "22", True + ) + + assert path == Path("/usr/bin/clang-include-cleaner") + assert error is None + assert ( + "Resolved clang-include-cleaner --version=22 to Python wheel version 22.1.7" + in capsys.readouterr().err + )