Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 8 additions & 12 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand All @@ -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
Expand Down
27 changes: 9 additions & 18 deletions .github/workflows/release-drafter.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
86 changes: 0 additions & 86 deletions .github/workflows/update-versions.yml

This file was deleted.

182 changes: 106 additions & 76 deletions cpp_linter_hooks/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +91 to +95

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚑ Quick win

Prefix matching can resolve the wrong major/minor line.

Using startswith() for partial version matching can select unintended versions (e.g., 21.1 also matches 21.10.x). Match on dot-separated segments instead.

Suggested fix
-    matched = [v for v in versions if v.startswith(user_input)]
+    requested_parts = user_input.split(".")
+    matched = [
+        v
+        for v in versions
+        if v.split(".")[: len(requested_parts)] == requested_parts
+    ]
     if matched:
         return matched[0], None
πŸ€– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cpp_linter_hooks/util.py` around lines 76 - 80, The prefix matching logic
using startswith() can incorrectly match unintended versions because it performs
a simple string prefix match rather than matching on dot-separated version
segments (e.g., "21.1" would match both "21.1.8" and "21.10.x"). Replace the
startswith() check in the list comprehension with logic that splits both the
user_input and each version string by dots, then verifies that the version
segments match the user_input segments at the beginning. This ensures proper
dot-separated segment matching so that "21.1" only matches versions starting
with "21.1" and not "21.10" or higher.


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 ``<tool> --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]:
Expand Down Expand Up @@ -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:
Expand Down
Loading
Loading