Ctrl K
ComunidadGuías…

scripts/update_versions.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""
Bump the omnigent project version across all packages in lockstep.

The four 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``
- ``omnigent-slack``   — ``integrations/slack/pyproject.toml``

Each declares its own ``[project].version``. The first three ``==``-pin
their siblings in ``[project].dependencies``; the root ``omnigent``
package also ``==``-pins ``omnigent-slack`` in the ``slack`` optional
dependency extra — 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.9.0``) are left untouched.

The desktop app (``web/electron/package.json``) and private SPA
(``web/package.json``) are intentionally OUT of scope: neither is part
of the Python distributions' release-validated 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
import tomllib
from dataclasses import dataclass
from pathlib import Path

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 four :class:`Package` entries.
    """
    return [
        Package(
            "omnigent",
            root / "pyproject.toml",
            ("omnigent-client", "omnigent-ui-sdk", "omnigent-slack"),
        ),
        Package(
            "omnigent-client",
            root / "sdks" / "python-client" / "pyproject.toml",
            ("omnigent",),
        ),
        Package(
            "omnigent-ui-sdk",
            root / "sdks" / "ui" / "pyproject.toml",
            ("omnigent-client",),
        ),
        # omnigent-slack is deliberately decoupled from omnigent core (it
        # drives the server over HTTP, never imports ``omnigent``), so it
        # pins no siblings. The root ``omnigent`` package ``==``-pins it in
        # the ``slack`` optional-dependency extra; the pin lives in
        # [project.optional-dependencies] rather than [project.dependencies],
        # so check() scans both sections for it.
        Package(
            "omnigent-slack",
            root / "integrations" / "slack" / "pyproject.toml",
            (),
        ),
    ]


# ``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*.

    ``main`` carries the next MINOR as ``.dev0`` (the 0.5 cycle left main at
    ``0.6.0.dev0``), and post-release runs only when a new ``branch-X.Y``
    cycle is cut — patches never move main — so bump the minor, not the
    micro. A micro bump would re-freeze main on the released line and make
    doc-sync stage to the docs branch the release already owns.

    :param released: The just-released version, e.g. ``"0.6.0rc1"``.
    :returns: The next dev version, e.g. ``"0.7.0.dev0"``.
    """
    v = Version(released)
    return f"{v.major}.{v.minor + 1}.0.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"]
        # Sibling == pins may live in [project.dependencies] (the three
        # SDK packages) or in [project.optional-dependencies] extras (the
        # root ``omnigent`` package pins ``omnigent-slack`` in the ``slack``
        # extra). Collect both so the check covers every pin location.
        deps = list(project.get("dependencies", []))
        for extra_deps in project.get("optional-dependencies", {}).values():
            deps.extend(extra_deps)
        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()