from __future__ import annotations
import argparse
import json
import os
import pathlib
import re
import subprocess
import sys
import tomllib
import xml.etree.ElementTree as ET
SEMVER = re.compile(r"^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$")
PRUNE = {".git", "target", "node_modules", "dist", ".venv", "venv", "__pycache__"}
_TABLE = re.compile(r"^\s*\[([^\[\]]+)\]\s*(?:#.*)?$")
_VERSION_LINE = re.compile(r'^(\s*version\s*=\s*")([^"]*)(".*)$', re.S)
class StampError(Exception):
def stamp_toml(text: str, table: str, version: str) -> tuple[str, str] | None:
lines = text.splitlines(keepends=True)
current = ""
for i, line in enumerate(lines):
header = _TABLE.match(line)
if header:
current = header.group(1).strip()
continue
if line.lstrip().startswith("[["):
current = ""
continue
if current == table:
m = _VERSION_LINE.match(line)
if m:
lines[i] = m.group(1) + version + m.group(3)
return "".join(lines), m.group(2)
return None
_DEP_TABLE = re.compile(r"(^|\.)(dev-|build-)?dependencies$")
_DEP_SUBTABLE = re.compile(r"(^|\.)(dev-|build-)?dependencies\.(?P<name>[^.]+)$")
_INLINE_DEP = re.compile(r"^(?P<head>\s*(?P<key>[A-Za-z0-9_-]+|\"[^\"]+\"|'[^']+')\s*=\s*\{)(?P<body>.*)(?P<tail>\}.*)$", re.S)
_INLINE_VERSION = re.compile(r'(\bversion\s*=\s*")[^"]*(")')
_INLINE_PATH = re.compile(r"\bpath\s*=")
def stamp_member_dependencies(text: str, names: set[str], version: str) -> str:
lines = text.splitlines(keepends=True)
table = ""
sub_member = ""
sub_keys: set[str] = set()
def close_subtable() -> None:
if sub_member and {"path", "version"} <= sub_keys:
raise StampError(f"[{table}] pins a workspace member in table form; use an inline table")
for i, line in enumerate(lines):
header = _TABLE.match(line)
if header or line.lstrip().startswith("[["):
close_subtable()
table, sub_member, sub_keys = (header.group(1).strip() if header else ""), "", set()
sub = _DEP_SUBTABLE.search(table)
if sub and sub.group("name").strip("\"'") in names:
sub_member = sub.group("name")
continue
if sub_member:
key = line.split("=", 1)[0].strip()
if "=" in line and key in ("path", "version"):
sub_keys.add(key)
continue
if not _DEP_TABLE.search(table):
continue
m = _INLINE_DEP.match(line)
if m is None or m.group("key").strip("\"'") not in names:
continue
body = m.group("body")
if _INLINE_PATH.search(body) and _INLINE_VERSION.search(body):
lines[i] = m.group("head") + _INLINE_VERSION.sub(lambda v: v.group(1) + version + v.group(2), body, count=1) + m.group("tail")
close_subtable()
return "".join(lines)
def stamp_cargo_lock(text: str, names: set[str], version: str) -> str:
for name in sorted(names):
pattern = re.compile(
r'(\nname = "' + re.escape(name) + r'"\r?\nversion = ")[^"]*("\r?\n(?!source =))'
)
text, count = pattern.subn(lambda m: m.group(1) + version + m.group(2), text)
if count != 1:
raise StampError(f"Cargo.lock: expected exactly one workspace entry for {name}, found {count}")
return text
def _dump_json(obj: object) -> str:
return json.dumps(obj, indent=2, ensure_ascii=False) + "\n"
def _load_canonical_json(text: str, what: str) -> dict:
obj = json.loads(text)
if _dump_json(obj) != text:
raise StampError(f"{what}: not canonical 2-space JSON; refusing to reformat it")
return obj
def stamp_package_json(text: str, version: str) -> str:
obj = _load_canonical_json(text, "package.json")
obj["version"] = version
return _dump_json(obj)
def stamp_package_lock(text: str, version: str) -> str:
obj = _load_canonical_json(text, "package-lock.json")
obj["version"] = version
root = obj.get("packages", {}).get("")
if root is not None:
root["version"] = version
return _dump_json(obj)
def stamp_pom(text: str, version: str) -> str:
start = text.find("</parent>")
if start < 0:
start = text.find("</modelVersion>")
if start < 0:
raise StampError("pom.xml: no <modelVersion> to anchor on")
pattern = re.compile(r"(</artifactId>\s*<version>)([^<]*)(</version>)")
m = pattern.search(text, start)
if not m:
raise StampError("pom.xml: no <version> directly after the project <artifactId>")
new = text[: m.start()] + m.group(1) + version + m.group(3) + text[m.end() :]
root = ET.fromstring(new)
ns = root.tag[: root.tag.index("}") + 1] if root.tag.startswith("{") else ""
el = root.find(ns + "version")
if el is None or (el.text or "").strip() != version:
raise StampError("pom.xml: stamped a <version> that is not the project's own")
return new
def _read(path: pathlib.Path) -> str:
with open(path, encoding="utf-8", newline="") as f:
return f.read()
def _write(path: pathlib.Path, text: str) -> None:
with open(path, "w", encoding="utf-8", newline="") as f:
f.write(text)
def _cargo_manifests(root: pathlib.Path) -> list[pathlib.Path]:
found = []
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = sorted(d for d in dirnames if d not in PRUNE)
if "Cargo.toml" in filenames:
found.append(pathlib.Path(dirpath) / "Cargo.toml")
return found
def stamp_tree(root: pathlib.Path, version: str) -> list[pathlib.Path]:
changed: list[pathlib.Path] = []
seen = 0
def apply(path: pathlib.Path, new: str) -> None:
if new != _read(path):
_write(path, new)
changed.append(path)
manifests = []
member_names: set[str] = set()
for manifest in _cargo_manifests(root):
text = _read(manifest)
data = tomllib.loads(text)
package = data.get("package")
if isinstance(package, dict) and isinstance(package.get("version"), str):
member_names.add(package["name"])
manifests.append((manifest, text, data))
for manifest, text, data in manifests:
for table, section in (("package", data.get("package")), ("workspace.package", data.get("workspace", {}).get("package"))):
if not isinstance(section, dict) or not isinstance(section.get("version"), str):
continue
stamped = stamp_toml(text, table, version)
if stamped is None:
raise StampError(f"{manifest}: [{table}] has a version the line editor cannot find")
text = stamped[0]
seen += 1
apply(manifest, stamp_member_dependencies(text, member_names, version))
lock = root / "Cargo.lock"
if lock.exists() and member_names:
apply(lock, stamp_cargo_lock(_read(lock), member_names, version))
py = root / "sdk" / "python" / "pyproject.toml"
if py.exists():
stamped = stamp_toml(_read(py), "project", version)
if stamped is None:
raise StampError(f"{py}: [project] has no literal version")
seen += 1
apply(py, stamped[0])
for rel, fn in (
("sdk/node/package.json", stamp_package_json),
("sdk/node/package-lock.json", stamp_package_lock),
("sdk/java/pom.xml", stamp_pom),
):
path = root / rel
if path.exists():
seen += 1
apply(path, fn(_read(path), version))
if seen == 0:
raise StampError(f"no manifest to stamp under {root}")
return changed
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
ap.add_argument("version", nargs="?", default=os.environ.get("NEW_VERSION", ""))
ap.add_argument("--root", default=".", help="repository root")
ap.add_argument("--no-git-add", action="store_true", help="do not stage the changed files")
args = ap.parse_args(argv)
if not SEMVER.match(args.version):
ap.error(f"version {args.version!r} is not semver (no leading v); pass it or set NEW_VERSION")
root = pathlib.Path(args.root).resolve()
try:
changed = stamp_tree(root, args.version)
except StampError as exc:
print(f"FAIL: {exc}")
return 1
for path in changed:
print(f"stamped {path.relative_to(root).as_posix()} -> {args.version}")
if not changed:
print(f"ok: every manifest already carries {args.version}")
if changed and not args.no_git_add:
subprocess.run(["git", "add", "--", *map(str, changed)], cwd=root, check=True)
return 0
if __name__ == "__main__":
sys.exit(main())