358 lines
13 KiB
Python
358 lines
13 KiB
Python
"""
|
|
Bump the omnigent project version across all packages in lockstep.
|
|
|
|
The three distributions in this repo release together at a single
|
|
version:
|
|
|
|
- ``omnigent`` — root ``pyproject.toml``
|
|
- ``omnigent-client`` — ``sdks/python-client/pyproject.toml``
|
|
- ``omnigent-ui-sdk`` — ``sdks/ui/pyproject.toml``
|
|
|
|
Each declares its own ``[project].version`` and ``==``-pins its
|
|
siblings — the lockstep contract that
|
|
``.github/workflows/release-omnigent.yml`` verifies at tag time. This
|
|
script rewrites every one of those locations at once so they never
|
|
drift.
|
|
|
|
It edits ONLY the ``[project].version`` line and the sibling ``==``
|
|
pins, matched by package name — never a blind version-string replace —
|
|
so unrelated version literals (host/runner wire-protocol versions,
|
|
docstring examples, third-party dependency floors like
|
|
``databricks-mcp>=0.1.0``) are left untouched.
|
|
|
|
``web/package.json`` (a ``0.0.0`` sentinel for the private SPA) and
|
|
``web/electron/package.json`` (the desktop app's independent
|
|
version) are intentionally OUT of scope: neither is part of the
|
|
release-validated Python lockstep.
|
|
|
|
After editing the ``pyproject.toml`` files, regenerate the lockfile so
|
|
the embedded sibling specifiers track the new version::
|
|
|
|
uv lock
|
|
|
|
Usage::
|
|
|
|
# Stamp an exact version (cutting a release or release candidate):
|
|
python scripts/update_versions.py pre-release --new-version 0.1.2
|
|
python scripts/update_versions.py pre-release --new-version 0.1.2rc1
|
|
|
|
# After releasing X, move main to the next dev version:
|
|
python scripts/update_versions.py post-release --new-version 0.1.2
|
|
# -> stamps 0.1.3.dev0 everywhere
|
|
|
|
# Verify every location agrees (prints the resolved version):
|
|
python scripts/update_versions.py check
|
|
python scripts/update_versions.py check --expect 0.1.2
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import re
|
|
import sys
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
import tomllib
|
|
from packaging.version import InvalidVersion, Version
|
|
|
|
# scripts/update_versions.py -> repo root is one level up.
|
|
_REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Package:
|
|
"""
|
|
One lockstep-versioned distribution in the repo.
|
|
|
|
:param name: Distribution name, e.g. ``"omnigent"``.
|
|
:param pyproject: Path to the package's ``pyproject.toml``, e.g.
|
|
``Path("sdks/python-client/pyproject.toml")``.
|
|
:param sibling_pins: Sibling distribution names this package
|
|
``==``-pins, e.g. ``("omnigent-client", "omnigent-ui-sdk")``.
|
|
Empty for a package that pins no siblings.
|
|
"""
|
|
|
|
name: str
|
|
pyproject: Path
|
|
sibling_pins: tuple[str, ...]
|
|
|
|
|
|
def packages(root: Path) -> list[Package]:
|
|
"""
|
|
Return the lockstep packages with their paths rooted at *root*.
|
|
|
|
:param root: Repo root, e.g. ``Path("/repo")``.
|
|
:returns: The three :class:`Package` entries.
|
|
"""
|
|
return [
|
|
Package(
|
|
"omnigent",
|
|
root / "pyproject.toml",
|
|
("omnigent-client", "omnigent-ui-sdk"),
|
|
),
|
|
Package(
|
|
"omnigent-client",
|
|
root / "sdks" / "python-client" / "pyproject.toml",
|
|
("omnigent",),
|
|
),
|
|
Package(
|
|
"omnigent-ui-sdk",
|
|
root / "sdks" / "ui" / "pyproject.toml",
|
|
("omnigent-client",),
|
|
),
|
|
]
|
|
|
|
|
|
# ``version = "..."`` on its own line (the [project].version field).
|
|
_VERSION_LINE = re.compile(r'^version = "[^"]*"$', re.MULTILINE)
|
|
|
|
# ``VERSION = "..."`` on its own line — the runtime constant in
|
|
# ``omnigent/version.py`` that mirrors the canonical [project].version.
|
|
_VERSION_CONSTANT = re.compile(r'^VERSION = "[^"]*"$', re.MULTILINE)
|
|
|
|
|
|
def _version_py(root: Path) -> Path:
|
|
"""Return the path to the runtime version constant module."""
|
|
return root / "omnigent" / "version.py"
|
|
|
|
|
|
def _pin_pattern(name: str) -> re.Pattern[str]:
|
|
"""
|
|
Build the regex matching a quoted ``"<name>==<ver>",`` dependency.
|
|
|
|
Anchored on the exact distribution *name* so a blind version
|
|
literal is never matched, and capturing the leading indent so it
|
|
is preserved on rewrite.
|
|
|
|
:param name: Distribution name to match, e.g. ``"omnigent-client"``.
|
|
:returns: A compiled multiline pattern.
|
|
"""
|
|
return re.compile(rf'^(?P<indent>\s*)"{re.escape(name)}==[^"]*",$', re.MULTILINE)
|
|
|
|
|
|
def _sub_exactly_once(pattern: re.Pattern[str], repl: str, text: str, where: str) -> str:
|
|
"""
|
|
Substitute *pattern* with *repl* in *text*, requiring one match.
|
|
|
|
Failing loud on zero or multiple matches turns a drifted file
|
|
format (renamed field, duplicated pin) into an immediate error
|
|
rather than a silent partial edit.
|
|
|
|
:param pattern: Compiled pattern to replace.
|
|
:param repl: Replacement string (may reference groups).
|
|
:param text: Source text.
|
|
:param where: Human description for the error, e.g.
|
|
``"[project].version in pyproject.toml"``.
|
|
:returns: The edited text.
|
|
:raises ValueError: If the match count is not exactly one.
|
|
"""
|
|
new_text, count = pattern.subn(repl, text)
|
|
if count != 1:
|
|
raise ValueError(f"expected exactly 1 match for {where}, found {count}")
|
|
return new_text
|
|
|
|
|
|
def read_version(root: Path) -> str:
|
|
"""
|
|
Read the canonical version from the root ``pyproject.toml``.
|
|
|
|
:param root: Repo root.
|
|
:returns: The version string, e.g. ``"0.1.2.dev0"``.
|
|
"""
|
|
data = tomllib.loads((root / "pyproject.toml").read_text())
|
|
return data["project"]["version"]
|
|
|
|
|
|
def set_version(root: Path, new_version: str) -> list[Path]:
|
|
"""
|
|
Rewrite every package's version + sibling pins to *new_version*.
|
|
|
|
Also rewrites the runtime ``VERSION`` constant in ``omnigent/version.py``
|
|
so the value the runtime imports stays equal to ``[project].version`` —
|
|
the automated bump path must keep both in lockstep (the ``sync-version-py``
|
|
pre-commit fixer only fires in the local dev flow).
|
|
|
|
:param root: Repo root.
|
|
:param new_version: PEP 440 version to stamp, e.g. ``"0.1.2"``.
|
|
:returns: The list of files changed (in edit order).
|
|
:raises ValueError: If any expected line is missing or duplicated.
|
|
"""
|
|
changed: list[Path] = []
|
|
for pkg in packages(root):
|
|
text = pkg.pyproject.read_text()
|
|
text = _sub_exactly_once(
|
|
_VERSION_LINE,
|
|
f'version = "{new_version}"',
|
|
text,
|
|
f"[project].version in {pkg.pyproject}",
|
|
)
|
|
for sibling in pkg.sibling_pins:
|
|
text = _sub_exactly_once(
|
|
_pin_pattern(sibling),
|
|
rf'\g<indent>"{sibling}=={new_version}",',
|
|
text,
|
|
f"{sibling}== pin in {pkg.pyproject}",
|
|
)
|
|
pkg.pyproject.write_text(text)
|
|
changed.append(pkg.pyproject)
|
|
|
|
version_py = _version_py(root)
|
|
version_text = _sub_exactly_once(
|
|
_VERSION_CONSTANT,
|
|
f'VERSION = "{new_version}"',
|
|
version_py.read_text(),
|
|
f"VERSION constant in {version_py}",
|
|
)
|
|
version_py.write_text(version_text)
|
|
changed.append(version_py)
|
|
|
|
return changed
|
|
|
|
|
|
def next_dev_version(released: str) -> str:
|
|
"""
|
|
Compute the next development version after releasing *released*.
|
|
|
|
Mirrors MLflow's post-release convention: bump the patch component
|
|
and append ``.dev0`` (e.g. ``0.1.2`` -> ``0.1.3.dev0``).
|
|
|
|
:param released: The just-released version, e.g. ``"0.1.2"``.
|
|
:returns: The next dev version, e.g. ``"0.1.3.dev0"``.
|
|
"""
|
|
v = Version(released)
|
|
return f"{v.major}.{v.minor}.{v.micro + 1}.dev0"
|
|
|
|
|
|
def _read_version_constant(root: Path) -> str:
|
|
"""
|
|
Return the ``VERSION`` literal from ``omnigent/version.py``.
|
|
|
|
:param root: Repo root.
|
|
:returns: The quoted value of the ``VERSION`` assignment.
|
|
:raises ValueError: If the assignment is missing or not unique.
|
|
"""
|
|
version_py = _version_py(root)
|
|
matches = _VERSION_CONSTANT.findall(version_py.read_text())
|
|
if len(matches) != 1:
|
|
raise ValueError(
|
|
f'expected exactly one `VERSION = "..."` line in {version_py}, found {len(matches)}'
|
|
)
|
|
return matches[0].split('"')[1]
|
|
|
|
|
|
def check(root: Path, expect: str | None = None) -> str:
|
|
"""
|
|
Verify every package agrees on the version and pins its siblings.
|
|
|
|
Also checks the runtime ``VERSION`` constant in ``omnigent/version.py``
|
|
against the resolved version, so a bump that forgets it fails here rather
|
|
than in the ``test_version_matches_pyproject`` backstop on the bot PR.
|
|
|
|
:param root: Repo root.
|
|
:param expect: If given, additionally assert the resolved version
|
|
equals this (compared as PEP 440), e.g. ``"0.1.2"``.
|
|
:returns: The single resolved version string.
|
|
:raises ValueError: If versions disagree, a sibling pin is missing
|
|
or not pinned to the package's own version, the runtime ``VERSION``
|
|
constant differs, or the resolved version differs from *expect*.
|
|
"""
|
|
versions: dict[str, str] = {}
|
|
for pkg in packages(root):
|
|
project = tomllib.loads(pkg.pyproject.read_text())["project"]
|
|
versions[pkg.name] = project["version"]
|
|
deps = project.get("dependencies", [])
|
|
for sibling in pkg.sibling_pins:
|
|
pin = f"{sibling}=={project['version']}"
|
|
if pin not in deps:
|
|
raise ValueError(f"{pkg.pyproject}: missing exact pin {pin!r}")
|
|
unique = set(versions.values())
|
|
if len(unique) != 1:
|
|
raise ValueError(f"package versions disagree: {versions}")
|
|
resolved = unique.pop()
|
|
constant = _read_version_constant(root)
|
|
if Version(constant) != Version(resolved):
|
|
raise ValueError(
|
|
f"omnigent/version.py VERSION {constant!r} != [project].version {resolved!r}"
|
|
)
|
|
if expect is not None and Version(resolved) != Version(expect):
|
|
raise ValueError(f"resolved version {resolved} != expected {expect}")
|
|
return resolved
|
|
|
|
|
|
def _validate_pep440(value: str) -> str:
|
|
"""
|
|
Validate *value* is a PEP 440 version, exiting loudly otherwise.
|
|
|
|
:param value: Candidate version string, e.g. ``"0.1.2rc1"``.
|
|
:returns: The same value.
|
|
"""
|
|
try:
|
|
Version(value)
|
|
except InvalidVersion as exc:
|
|
raise SystemExit(f"invalid version {value!r}: {exc}") from exc
|
|
return value
|
|
|
|
|
|
def _cmd_pre_release(root: Path, new_version: str) -> None:
|
|
"""Stamp *new_version* exactly across all packages."""
|
|
_validate_pep440(new_version)
|
|
changed = set_version(root, new_version)
|
|
check(root, expect=new_version)
|
|
print(f"Set version to {new_version} in:", file=sys.stderr)
|
|
for path in changed:
|
|
print(f" {path.relative_to(root)}", file=sys.stderr)
|
|
print("Now run `uv lock` to update the lockfile.", file=sys.stderr)
|
|
|
|
|
|
def _cmd_post_release(root: Path, released: str) -> None:
|
|
"""Stamp the next ``.dev0`` after releasing *released*."""
|
|
_validate_pep440(released)
|
|
current = Version(read_version(root))
|
|
if not current.is_devrelease:
|
|
raise SystemExit(
|
|
f"current version {current} is not a dev release; post-release must run "
|
|
"on main (which carries a .devN version), not a release branch"
|
|
)
|
|
new_version = next_dev_version(released)
|
|
set_version(root, new_version)
|
|
check(root, expect=new_version)
|
|
print(f"Bumped main to {new_version} (after release {released}).", file=sys.stderr)
|
|
print("Now run `uv lock` to update the lockfile.", file=sys.stderr)
|
|
|
|
|
|
def _cmd_check(root: Path, expect: str | None) -> None:
|
|
"""Verify consistency and print the resolved version to stdout."""
|
|
print(check(root, expect=expect))
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> None:
|
|
"""
|
|
Parse args and dispatch to the requested subcommand.
|
|
|
|
:param argv: Argument list (defaults to ``sys.argv[1:]``).
|
|
"""
|
|
parser = argparse.ArgumentParser(description="Bump omnigent package versions in lockstep")
|
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
|
|
pre = sub.add_parser("pre-release", help="Stamp an exact version across all packages")
|
|
pre.add_argument("--new-version", required=True, help="Version to stamp, e.g. 0.1.2")
|
|
|
|
post = sub.add_parser("post-release", help="Stamp the next .dev0 after a release")
|
|
post.add_argument("--new-version", required=True, help="The just-released version, e.g. 0.1.2")
|
|
|
|
chk = sub.add_parser("check", help="Verify all packages agree (prints the version)")
|
|
chk.add_argument("--expect", default=None, help="Assert the resolved version equals this")
|
|
|
|
args = parser.parse_args(argv)
|
|
if args.command == "pre-release":
|
|
_cmd_pre_release(_REPO_ROOT, args.new_version)
|
|
elif args.command == "post-release":
|
|
_cmd_post_release(_REPO_ROOT, args.new_version)
|
|
elif args.command == "check":
|
|
_cmd_check(_REPO_ROOT, args.expect)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|