Skip to content

Commit d8299d6

Browse files
authored
feat: dynamic PyPI version resolution + add clang-include-cleaner & clang-apply-replacements (#242)
1 parent 6f66123 commit d8299d6

7 files changed

Lines changed: 538 additions & 538 deletions

File tree

.github/copilot-instructions.md

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,9 @@ Pre-commit hooks wrapper that auto-installs and runs clang-format and clang-tidy
1414
- **`clang_format.py`**: Wraps clang-format with `-i` (in-place), supports `--verbose` and `--dry-run` modes
1515
- Returns `-1` for dry-run to distinguish from actual failures
1616
- **`clang_tidy.py`**: Wraps clang-tidy, forces exit code 1 if "warning:" or "error:" in output
17-
- **`util.py`**: Version resolution and pip-based tool installation
18-
- `_resolve_version()`: Supports partial matches (e.g., "20" → "20.1.7")
19-
- `DEFAULT_CLANG_FORMAT_VERSION` and `DEFAULT_CLANG_TIDY_VERSION` read from `pyproject.toml`
20-
- **`versions.py`**: Auto-generated by `scripts/update_versions.py` (runs weekly via GitHub Actions)
17+
- **`util.py`**: Dynamic version resolution via PyPI JSON API + pip-based tool installation
18+
- `_resolve_version_from_pypi()`: Supports partial matches (e.g., "20" → "20.1.8"), resolves against PyPI in real time
19+
- No hardcoded version lists — versions are always up-to-date from PyPI
2120

2221
### Version Management Pattern
2322
```python
@@ -49,8 +48,8 @@ uv run pytest -m benchmark # Performance tests only
4948

5049
### Dependency Management
5150
- **Uses `uv`** for all dev operations (not pip directly)
52-
- **Pin versions**: Default tool versions in `pyproject.toml` dependencies section
53-
- **Update versions**: Run `python scripts/update_versions.py` (auto-runs weekly on Monday 2 AM UTC)
51+
- **Tool versions**: Resolved dynamically from PyPI at hook runtime — no manual updates needed
52+
- **Version caching**: `_get_pypi_versions()` uses `lru_cache` to avoid repeated PyPI requests within a single run
5453

5554
## Project-Specific Conventions
5655

@@ -78,8 +77,8 @@ command = ["tool-name"] + other_args # Pass through unknown args
7877

7978
## Critical Files
8079

81-
- **`pyproject.toml`**: Defines entry points, dependencies, default versions
82-
- **`versions.py`**: Auto-updated; DO NOT edit manually (see comment)
80+
- **`pyproject.toml`**: Defines entry points and dependencies
81+
- **`util.py`**: Core version resolution + tool installation logic
8382
- **`.pre-commit-hooks.yaml`**: Hook metadata for pre-commit framework
8483
- **`testing/run.sh`**: Integration test script used in CI
8584

@@ -102,10 +101,7 @@ command = ["tool-name"] + other_args # Pass through unknown args
102101
2. Pass to subprocess command or handle in Python
103102
3. Add test case in `tests/test_*.py`
104103

105-
**Update default tool versions:**
106-
1. Edit `dependencies` in `pyproject.toml`
107-
2. Run tests to ensure compatibility
108-
3. Update version in README examples
104+
**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.
109105

110106
**Debug hook failures:**
111107
- Add `--verbose` to clang-format args for detailed output

.github/workflows/release-drafter.yml

Lines changed: 9 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -19,26 +19,17 @@ jobs:
1919
- name: Checkout repository
2020
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
2121

22-
- name: Extract default tool versions
23-
id: versions
22+
- name: Generate release notes header
2423
run: |
25-
# Get versions directly from versions.py (no package install needed)
26-
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])")
27-
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])")
28-
29-
# Export to GitHub Actions environment for subsequent steps
30-
echo "CLANG_FORMAT_VERSION=$CLANG_FORMAT_VERSION" >> $GITHUB_ENV
31-
echo "CLANG_TIDY_VERSION=$CLANG_TIDY_VERSION" >> $GITHUB_ENV
32-
33-
# Log for debug
34-
echo "Default clang-format version: $CLANG_FORMAT_VERSION"
35-
echo "Default clang-tidy version: $CLANG_TIDY_VERSION"
36-
37-
# Generate release notes file
38-
echo "## 💡 Default Clang Tool Version" > release_notes.md
39-
echo "clang-format: \`$CLANG_FORMAT_VERSION\` · clang-tidy: \`$CLANG_TIDY_VERSION\`" >> release_notes.md
24+
echo "## 💡 Default Clang Tool Versions" > release_notes.md
25+
echo "" >> release_notes.md
26+
echo "Versions are resolved **dynamically from PyPI** at hook runtime. " >> release_notes.md
27+
echo "The latest stable versions are always used by default — " >> release_notes.md
28+
echo "no manual updates required." >> release_notes.md
4029
echo "" >> release_notes.md
41-
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
30+
echo "You can pin a specific version by adding the \`--version\` argument " >> release_notes.md
31+
echo "under \`args\` in your pre-commit config. " >> release_notes.md
32+
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
4233
echo "" >> release_notes.md
4334
cat release_notes.md
4435

.github/workflows/update-versions.yml

Lines changed: 0 additions & 86 deletions
This file was deleted.

cpp_linter_hooks/util.py

Lines changed: 106 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -5,98 +5,124 @@
55
import subprocess
66
from pathlib import Path
77
import logging
8-
from typing import Optional, List, Tuple
9-
10-
if sys.version_info >= (3, 11):
11-
import tomllib
12-
else:
13-
import tomli as tomllib
14-
15-
from cpp_linter_hooks.versions import CLANG_FORMAT_VERSIONS, CLANG_TIDY_VERSIONS
8+
from typing import Optional, Tuple
9+
from functools import lru_cache
10+
import json
11+
import urllib.request
12+
import re
1613

1714
LOG = logging.getLogger(__name__)
1815

1916

20-
def get_version_from_dependency(tool: str) -> Optional[str]:
21-
"""Get the version of a tool from the pyproject.toml dependencies."""
22-
pyproject_path = Path(__file__).parent.parent / "pyproject.toml"
23-
if not pyproject_path.exists():
24-
return None
25-
with open(pyproject_path, "rb") as f:
26-
data = tomllib.load(f)
27-
# Check [project].dependencies
28-
dependencies = data.get("project", {}).get("dependencies", [])
29-
for dep in dependencies:
30-
if dep.startswith(f"{tool}=="):
31-
return dep.split("==")[1]
32-
return None
33-
17+
@lru_cache(maxsize=4)
18+
def _get_pypi_versions(tool: str) -> Tuple[Optional[str], list]:
19+
"""Fetch (latest_version, [stable_versions_descending]) from PyPI JSON API.
3420
35-
DEFAULT_CLANG_FORMAT_VERSION = CLANG_FORMAT_VERSIONS[-1] # latest from versions.py
36-
DEFAULT_CLANG_TIDY_VERSION = CLANG_TIDY_VERSIONS[-1] # latest from versions.py
21+
Results are cached per tool name so repeated calls within the same
22+
process reuse the last HTTP response.
23+
"""
24+
try:
25+
url = f"https://pypi.org/pypi/{tool}/json"
26+
with urllib.request.urlopen(url, timeout=10) as response:
27+
data = json.loads(response.read())
28+
except Exception as exc:
29+
LOG.warning("Failed to fetch versions for %s from PyPI: %s", tool, exc)
30+
return None, []
31+
32+
all_versions = list(data["releases"].keys())
33+
34+
# Filter out pre-release versions
35+
pre_release_pattern = re.compile(
36+
r".*(alpha|beta|rc|dev|a\d+|b\d+).*", re.IGNORECASE
37+
)
38+
stable = [v for v in all_versions if not pre_release_pattern.match(v)]
3739

40+
if not stable:
41+
LOG.warning("No stable versions found for %s on PyPI", tool)
42+
return None, []
3843

39-
def _versions_for_tool(tool: str) -> List[str]:
40-
"""Return supported Python wheel versions for a clang tool."""
41-
return CLANG_FORMAT_VERSIONS if tool == "clang-format" else CLANG_TIDY_VERSIONS
44+
# Sort ascending by version tuple
45+
stable.sort(key=lambda x: tuple(map(int, x.split("."))))
4246

47+
latest = stable[-1]
48+
# Return descending for prefix matching (newest first)
49+
return latest, list(reversed(stable))
4350

44-
def _default_version_for_tool(tool: str) -> Optional[str]:
45-
"""Return the default Python wheel version for a clang tool."""
46-
return (
47-
DEFAULT_CLANG_FORMAT_VERSION
48-
if tool == "clang-format"
49-
else DEFAULT_CLANG_TIDY_VERSION
50-
)
51-
52-
53-
def _supported_versions_message(tool: str) -> str:
54-
"""Build a user-facing list of supported wheel versions for a clang tool."""
55-
versions = ", ".join(_versions_for_tool(tool))
56-
return f"Supported {tool} wheel versions: {versions}"
5751

52+
def _resolve_version_from_pypi(
53+
tool: str, user_input: Optional[str]
54+
) -> Tuple[Optional[str], Optional[str]]:
55+
"""Resolve a version dynamically from PyPI.
56+
57+
Returns (resolved_version, error_message). The error_message is
58+
suitable for displaying directly to the end user.
59+
60+
When PyPI is unreachable and no explicit version was requested,
61+
falls back to whatever version is already installed on the host
62+
so that pre-installed tools keep working offline.
63+
"""
64+
latest, versions = _get_pypi_versions(tool)
65+
66+
if not versions:
67+
if user_input is None:
68+
# PyPI is unreachable, but the user didn't ask for a specific
69+
# version – try the locally installed tool as a fallback.
70+
installed = _detect_installed_version(tool)
71+
if installed:
72+
LOG.info(
73+
"PyPI unreachable; using locally installed %s %s",
74+
tool,
75+
installed,
76+
)
77+
return installed, None
78+
return (
79+
None,
80+
f"Could not find any stable versions of {tool} on PyPI. "
81+
"Check your network connection.",
82+
)
5883

59-
def _resolve_version(versions: List[str], user_input: Optional[str]) -> Optional[str]:
60-
"""Resolve the latest matching version based on user input and available versions."""
6184
if user_input is None:
62-
return None
63-
if user_input in versions:
64-
return user_input
85+
return latest, None
6586

66-
try:
67-
# filter versions that start with the user input
68-
matched_versions = [v for v in versions if v.startswith(user_input)]
69-
if not matched_versions:
70-
raise ValueError
71-
72-
# define a function to parse version strings into tuples for comparison
73-
def parse_version(v: str):
74-
"""Convert a dotted version string into an integer tuple."""
75-
return tuple(map(int, v.split(".")))
87+
# Exact match
88+
if user_input in versions:
89+
return user_input, None
7690

77-
# return the latest version
78-
return max(matched_versions, key=parse_version)
91+
# Prefix match (e.g. "20" → "20.1.8"). Versions are newest-first,
92+
# so the first matching entry is the latest for that prefix.
93+
matched = [v for v in versions if v.startswith(user_input)]
94+
if matched:
95+
return matched[0], None
7996

80-
except ValueError:
81-
LOG.warning("Version %s not found in available versions", user_input)
82-
return None
97+
# No match – help the user
98+
sample = ", ".join(versions[:15])
99+
return (
100+
None,
101+
f"Unsupported {tool} version '{user_input}'.\n"
102+
f"Latest stable version: {latest}\n"
103+
f"Available versions (sample): {sample}\n"
104+
f"Run `pip index versions {tool}` to see all available versions.",
105+
)
83106

84107

85-
def resolve_tool_version(
86-
tool: str, version: Optional[str]
87-
) -> Tuple[Optional[str], Optional[str]]:
88-
"""Resolve a requested tool version or return a user-facing error message."""
89-
if version is None:
90-
return _default_version_for_tool(tool), None
108+
def _detect_installed_version(tool: str) -> Optional[str]:
109+
"""Return the version of *tool* already on PATH, or None.
91110
92-
resolved = _resolve_version(_versions_for_tool(tool), version)
93-
if resolved is None:
94-
return (
95-
None,
96-
f"Unsupported {tool} version '{version}'.\n"
97-
f"{_supported_versions_message(tool)}",
111+
Used as a fallback when PyPI is unreachable and no explicit version
112+
was requested. Extracts the version string from ``<tool> --version``
113+
output (e.g. ``"clang-format version 18.1.8"`` → ``"18.1.8"``).
114+
"""
115+
existing = shutil.which(tool)
116+
if not existing:
117+
return None
118+
try:
119+
result = subprocess.run(
120+
[existing, "--version"], capture_output=True, text=True, timeout=10
98121
)
99-
return resolved, None
122+
except (OSError, subprocess.TimeoutExpired):
123+
return None
124+
match = re.search(r"(\d+\.\d+\.\d+(?:\.\d+)?)", result.stdout)
125+
return match.group(1) if match else None
100126

101127

102128
def _is_version_installed(tool: str, version: str) -> Optional[Path]:
@@ -128,15 +154,19 @@ def _install_tool(tool: str, version: str) -> Optional[Path]:
128154
def resolve_install_with_diagnostics(
129155
tool: str, version: Optional[str], verbose: bool = False
130156
) -> Tuple[Optional[Path], Optional[str]]:
131-
"""Resolve/install a tool, returning a user-facing error for bad versions."""
132-
user_version, error = resolve_tool_version(tool, version)
157+
"""Resolve/install a tool, returning a user-facing error for bad versions.
158+
159+
Tool versions are resolved dynamically from PyPI — no hardcoded
160+
list is maintained in-tree.
161+
"""
162+
user_version, error = _resolve_version_from_pypi(tool, version)
133163
if error is not None:
134164
return None, error
135165

136166
if verbose:
137167
if version is None:
138168
print(
139-
f"Using default {tool} Python wheel version {user_version}",
169+
f"Using latest {tool} Python wheel version {user_version}",
140170
file=sys.stderr,
141171
)
142172
elif version == user_version:

0 commit comments

Comments
 (0)