Skip to content

Instantly share code, notes, and snippets.

@clbarnes
Last active January 2, 2025 17:31
Show Gist options
  • Save clbarnes/73163e1975b2a9642fa8ca321dc10bc3 to your computer and use it in GitHub Desktop.
Save clbarnes/73163e1975b2a9642fa8ca321dc10bc3 to your computer and use it in GitHub Desktop.
Bump python version

Changelog

In progress

#!/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