Last active
January 2, 2025 17:31
-
-
Save clbarnes/73163e1975b2a9642fa8ca321dc10bc3 to your computer and use it in GitHub Desktop.
Bump python version
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env python3 | |
# /// script | |
# requires-python = ">=3.12,<3.13" | |
# dependencies = [ | |
# "parver>=0.5", | |
# "tomlkit>=0.13.2", | |
# ] | |
# /// | |
""" | |
Script for getting, setting or bumping the version in `pyproject.toml` and adding a new entry into `CHANGELOG.md`. | |
""" | |
import argparse | |
import difflib | |
import logging | |
import sys | |
from enum import StrEnum, auto | |
from functools import wraps | |
from pathlib import Path | |
import tomlkit as tk | |
from parver import Version | |
logger = logging.getLogger(__name__) | |
class BumpLevel(StrEnum): | |
MAJOR = auto() | |
MINOR = auto() | |
PATCH = auto() | |
DEV = auto() | |
@wraps(print) | |
def err(msg, **print_kwargs): | |
kwargs = dict(file=sys.stderr, **print_kwargs) | |
print(msg, **kwargs) | |
def get_ancestor_path(fname: str = "pyproject.toml") -> Path: | |
"""Traverse the parent directories until one containing the given file name is found, | |
returning the path of that file. | |
""" | |
for p in Path(__file__).resolve().parents: | |
pyproject = p / fname | |
if pyproject.is_file(): | |
return pyproject | |
raise RuntimeError(f"Could not find `{fname}`") | |
def get_current_version() -> tuple[Version, tk.TOMLDocument]: | |
with get_ancestor_path().open(mode="rb") as f: | |
doc = tk.load(f) | |
ver_str = str(doc["project"]["version"]) | |
return (parse_version(ver_str), doc) | |
def get_bumped(current: Version, bump: BumpLevel) -> Version: | |
release_idx = { | |
BumpLevel.MAJOR: 0, | |
BumpLevel.MINOR: 1, | |
BumpLevel.PATCH: 2, | |
}.get(bump) | |
if release_idx is not None: | |
return current.bump_release(index=release_idx) | |
if bump == BumpLevel.DEV: | |
return current.bump_dev(1) | |
raise ValueError(f"Unknown bump level: {bump}") | |
def set_new_version( | |
old_ver: Version, | |
new_ver: Version, | |
config: tk.TOMLDocument, | |
dry_run=False, | |
) -> Version: | |
update_changelog(old_ver, dry_run) | |
return update_pyproject(new_ver, config, dry_run) | |
def get_diff(old: str, new: str) -> str: | |
return "".join( | |
difflib.unified_diff( | |
old.splitlines(True), | |
new.splitlines(True), | |
fromfile="before", | |
tofile="after", | |
) | |
) | |
def update_pyproject( | |
new_ver: Version, config: tk.TOMLDocument, dry_run=False | |
) -> Version: | |
config["project"]["version"] = str(new_ver.normalize()) | |
path = get_ancestor_path() | |
new_str = tk.dumps(config) | |
if dry_run: | |
err(f"Would update {path}") | |
old_str = path.read_text() | |
diff = get_diff(old_str, new_str) | |
err(diff) | |
else: | |
path.write_text(new_str) | |
return new_ver | |
def update_changelog(old_ver: Version = False, dry_run=False): | |
path = get_ancestor_path("CHANGELOG.md") | |
out_lines = [] | |
with path.open() as f: | |
for line in f: | |
out_lines.append(line.rstrip()) | |
if line.startswith("## In progress"): | |
out_lines.extend(["", "-", "", f"## v{old_ver.normalize()}"]) | |
while not out_lines[-1]: | |
out_lines.pop() | |
out_lines.append("") | |
new_str = "\n".join(out_lines) | |
if dry_run: | |
err(f"Would update {path}") | |
old_str = path.read_text() | |
diff = get_diff(old_str, new_str) | |
err(diff) | |
else: | |
path.write_text(new_str) | |
def parse_version(s: str) -> Version: | |
return Version.parse(s).normalize() | |
def main(args=None): | |
parser = argparse.ArgumentParser() | |
parser.add_argument( | |
"newversion", | |
nargs="?", | |
type=parse_version, | |
help="optionally, set the exact version to update to (will be normalized)", | |
) | |
parser.add_argument( | |
"--bump", | |
"-b", | |
type=BumpLevel, | |
choices=[str(v) for v in BumpLevel], | |
help="bump the existing version", | |
) | |
parser.add_argument( | |
"--dry-run", | |
"-d", | |
action="store_true", | |
help="if given, do not write any changes, just report them", | |
) | |
parsed = parser.parse_args(args) | |
if parsed.newversion and parsed.bump: | |
err("provide only one of `newversion` and `--bump`/ `-b`") | |
return 1 | |
orig_ver, config = get_current_version() | |
if parsed.bump is not None: | |
new_ver = get_bumped(orig_ver, parsed.bump) | |
elif parsed.newversion is not None and parsed.newversion != orig_ver: | |
new_ver = parsed.newversion | |
else: | |
print(orig_ver) | |
return 0 | |
if new_ver != orig_ver: | |
new_ver = set_new_version(orig_ver, new_ver, config, parsed.dry_run) | |
print(new_ver) | |
return 0 | |
if __name__ == "__main__": | |
logging.basicConfig(level=logging.INFO) | |
sys.exit(main()) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment