Files
pybind11/tools/make_changelog.py
pre-commit-ci[bot]pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>Henry Schreiner
6dd1756f98 chore(deps): update pre-commit hooks (#6126)
* chore(deps): update pre-commit hooks

updates:
- [github.com/pre-commit/mirrors-clang-format: v22.1.5 → v22.1.8](https://github.com/pre-commit/mirrors-clang-format/compare/v22.1.5...v22.1.8)
- [github.com/astral-sh/ruff-pre-commit: v0.15.20 → v0.16.1](https://github.com/astral-sh/ruff-pre-commit/compare/v0.15.20...v0.16.1)
- [github.com/pre-commit/mirrors-mypy: v2.1.0 → v2.3.0](https://github.com/pre-commit/mirrors-mypy/compare/v2.1.0...v2.3.0)
- [github.com/codespell-project/codespell: v2.4.2 → v2.4.3](https://github.com/codespell-project/codespell/compare/v2.4.2...v2.4.3)

* style: pre-commit fixes

* fix: adapt to the ruff 0.16 default rule set

The hook update pulled in ruff 0.16, which expanded its default rules and
autofixed `self: S -> S` to `-> Self`. That fix added a runtime
`typing_extensions` import to setup_helpers.py, which must stay
dependency-free; it broke every test job whose environment does not supply
that package. The import is now guarded by TYPE_CHECKING.

Also replace the `exec` in docs/conf.py with an importlib module load,
parenthesize implicit string concatenations in list literals, mark unused
unpacked variables with a leading underscore, and make noxfile.py
executable to match its shebang.

The remaining new rules are silenced per-file, with reasons: the chrono
tests check naive local time on purpose, and the other rules conflict with
how the tests are written.

Assisted-by: ClaudeCode:claude-opus-5

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Henry Schreiner <henryfs@princeton.edu>
2026-08-06 01:26:39 -04:00

151 lines
4.2 KiB
Python
Executable File

#!/usr/bin/env -S uv run
# /// script
# dependencies = ["ghapi>=2", "rich"]
# ///
from __future__ import annotations
import os
import re
import subprocess
import ghapi.all
from rich import print
from rich.syntax import Syntax
MD_ENTRY = re.compile(
r"""
^\#+\ Suggested\ changelog\ entry:?\s*$ # Match the heading, colon optional
(?:\s*<!--.*?-->)? # Optionally match one comment
(?P<content>.*?) # Lazily capture content until...
(?= # Lookahead for one of the following:
^-{3,}\s*$ # A line with 3 or more dashes
| ^<!--\s*readthedocs # A comment starting with 'readthedocs'
| ^\#\# # A new heading
| \Z # End of string
)
""",
re.DOTALL | re.VERBOSE | re.MULTILINE,
)
# Conventional commit prefix, such as "fix(cmake)!:"
TITLE_CAT = re.compile(r"(?P<cat>\w+)(?:\((?P<sub>[^)]+)\))?!?:")
print()
def get_token() -> str | None:
"""
Unauthenticated requests get a shared CDN cache ("Cache-Control: public"),
which returns stale bodies and labels. A token makes the cache private.
"""
token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
if token:
return token
try:
result = subprocess.run(
["gh", "auth", "token"], capture_output=True, text=True, check=True
)
except (OSError, subprocess.CalledProcessError):
print("[yellow]No GitHub token found; results may be stale (cached).")
return None
return result.stdout.strip()
LABEL = "needs changelog"
api = ghapi.all.GhApi(owner="pybind", repo="pybind11", token=get_token(), sync=True)
issues_pages = ghapi.page.sync_paged(
api.issues.list_for_repo, labels=LABEL, state="closed", per_page=100
)
# The server-side label filter lags behind label removals, so re-check each issue
issues = (
issue
for page in issues_pages
for issue in page
if any(label.name == LABEL for label in issue.labels)
)
missing = []
old = []
cats_descr = {
"feat": "New Features",
"fix": "Bug fixes",
"docs": "Documentation",
"tests": "Tests",
"ci": "CI",
"chore": "Other",
"unknown": "Uncategorised",
}
# Each main category maps its subcategories ("" if there is none) to entries
cats: dict[str, dict[str, list[str]]] = {c: {} for c in cats_descr}
for issue in issues:
if "```rst" in issue.body:
old.append(issue)
continue
changelog = MD_ENTRY.search(issue.body or "")
if not changelog:
missing.append(issue)
continue
msg = changelog.group("content").strip()
if not msg:
missing.append(issue)
continue
msg = msg.removeprefix("* ")
if not msg.startswith("- "):
msg = "- " + msg
if not msg.endswith("."):
msg += "."
if msg == "- Placeholder.":
missing.append(issue)
continue
msg += f"\n [#{issue.number}]({issue.html_url})"
title = TITLE_CAT.match(issue.title)
cat = title.group("cat").lower() if title else "unknown"
sub = (title.group("sub") or "").lower() if title else ""
if cat not in cats:
cat, sub = "unknown", ""
cats[cat].setdefault(sub, []).append(msg)
for cat, subs in cats.items():
if subs:
print(f"[bold]{cats_descr[cat]}:")
print()
# An empty subcategory sorts first, so plain entries lead the section
for sub in sorted(subs):
if sub:
print(f"<!-- {cat}({sub}) -->")
print()
for msg in subs[sub]:
print(Syntax(msg, "md", theme="ansi_light", word_wrap=True))
print()
print()
if missing:
print()
print("[blue]" + "-" * 30)
print()
for issue in missing:
print(f"[red bold]Missing:[/red bold][red] {issue.title}")
print(f"[red] {issue.html_url}\n")
print("[bold]Template:\n")
msg = "## Suggested changelog entry:"
print(Syntax(msg, "md", theme="ansi_light"))
if old:
print()
print("[red]" + "-" * 30)
print()
for issue in old:
print(f"[red bold]Old:[/red bold][red] {issue.title}")
print(f"[red] {issue.html_url}\n")
print()