chore: import upstream snapshot with attribution
fuzz / fuzz (3.11) (push) Failing after 1s
fuzz / fuzz (3.12) (push) Failing after 1s
build and publish / sdist + pure wheel (push) Failing after 0s
diff-shades / analysis / base / ${{ matrix.mode }} (push) Has been skipped
docs / docs (ubuntu-latest) (push) Failing after 2s
fuzz / fuzz (3.10) (push) Failing after 1s
fuzz / fuzz (3.14) (push) Failing after 0s
lint and format / lint (push) Failing after 1s
diff-shades / configure (push) Failing after 1s
docker / build (linux/amd64) (push) Has been skipped
diff-shades / analysis / target / ${{ matrix.mode }} (push) Has been skipped
fuzz / fuzz (3.13) (push) Failing after 0s
build and publish / generate wheels matrix (push) Failing after 0s
test release tool / test-release-tool (ubuntu-latest, 3.13) (push) Failing after 1s
build and publish / mypyc wheels ${{ matrix.only }} (push) Has been skipped
test / test (ubuntu-latest, 3.10) (push) Failing after 0s
test / test (ubuntu-latest, 3.11) (push) Failing after 1s
test / test (ubuntu-latest, 3.13) (push) Failing after 0s
test / test (ubuntu-latest, pypy3.11-v7.3.22) (push) Failing after 1s
test / test (ubuntu-latest, 3.15) (push) Failing after 1s
test release tool / test-release-tool (ubuntu-latest, 3.15) (push) Failing after 0s
zizmor / zizmor (push) Failing after 0s
test release tool / test-release-tool (ubuntu-latest, 3.12) (push) Failing after 4s
test release tool / test-release-tool (ubuntu-latest, 3.14) (push) Failing after 3s
test / uvloop (ubuntu-latest) (push) Failing after 1s
test / test (ubuntu-latest, 3.14) (push) Failing after 1s
test / test (ubuntu-latest, 3.12.10) (push) Failing after 5s
docker / build (linux/arm64) (push) Has been cancelled
docker / push (push) Has been cancelled
docs / docs (windows-latest) (push) Has been cancelled
test release tool / test-release-tool (windows-latest, 3.14) (push) Has been cancelled
test release tool / test-release-tool (windows-latest, 3.15) (push) Has been cancelled
test release tool / test-release-tool (macOS-latest, 3.12) (push) Has been cancelled
test release tool / test-release-tool (macOS-latest, 3.13) (push) Has been cancelled
test release tool / test-release-tool (macOS-latest, 3.14) (push) Has been cancelled
test release tool / test-release-tool (macOS-latest, 3.15) (push) Has been cancelled
test release tool / test-release-tool (windows-latest, 3.12) (push) Has been cancelled
test release tool / test-release-tool (windows-latest, 3.13) (push) Has been cancelled
test / test (macOS-latest, 3.11) (push) Has been cancelled
test / test (macOS-latest, 3.12.10) (push) Has been cancelled
test / test (macOS-latest, 3.13) (push) Has been cancelled
test / test (macOS-latest, 3.14) (push) Has been cancelled
test / test (macOS-latest, 3.15) (push) Has been cancelled
test / test (macOS-latest, pypy3.11-v7.3.22) (push) Has been cancelled
test / test (windows-11-arm, 3.11) (push) Has been cancelled
test / test (windows-11-arm, 3.12.10) (push) Has been cancelled
test / test (windows-11-arm, 3.13) (push) Has been cancelled
test / test (macOS-latest, 3.10) (push) Has been cancelled
test / coveralls-finish (push) Has been cancelled
test / uvloop (macOS-latest) (push) Has been cancelled
test / uvloop (windows-11-arm) (push) Has been cancelled
test / uvloop (windows-latest) (push) Has been cancelled
test / test (windows-11-arm, 3.14) (push) Has been cancelled
test / test (windows-11-arm, 3.15) (push) Has been cancelled
test / test (windows-latest, 3.10) (push) Has been cancelled
test / test (windows-latest, 3.11) (push) Has been cancelled
test / test (windows-latest, 3.12.10) (push) Has been cancelled
test / test (windows-latest, 3.13) (push) Has been cancelled
test / test (windows-latest, 3.14) (push) Has been cancelled
test / test (windows-latest, 3.15) (push) Has been cancelled
test / test (windows-latest, pypy3.11-v7.3.22) (push) Has been cancelled
diff-shades / compare / ${{ matrix.mode }} (push) Has been cancelled
fuzz / create-issue (push) Has been cancelled
build and publish / publish-mypyc (push) Has been cancelled
build and publish / publish-hatch (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:07:39 +08:00
commit d0aab9212a
460 changed files with 145411 additions and 0 deletions
View File
@@ -0,0 +1,63 @@
"""
Check that the rev value in the example pre-commit configuration matches
the latest version of Black. This saves us from forgetting to update that
during the release process.
Why can't we just use `rev: stable` and call it a day? Well pre-commit
won't auto update the hook as you may expect (and for good reasons, some
technical and some pragmatic). Encouraging bad practice is also just
not ideal. xref: https://github.com/psf/black/issues/420
"""
import os
import sys
import commonmark
import yaml
from bs4 import BeautifulSoup
def main(changes: str, content: str, filename: str) -> None:
changes_html = commonmark.commonmark(changes)
changes_soup = BeautifulSoup(changes_html, "html.parser")
headers = changes_soup.find_all("h2")
latest_tag, *_ = (
(header.string or "").removeprefix("Version ")
for header in headers
if header.string != "Unreleased"
)
source_version_control_html = commonmark.commonmark(content)
source_version_control_soup = BeautifulSoup(
source_version_control_html, "html.parser"
)
codeblocks = source_version_control_soup.find_all(class_="language-yaml")
for codeblock in codeblocks:
parsed = yaml.safe_load(codeblock.string) # type: ignore[arg-type]
if not isinstance(parsed, dict):
return
pre_commit_rev = parsed["repos"][0]["rev"]
if not pre_commit_rev == latest_tag:
print(
f"Please set the rev in ``{filename}`` to be the latest one.\n"
f"Expected {latest_tag}, got {pre_commit_rev}.\n"
)
sys.exit(1)
if __name__ == "__main__":
with open("CHANGES.md", encoding="utf-8") as fd:
changes = fd.read()
with open(
os.path.join("docs", "integrations", "source_version_control.md"),
encoding="utf-8",
) as fd:
content = fd.read()
main(changes, content, "source_version_control.md")
with open(
os.path.join("docs", "guides", "using_black_with_jupyter_notebooks.md"),
encoding="utf-8",
) as fd:
content = fd.read()
main(changes, content, "using_black_with_jupyter_notebooks.md")
@@ -0,0 +1,54 @@
"""
Check that the rev value in the example from ``the_basics.md`` matches
the latest version of Black. This saves us from forgetting to update that
during the release process.
"""
import os
import sys
import commonmark
from bs4 import BeautifulSoup
def main(changes: str, the_basics: str) -> None:
changes_html = commonmark.commonmark(changes)
changes_soup = BeautifulSoup(changes_html, "html.parser")
headers = changes_soup.find_all("h2")
tags = []
for header in headers:
text = header.string or ""
if text == "Unreleased":
continue
# Strip "Version " prefix if present
tags.append(text.removeprefix("Version "))
latest_tag = tags[0]
the_basics_html = commonmark.commonmark(the_basics)
the_basics_soup = BeautifulSoup(the_basics_html, "html.parser")
version_examples = [
code_block.string
for code_block in the_basics_soup.find_all(class_="language-console")
if "$ black --version" in code_block.string # type: ignore[operator]
]
for tag in tags:
for version_example in version_examples:
if tag in version_example and tag != latest_tag: # type: ignore[operator]
print(
"Please set the version in the ``black --version`` "
"examples from ``the_basics.md`` to be the latest one.\n"
f"Expected {latest_tag}, got {tag}.\n"
)
sys.exit(1)
if __name__ == "__main__":
with open("CHANGES.md", encoding="utf-8") as fd:
changes = fd.read()
with open(
os.path.join("docs", "usage_and_configuration", "the_basics.md"),
encoding="utf-8",
) as fd:
the_basics = fd.read()
main(changes, the_basics)
+231
View File
@@ -0,0 +1,231 @@
"""Helper script for psf/black's diff-shades Github Actions integration.
diff-shades is a tool for analyzing what happens when you run Black on
OSS code capturing it for comparisons or other usage. It's used here to
help measure the impact of a change *before* landing it (in particular
posting a comment on completion for PRs).
This script exists as a more maintainable alternative to using inline
Javascript in the workflow YAML files. The revision configuration and
resolving, caching, and PR comment logic is contained here.
For more information, please see the developer docs:
https://black.readthedocs.io/en/latest/contributing/gauging_changes.html#diff-shades
"""
import json
import os
import platform
import pprint
import subprocess
import sys
from base64 import b64encode
from os.path import dirname, join
from pathlib import Path
from typing import Any, Final
import click
import urllib3
from packaging.version import Version
COMMENT_FILE: Final = ".pr-comment.md"
DIFF_STEP_NAME: Final = "Generate HTML diff report"
DOCS_URL: Final = (
"https://black.readthedocs.io/en/latest/contributing/gauging_changes.html#diff-shades"
)
SHA_LENGTH: Final = 10
GH_API_TOKEN: Final = os.getenv("GITHUB_TOKEN")
REPO: Final = os.getenv("GITHUB_REPOSITORY", default="psf/black")
USER_AGENT: Final = f"{REPO} diff-shades workflow via urllib3/{urllib3.__version__}"
http = urllib3.PoolManager()
def set_output(name: str, value: str) -> None:
if len(value) < 200:
print(f"[INFO]: setting '{name}' to '{value}'")
else:
print(f"[INFO]: setting '{name}' to [{len(value)} chars]")
if "GITHUB_OUTPUT" in os.environ:
if "\n" in value:
# https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#multiline-strings
delimiter = b64encode(os.urandom(16)).decode()
value = f"{delimiter}\n{value}\n{delimiter}"
command = f"{name}<<{value}"
else:
command = f"{name}={value}"
with open(os.environ["GITHUB_OUTPUT"], "a") as f:
print(command, file=f)
def http_get(url: str, *, is_json: bool = True, **kwargs: Any) -> Any:
headers = kwargs.get("headers") or {}
headers["User-Agent"] = USER_AGENT
if "github" in url:
if GH_API_TOKEN:
headers["Authorization"] = f"token {GH_API_TOKEN}"
headers["Accept"] = "application/vnd.github.v3+json"
kwargs["headers"] = headers
r = http.request("GET", url, **kwargs)
if is_json:
data = json.loads(r.data.decode("utf-8"))
else:
data = r.data
print(f"[INFO]: issued GET request for {r.geturl()}")
if not (200 <= r.status < 300):
pprint.pprint(dict(r.info()))
pprint.pprint(data)
raise RuntimeError(f"unexpected status code: {r.status}")
return data
def get_latest_revision(ref: str) -> str:
data = http_get(
f"https://api.github.com/repos/{REPO}/commits",
fields={"per_page": "1", "sha": ref},
)
assert isinstance(data[0]["sha"], str)
return data[0]["sha"]
def get_pr_branches(pr: int | None = None) -> tuple[Any, Any, int]:
if not pr:
pr_ref = os.getenv("GITHUB_REF")
assert pr_ref is not None
pr = int(pr_ref[10:-6])
data = http_get(f"https://api.github.com/repos/{REPO}/pulls/{pr}")
assert isinstance(data["base"]["sha"], str)
assert isinstance(data["head"]["sha"], str)
return data["base"], data["head"], pr
def get_pypi_version() -> Version:
data = http_get("https://pypi.org/pypi/black/json")
versions = [Version(v) for v in data["releases"]]
sorted_versions = sorted(versions, reverse=True)
return sorted_versions[0]
@click.group()
def main() -> None:
pass
@main.command("config", help="Acquire run configuration and metadata.")
def config() -> None:
import diff_shades # type: ignore[import-not-found]
jobs = [{"mode": "preview-new-changes", "style": "preview"}]
event = os.getenv("GITHUB_EVENT_NAME")
if event == "push":
# Push on main, let's use PyPI Black as the baseline.
baseline_name = str(get_pypi_version())
baseline_cmd = f"git checkout {baseline_name}"
target_rev = os.getenv("GITHUB_SHA")
assert target_rev is not None
target_name = "main-" + target_rev[:SHA_LENGTH]
target_cmd = f"git checkout {target_rev}"
elif event == "pull_request":
jobs.insert(0, {"mode": "assert-no-changes", "style": "stable"})
# PR, let's use the PR base as the baseline.
base, head, pr_num = get_pr_branches()
baseline_rev = get_latest_revision(base["ref"])
baseline_name = f"{base['ref']}-{baseline_rev[:SHA_LENGTH]}"
baseline_cmd = f"git checkout {baseline_rev}"
target_name = f"pr-{pr_num}-{head['sha'][:SHA_LENGTH]}"
target_cmd = f"gh pr checkout {pr_num}\ngit merge origin/{base['ref']}"
else:
raise ValueError(f"Unknown event {event}")
env = f"{platform.system()}-{platform.python_version()}-{diff_shades.__version__}"
for entry in jobs:
entry["baseline-analysis"] = f"{entry['style']}-{baseline_name}.json"
entry["baseline-setup-cmd"] = baseline_cmd
entry["baseline-cache-key"] = f"{env}-{baseline_name}-{entry['style']}"
entry["target-analysis"] = f"{entry['style']}-{target_name}.json"
entry["target-setup-cmd"] = target_cmd
set_output("matrix", json.dumps(jobs, indent=None))
pprint.pprint(jobs)
@main.command("comment-body", help="Generate the body for a summary PR comment.")
@click.argument("baseline", type=click.Path(exists=True, path_type=Path))
@click.argument("target", type=click.Path(exists=True, path_type=Path))
@click.argument("style")
@click.argument("mode")
def comment_body(baseline: Path, target: Path, style: str, mode: str) -> None:
cmd = (
f"{sys.executable} -m diff_shades --no-color "
f"compare {baseline} {target} --quiet --check"
).split(" ")
proc = subprocess.run(cmd, stdout=subprocess.PIPE, encoding="utf-8")
if proc.returncode:
run_id = os.getenv("GITHUB_RUN_ID")
jobs = http_get(
f"https://api.github.com/repos/{REPO}/actions/runs/{run_id}/jobs",
)["jobs"]
job = next(j for j in jobs if j["name"] == f"compare / {mode}")
diff_step = next(s for s in job["steps"] if s["name"] == DIFF_STEP_NAME)
diff_url = f"{job['html_url']}#step:{diff_step['number']}:1"
body = (
"<details>"
f"<summary><b><code>--{style}</code> style</b> "
f'(<a href="{diff_url}">View full diff</a>):</summary>'
f"<pre>{proc.stdout.strip()}</pre>"
"</details>"
)
else:
body = f"<b><code>--{style}</code> style</b>: no changes"
filename = f".{style}{COMMENT_FILE}"
print(f"[INFO]: writing comment details to {filename}")
with open(filename, "w", encoding="utf-8") as f:
f.write(body)
@main.command("comment-details", help="Get PR comment resources from a workflow run.")
@click.argument("pr")
@click.argument("run-id")
@click.argument("styles", nargs=-1)
def comment_details(pr: int, run_id: str, styles: tuple[str, ...]) -> None:
base, head, _ = get_pr_branches(pr)
lines = [
f"**diff-shades** results comparing this PR ({head['sha']}) to {base['ref']}"
f" ({base['sha']}):"
]
for style_file in styles:
with open(
join(dirname(__file__), "..", style_file),
"r",
encoding="utf-8",
) as f:
content = f.read()
lines.append(content)
lines.append("---")
lines.append(
f"[**What is this?**]({DOCS_URL}) | "
f"[Workflow run](https://github.com/psf/black/actions/runs/{run_id}) | "
"[diff-shades documentation](https://github.com/ichard26/diff-shades#readme)"
)
body = "\n\n".join(lines)
set_output("comment-body", body)
if __name__ == "__main__":
main()
+73
View File
@@ -0,0 +1,73 @@
"""Property-based tests for Black.
By Zac Hatfield-Dodds, based on my Hypothesmith tool for source code
generation. You can run this file with `python`, `pytest`, or (soon)
a coverage-guided fuzzer I'm working on.
"""
import hypothesmith
from hypothesis import HealthCheck, given, settings
from hypothesis import strategies as st
import black
# This test uses the Hypothesis and Hypothesmith libraries to generate random
# syntactically-valid Python source code and run Black in odd modes.
@settings(
max_examples=1000, # roughly 1k tests/minute, or half that under coverage
derandomize=True, # deterministic mode to avoid CI flakiness
deadline=None, # ignore Hypothesis' health checks; we already know that
suppress_health_check=list(HealthCheck), # this is slow and filter-heavy.
)
@given(
# Note that while Hypothesmith might generate code unlike that written by
# humans, it's a general test that should pass for any *valid* source code.
# (so e.g. running it against code scraped of the internet might also help)
src_contents=hypothesmith.from_grammar() | hypothesmith.from_node(),
# Using randomly-varied modes helps us to exercise less common code paths.
mode=st.builds(
black.FileMode,
line_length=st.just(88) | st.integers(0, 200),
string_normalization=st.booleans(),
preview=st.booleans(),
is_pyi=st.booleans(),
magic_trailing_comma=st.booleans(),
),
)
def test_idempotent_any_syntactically_valid_python(
src_contents: str, mode: black.FileMode
) -> None:
# Before starting, let's confirm that the input string is valid Python:
compile(src_contents, "<string>", "exec") # else the bug is in hypothesmith
# Then format the code...
dst_contents = black.format_str(src_contents, mode=mode)
# And check that we got equivalent and stable output.
black.assert_equivalent(src_contents, dst_contents)
black.assert_stable(src_contents, dst_contents, mode=mode)
# Future test: check that pure-python and mypyc versions of black
# give identical output for identical input?
if __name__ == "__main__":
# Run tests, including shrinking and reporting any known failures.
test_idempotent_any_syntactically_valid_python()
# If Atheris is available, run coverage-guided fuzzing.
# (if you want only bounded fuzzing, just use `pytest fuzz.py`)
try:
import sys
import atheris
except ImportError:
pass
else:
test = test_idempotent_any_syntactically_valid_python
atheris.Setup(
sys.argv,
test.hypothesis.fuzz_one_input, # type: ignore[attr-defined]
)
atheris.Fuzz()
+75
View File
@@ -0,0 +1,75 @@
import json
from typing import IO, Any
import click
import black
def generate_schema_from_click(
cmd: click.Command,
) -> dict[str, Any]:
result: dict[str, dict[str, Any]] = {}
for param in cmd.params:
if not isinstance(param, click.Option) or param.is_eager:
continue
assert param.name
name = param.name.replace("_", "-")
result[name] = {}
match param.type:
case click.types.IntParamType():
result[name]["type"] = "integer"
case click.types.StringParamType() | click.types.Path():
result[name]["type"] = "string"
case click.types.Choice(choices=choices):
result[name]["enum"] = choices
case click.types.BoolParamType():
result[name]["type"] = "boolean"
case _:
msg = f"{param.type!r} not a known type for {param}"
raise TypeError(msg)
if param.multiple:
result[name] = {"type": "array", "items": result[name]}
result[name]["description"] = param.help
default = param.to_info_dict()["default"]
if default is not None and not param.multiple:
result[name]["default"] = default
return result
@click.command(context_settings={"help_option_names": ["-h", "--help"]})
@click.option("--schemastore", is_flag=True, help="SchemaStore format")
@click.option("--outfile", type=click.File(mode="w"), help="Write to file")
def main(schemastore: bool, outfile: IO[str]) -> None:
properties = generate_schema_from_click(black.main)
del properties["line-ranges"]
schema: dict[str, Any] = {
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": (
"https://github.com/psf/black/blob/main/src/black/resources/black.schema.json"
),
"$comment": "tool.black table in pyproject.toml",
"type": "object",
"additionalProperties": False,
"properties": properties,
}
if schemastore:
schema["$id"] = "https://json.schemastore.org/partial-black.json"
# The precise list of unstable features may change frequently, so don't
# bother putting it in SchemaStore
schema["properties"]["enable-unstable-feature"]["items"] = {"type": "string"}
print(json.dumps(schema, indent=2), file=outfile)
if __name__ == "__main__":
main()
+66
View File
@@ -0,0 +1,66 @@
"""Generates a width table for Unicode characters.
This script generates a width table for Unicode characters that are not
narrow (width 1). The table is written to src/black/_width_table.py (note
that although this file is generated, it is checked into Git) and is used
by the char_width() function in src/black/strings.py.
You should run this script when you upgrade wcwidth, which is expected to
happen when a new Unicode version is released. The generated table contains
the version of wcwidth and Unicode that it was generated for.
In order to run this script, you need to install the latest version of wcwidth.
You can do this by running:
pip install --group width-table
"""
import sys
from collections.abc import Iterable
from os.path import basename, dirname, join
import wcwidth # type: ignore[import-not-found]
def make_width_table() -> Iterable[tuple[int, int, int]]:
start_codepoint = -1
end_codepoint = -1
range_width = -2
for codepoint in range(0, sys.maxunicode + 1):
width = wcwidth.wcwidth(chr(codepoint))
if width <= 1:
# Ignore narrow characters along with zero-width characters so that
# they are treated as single-width. Note that treating zero-width
# characters as single-width is consistent with the heuristics built
# on top of str.isascii() in the str_width() function in strings.py.
continue
if start_codepoint < 0:
start_codepoint = codepoint
range_width = width
elif width != range_width or codepoint != end_codepoint + 1:
yield (start_codepoint, end_codepoint, range_width)
start_codepoint = codepoint
range_width = width
end_codepoint = codepoint
if start_codepoint >= 0:
yield (start_codepoint, end_codepoint, range_width)
def main() -> None:
table_path = join(dirname(__file__), "..", "src", "black", "_width_table.py")
with open(table_path, "w") as f:
f.write(f"""# Generated by {basename(__file__)}
# wcwidth {wcwidth.__version__}
# Unicode {wcwidth.list_versions()[-1]}
from typing import Final
WIDTH_TABLE: Final[list[tuple[int, int, int]]] = [
""")
for triple in make_width_table():
f.write(f" {triple!r},\n")
f.write("]\n")
if __name__ == "__main__":
main()
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env python3
# check out every commit added by the current branch, blackify them,
# and generate diffs to reconstruct the original commits, but then
# blackified
import logging
import os
import sys
from subprocess import PIPE, Popen, check_output, run
def git(*args: str) -> str:
return check_output(["git", *args]).decode("utf8").strip()
def blackify(base_branch: str, black_command: str, logger: logging.Logger) -> int:
current_branch = git("branch", "--show-current")
if not current_branch or base_branch == current_branch:
logger.error("You need to check out a feature branch to work on")
return 1
if not os.path.exists(".git"):
logger.error("Run me in the root of your repo")
return 1
merge_base = git("merge-base", "HEAD", base_branch)
if not merge_base:
logger.error(
f"Could not find a common commit for current head and {base_branch}"
)
return 1
commits = git(
"log", "--reverse", "--pretty=format:%H", f"{merge_base}~1..HEAD"
).split()
for commit in commits:
git("checkout", commit, f"-b{commit}-black")
check_output(black_command, shell=True)
git("commit", "-aqm", "blackify")
git("checkout", base_branch, f"-b{current_branch}-black")
for last_commit, commit in zip(commits, commits[1:], strict=False):
allow_empty = (
b"--allow-empty" in run(["git", "apply", "-h"], stdout=PIPE).stdout
)
quiet = b"--quiet" in run(["git", "apply", "-h"], stdout=PIPE).stdout
git_diff = Popen(
[
"git",
"diff",
"--binary",
"--find-copies",
f"{last_commit}-black..{commit}-black",
],
stdout=PIPE,
)
git_apply = Popen(
[
"git",
"apply",
]
+ (["--quiet"] if quiet else [])
+ [
"-3",
"--intent-to-add",
]
+ (["--allow-empty"] if allow_empty else [])
+ [
"-",
],
stdin=git_diff.stdout,
)
if git_diff.stdout is not None:
git_diff.stdout.close()
git_apply.communicate()
git("commit", "--allow-empty", "-aqC", commit)
for commit in commits:
git("branch", "-qD", f"{commit}-black")
return 0
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("base_branch")
parser.add_argument("--black_command", default="black -q .")
parser.add_argument("--logfile", type=argparse.FileType("w"), default=sys.stdout)
args = parser.parse_args()
logger = logging.getLogger(__name__)
logger.addHandler(logging.StreamHandler(args.logfile))
logger.setLevel(logging.INFO)
sys.exit(blackify(args.base_branch, args.black_command, logger))
+248
View File
@@ -0,0 +1,248 @@
#!/usr/bin/env python3
"""
Tool to help automate changes needed in commits during and after releases
"""
from __future__ import annotations
import argparse
import logging
import re
import sys
from datetime import datetime
from pathlib import Path
from subprocess import run
LOG = logging.getLogger(__name__)
NEW_VERSION_CHANGELOG_TEMPLATE = """\
## Unreleased
<!-- PR authors:
Please include the PR number in the changelog entry, not the issue number -->
### Highlights
<!-- Include any especially major or disruptive changes here -->
### Stable style
<!-- Changes that affect Black's stable style -->
### Preview style
<!-- Changes that affect Black's preview style -->
### Configuration
<!-- Changes to how Black can be configured -->
### Packaging
<!-- Changes to how Black is packaged, such as dependency requirements -->
### Parser
<!-- Changes to the parser or to version autodetection -->
### Performance
<!-- Changes that improve Black's performance. -->
### Output
<!-- Changes to Black's terminal output and error messages -->
### _Blackd_
<!-- Changes to blackd -->
### Integrations
<!-- For example, Docker, GitHub Actions, pre-commit, editors -->
### Documentation
<!-- Major changes to documentation and policies. Small docs changes
don't need a changelog entry. -->
"""
class NoGitTagsError(Exception): ...
# TODO: Do better with alpha + beta releases
# Maybe we vendor packaging library
def get_git_tags(versions_only: bool = True) -> list[str]:
"""Pull out all tags or calvers only"""
cp = run(["git", "tag"], capture_output=True, check=True, encoding="utf8")
if not cp.stdout:
LOG.error(f"Returned no git tags stdout: {cp.stderr}")
raise NoGitTagsError
git_tags = cp.stdout.splitlines()
if versions_only:
return [t for t in git_tags if t[0].isdigit()]
return git_tags
# TODO: Support sorting alpha/beta releases correctly
def tuple_calver(calver: str) -> tuple[int, ...]: # mypy can't notice maxsplit below
"""Convert a calver string into a tuple of ints for sorting"""
try:
return tuple(map(int, calver.split(".", maxsplit=2)))
except ValueError:
return (0, 0, 0)
class SourceFiles:
def __init__(self, black_repo_dir: Path):
# File path fun all pathlib to be platform agnostic
self.black_repo_path = black_repo_dir
self.changes_path = self.black_repo_path / "CHANGES.md"
self.docs_path = self.black_repo_path / "docs"
self.version_doc_paths = (
self.docs_path / "integrations" / "source_version_control.md",
self.docs_path / "usage_and_configuration" / "the_basics.md",
self.docs_path / "guides" / "using_black_with_jupyter_notebooks.md",
)
self.current_version = self.get_current_version()
self.next_version = self.get_next_version()
def __str__(self) -> str:
return f"""\
> SourceFiles ENV:
Repo path: {self.black_repo_path}
CHANGES.md path: {self.changes_path}
docs path: {self.docs_path}
Current version: {self.current_version}
Next version: {self.next_version}
"""
def add_template_to_changes(self) -> int:
"""Add the template to CHANGES.md if it does not exist"""
LOG.info(f"Adding template to {self.changes_path}")
with self.changes_path.open("r", encoding="utf-8") as cfp:
changes_string = cfp.read()
if "## Unreleased" in changes_string:
LOG.error(f"{self.changes_path} already has unreleased template")
return 1
templated_changes_string = changes_string.replace(
"# Change Log\n",
f"# Change Log\n\n{NEW_VERSION_CHANGELOG_TEMPLATE}",
)
with self.changes_path.open("w", encoding="utf-8") as cfp:
cfp.write(templated_changes_string)
LOG.info(f"Added template to {self.changes_path}")
return 0
def cleanup_changes_template_for_release(self) -> None:
LOG.info(f"Cleaning up {self.changes_path}")
with self.changes_path.open("r", encoding="utf-8") as cfp:
changes_string = cfp.read()
# Change Unreleased to next version
changes_string = changes_string.replace(
"## Unreleased", f"## Version {self.next_version}"
)
# Remove all comments
changes_string = re.sub(r"(?m)^<!--(?>(?:.|\n)*?-->)\n+", "", changes_string)
# Remove empty subheadings
changes_string = re.sub(r"(?m)^###.+\n+(?=#)", "", changes_string)
with self.changes_path.open("w", encoding="utf-8") as cfp:
cfp.write(changes_string)
LOG.debug(f"Finished Cleaning up {self.changes_path}")
def get_current_version(self) -> str:
"""Get the latest git (version) tag as latest version"""
return sorted(get_git_tags(), key=lambda k: tuple_calver(k))[-1]
def get_next_version(self) -> str:
"""Workout the year and month + version number we need to move to"""
base_calver = datetime.today().strftime("%y.%m")
calver_parts = base_calver.split(".")
base_calver = f"{calver_parts[0]}.{int(calver_parts[1])}" # Remove leading 0
git_tags = get_git_tags()
same_month_releases = [
t for t in git_tags if t.startswith(base_calver) and "a" not in t
]
if len(same_month_releases) < 1:
return f"{base_calver}.0"
same_month_version = same_month_releases[-1].split(".", 2)[-1]
return f"{base_calver}.{int(same_month_version) + 1}"
def update_repo_for_release(self) -> int:
"""Update CHANGES.md + doc files ready for release"""
self.cleanup_changes_template_for_release()
self.update_version_in_docs()
return 0 # return 0 if no exceptions hit
def update_version_in_docs(self) -> None:
for doc_path in self.version_doc_paths:
LOG.info(f"Updating black version to {self.next_version} in {doc_path}")
with doc_path.open("r", encoding="utf-8") as dfp:
doc_string = dfp.read()
next_version_doc = doc_string.replace(
self.current_version, self.next_version
)
with doc_path.open("w", encoding="utf-8") as dfp:
dfp.write(next_version_doc)
LOG.debug(
f"Finished updating black version to {self.next_version} in {doc_path}"
)
def _handle_debug(debug: bool) -> None:
"""Turn on debugging if asked otherwise INFO default"""
log_level = logging.DEBUG if debug else logging.INFO
logging.basicConfig(
format="[%(asctime)s] %(levelname)s: %(message)s (%(filename)s:%(lineno)d)",
level=log_level,
)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument(
"-a",
"--add-changes-template",
action="store_true",
help="Add the Unreleased template to CHANGES.md",
)
parser.add_argument(
"-d", "--debug", action="store_true", help="Verbose debug output"
)
args = parser.parse_args()
_handle_debug(args.debug)
return args
def main() -> int:
args = parse_args()
# Need parent.parent cause script is in scripts/ directory
sf = SourceFiles(Path(__file__).parent.parent)
if args.add_changes_template:
return sf.add_template_to_changes()
LOG.info(f"Current version detected to be {sf.current_version}")
LOG.info(f"Next version will be {sf.next_version}")
return sf.update_repo_for_release()
if __name__ == "__main__": # pragma: no cover
sys.exit(main())
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env python3
import unittest
from pathlib import Path
from shutil import rmtree
from tempfile import TemporaryDirectory
from typing import Any
from unittest.mock import Mock, patch
from release import SourceFiles, tuple_calver # type: ignore
class FakeDateTime:
"""Used to mock the date to test generating next calver function"""
def today(*args: Any, **kwargs: Any) -> "FakeDateTime": # noqa: B902
return FakeDateTime()
# Add leading 0 on purpose to ensure we remove it
def strftime(*args: Any, **kwargs: Any) -> str: # noqa: B902
return "69.01"
class TestRelease(unittest.TestCase):
def setUp(self) -> None:
# We only test on >= 3.12
self.tempdir = TemporaryDirectory(delete=False) # type: ignore
self.tempdir_path = Path(self.tempdir.name)
self.sf = SourceFiles(self.tempdir_path)
def tearDown(self) -> None:
rmtree(self.tempdir.name)
return super().tearDown()
@patch("release.get_git_tags")
def test_get_current_version(self, mocked_git_tags: Mock) -> None:
mocked_git_tags.return_value = ["1.1.0", "69.1.0", "69.1.1", "2.2.0"]
self.assertEqual("69.1.1", self.sf.get_current_version())
@patch("release.get_git_tags")
@patch("release.datetime", FakeDateTime)
def test_get_next_version(self, mocked_git_tags: Mock) -> None:
# test we handle no args
mocked_git_tags.return_value = []
self.assertEqual(
"69.1.0",
self.sf.get_next_version(),
"Unable to get correct next version with no git tags",
)
# test we handle
mocked_git_tags.return_value = ["1.1.0", "69.1.0", "69.1.1", "2.2.0"]
self.assertEqual(
"69.1.2",
self.sf.get_next_version(),
"Unable to get correct version with 2 previous versions released this"
" month",
)
def test_tuple_calver(self) -> None:
first_month_release = tuple_calver("69.1.0")
second_month_release = tuple_calver("69.1.1")
self.assertEqual((69, 1, 0), first_month_release)
self.assertEqual((0, 0, 0), tuple_calver("69.1.1a0")) # Hack for alphas/betas
self.assertTrue(first_month_release < second_month_release)
if __name__ == "__main__":
unittest.main()