diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 963ead4a..325c969d 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "pipefy", "displayName": "Pipefy", - "version": "0.2.0-beta.1", + "version": "0.3.0-alpha.1", "description": "Pipefy workflow integration for Claude Code.", "author": { "name": "Pipefy", "url": "https://github.com/pipefy" }, "homepage": "https://github.com/pipefy/ai-toolkit", diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f6cfd942..043453ab 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -73,7 +73,8 @@ jobs: from pathlib import Path dist = Path("dist") - for prefix in ("pipefy", "pipefy_mcp_server", "pipefy_cli", "pipefy_auth", "pipefy_infra"): + expected = ("pipefy", "pipefy_mcp_server", "pipefy_cli", "pipefy_auth", "pipefy_infra") + for prefix in expected: wheels = list(dist.glob(f"{prefix}-*.whl")) if len(wheels) != 1: print( @@ -81,6 +82,17 @@ jobs: file=sys.stderr, ) sys.exit(1) + # Bound the set: a new workspace member builds a wheel that is not in + # `expected` and so never gets staged for PyPI, shipping a partial + # release. Fail here so adding a package forces updating this list. + all_wheels = list(dist.glob("*.whl")) + if len(all_wheels) != len(expected): + print( + f"expected exactly {len(expected)} wheels in dist/, got " + f"{sorted(w.name for w in all_wheels)!r}", + file=sys.stderr, + ) + sys.exit(1) PY - name: Create GitHub Release uses: softprops/action-gh-release@v2 diff --git a/RELEASE.md b/RELEASE.md index 9d5d26a2..870a1960 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -96,6 +96,6 @@ Same steps as above. PyPI publishing already runs on every tag; a **`v1.`** tag | Piece | Role | | --- | --- | -| `scripts/bump_version.py` | Reads the SDK `__version__`, applies the bump, writes the same value to SDK, MCP, CLI, Auth, and Infra `__init__.py` plus the root `pyproject.toml`'s `[project].version`, then runs `uv lock` to refresh `uv.lock`. Also exposes a `verify` mode that asserts every version-bearing file agrees. | -| `.github/workflows/ci.yml` | Invokes `scripts/bump_version.py verify` to assert the five `__version__` strings and root `pyproject.toml` `[project].version` all match. | -| `.github/workflows/release.yml` | On `v*` tags: asserts the tag matches SDK `__version__`, extracts the matching `CHANGELOG.md` section as the GitHub Release body, builds wheels and sdists with `uv build --all-packages -o dist --wheel --sdist`, attaches wheels to the GitHub Release, and uploads all five wheels to PyPI via Trusted Publishing on every `v*` tag. | +| `scripts/bump_version.py` | Reads the SDK `__version__`, applies the bump, writes the same value to SDK, MCP, CLI, Auth, and Infra `__init__.py`, the root `pyproject.toml`'s `[project].version`, the `.claude-plugin/plugin.json` `version`, and each published package's sibling `==` pins, then runs `uv lock` to refresh `uv.lock`. Also exposes a `verify` mode that asserts every version-bearing file agrees. | +| `.github/workflows/ci.yml` | Invokes `scripts/bump_version.py verify` to assert that all version-bearing files match: the five `__version__` strings, the root `pyproject.toml` `[project].version`, `uv.lock`, the `.claude-plugin/plugin.json` `version`, and the sibling `==` pins in each published package's `pyproject.toml`. | +| `.github/workflows/release.yml` | On `v*` tags: asserts the tag matches SDK `__version__`, extracts the matching `CHANGELOG.md` section as the GitHub Release body, builds wheels and sdists with `uv build --all-packages -o dist --wheel --sdist`, guards that `dist/` holds exactly five wheels (one per workspace member) so a sixth member cannot ship unnoticed, attaches wheels to the GitHub Release, and uploads all five wheels to PyPI via Trusted Publishing on every `v*` tag. | diff --git a/packages/auth/pyproject.toml b/packages/auth/pyproject.toml index 7100ae43..720644a0 100644 --- a/packages/auth/pyproject.toml +++ b/packages/auth/pyproject.toml @@ -9,7 +9,7 @@ dependencies = [ "httpx-auth>=0.23.1", "keyring>=24", "keyrings.alt>=5", - "pipefy-infra", + "pipefy-infra==0.3.0-alpha.1", # RS256 validation of inbound bearers (resource-server role); [crypto] pulls cryptography. "pyjwt[crypto]>=2.8", "pydantic>=2.13.4,<3", diff --git a/packages/cli/pyproject.toml b/packages/cli/pyproject.toml index b3278a35..e0d48615 100644 --- a/packages/cli/pyproject.toml +++ b/packages/cli/pyproject.toml @@ -5,8 +5,8 @@ description = "Typer CLI for Pipefy (pipefy)." readme = "README.md" requires-python = ">=3.11" dependencies = [ - "pipefy", - "pipefy-auth", + "pipefy==0.3.0-alpha.1", + "pipefy-auth==0.3.0-alpha.1", "typer>=0.12", "rich>=13", "pydantic-settings>=2.8.1", diff --git a/packages/mcp/pyproject.toml b/packages/mcp/pyproject.toml index bf4112f6..9ed0f2b0 100644 --- a/packages/mcp/pyproject.toml +++ b/packages/mcp/pyproject.toml @@ -8,9 +8,9 @@ dependencies = [ "httpx>=0.27.0", "mcp[cli]>=1.25.0", # Workspace resolves versions in dev; PyPI releases stage these wheels alongside CLI/MCP (see release workflow). - "pipefy-auth", - "pipefy-infra", - "pipefy", + "pipefy-auth==0.3.0-alpha.1", + "pipefy-infra==0.3.0-alpha.1", + "pipefy==0.3.0-alpha.1", "pydantic>=2.13.4,<3", "pydantic-settings>=2.8.1", ] diff --git a/packages/sdk/pyproject.toml b/packages/sdk/pyproject.toml index 69598f6a..af56b344 100644 --- a/packages/sdk/pyproject.toml +++ b/packages/sdk/pyproject.toml @@ -9,7 +9,7 @@ dependencies = [ "gql[httpx]>=4.0.0,<5", "httpx>=0.27.0", "openpyxl>=3.1.5", - "pipefy-infra", + "pipefy-infra==0.3.0-alpha.1", "pydantic>=2.13.4,<3", "pydantic-settings>=2.8.1", "rapidfuzz>=3.14.3", diff --git a/scripts/bump_version.py b/scripts/bump_version.py index e6b7ebcd..2ba8df81 100644 --- a/scripts/bump_version.py +++ b/scripts/bump_version.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Bump the lockstep workspace version across SDK, MCP, CLI, Auth, Infra, and root workspace meta. +"""Bump the lockstep workspace version across SDK, MCP, CLI, Auth, Infra, the Claude plugin manifest, and root workspace meta. After rewriting the version strings, runs ``uv lock`` so the workspace lockfile's ``pipefy-workspace`` entry tracks the new version. @@ -15,18 +15,60 @@ import subprocess import sys import tomllib -from collections.abc import Callable +from collections.abc import Callable, Iterator from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[1] ROOT_PYPROJECT = REPO_ROOT / "pyproject.toml" -INIT_PATHS = ( - REPO_ROOT / "packages/sdk/src/pipefy_sdk/__init__.py", - REPO_ROOT / "packages/mcp/src/pipefy_mcp/__init__.py", - REPO_ROOT / "packages/cli/src/pipefy_cli/__init__.py", - REPO_ROOT / "packages/auth/src/pipefy_auth/__init__.py", - REPO_ROOT / "packages/infra/src/pipefy_infra/__init__.py", -) +PLUGIN_MANIFEST = REPO_ROOT / ".claude-plugin/plugin.json" + +# The SDK distribution; its __version__ is the lockstep source of truth every +# other version-bearing file is compared against. +SDK_DIST_NAME = "pipefy" + + +def _load_toml(path: Path) -> dict: + """Parse a TOML file into a dict.""" + return tomllib.loads(path.read_text(encoding="utf-8")) + + +def _workspace_pyprojects() -> tuple[Path, ...]: + """Resolve package pyproject paths from the root ``[tool.uv.workspace]`` members. + + uv is the source of truth for workspace membership: a package it does not + know about is never built, locked, or published. Deriving from that list + (rather than a second hand-maintained copy) means adding a member cannot + silently escape sibling-dependency pinning or version bumping. Members may + be literal paths or globs (``packages/*``); both resolve here. + """ + members = _load_toml(ROOT_PYPROJECT)["tool"]["uv"]["workspace"]["members"] + paths: list[Path] = [] + for pattern in members: + paths.extend(sorted(REPO_ROOT.glob(f"{pattern}/pyproject.toml"))) + return tuple(paths) + + +# Every workspace package, derived from uv's member list. The bump writer and +# the verifier both read each package's declared workspace-sibling dependencies +# from here (see declared_sibling_deps), so pinning follows the real dependency +# lists with no hand-maintained pin map to keep in sync. +PACKAGE_PYPROJECTS: tuple[Path, ...] = _workspace_pyprojects() + + +def _hatch_version_path(pyproject: Path) -> Path: + """Resolve a package's ``__version__`` file from its ``[tool.hatch.version]`` path. + + hatch already declares which file holds the version (``path`` under + ``[tool.hatch.version]``, relative to the package directory), so deriving + from it keeps the version files in step with the packages, no second list. + """ + rel = _load_toml(pyproject)["tool"]["hatch"]["version"]["path"] + return pyproject.parent / rel + + +# Each package's __version__ file, derived from hatch's version-path config so a +# new workspace member is bumped and verified without editing a hardcoded list. +INIT_PATHS: tuple[Path, ...] = tuple(_hatch_version_path(p) for p in PACKAGE_PYPROJECTS) # Anchored to the [project] table. The middle alternation walks lines that # don't start with `[` (so a sibling [tool.X] header ends the run), but the @@ -45,6 +87,13 @@ re.MULTILINE, ) +# The Claude plugin manifest carries the release version too (it is what the +# marketplace shows), so it moves with every bump. plugin.json has a single +# top-level "version" key. +PLUGIN_MANIFEST_VERSION_RE = re.compile( + r'(?P"version"\s*:\s*")(?P[^"]+)(?P")', +) + CORE_VER_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)") PRERELEASE_SUFFIX_RE = re.compile( r"^(a|alpha|b|beta|rc)\.?(\d+)$", @@ -52,12 +101,62 @@ ) +def _sole_match(pattern: re.Pattern[str], text: str) -> re.Match[str] | None: + """Return the single match of ``pattern`` in ``text``, or ``None`` for zero or many. + + The tokens this script reads (a package's ``__version__``, a quoted sibling + dependency) must be unique in their file. Resolving to the first hit would + let a decoy (a second ``__version__`` line, the same dep quoted in a + dependency group) be silently read; treating "more than one" as no match + surfaces it as a failure instead. + """ + matches = pattern.finditer(text) + first = next(matches, None) + if first is None or next(matches, None) is not None: + return None + return first + + +def _sub_exactly_one( + pattern: re.Pattern[str], repl: str, text: str, *, what: str, where: Path +) -> str: + """Substitute the one expected match of ``pattern`` in ``text``; raise otherwise. + + The write-side twin of ``_sole_match``: replacing every match and asserting + the count is exactly one means a decoy occurrence fails loudly instead of + being silently rewritten (first hit) or left behind. + """ + new_text, count = pattern.subn(repl, text) + if count != 1: + msg = f"Expected one {what} in {where}, replaced {count}" + raise ValueError(msg) + return new_text + + +def _sdk_pyproject() -> Path: + """The SDK package pyproject, located by distribution name, not position. + + Workspace member order is not guaranteed (a ``packages/*`` glob sorts + alphabetically), so the source-of-truth package is found by name, matched + with the same canonicalization the sibling-dependency checks use. + """ + from packaging.utils import canonicalize_name + + target = canonicalize_name(SDK_DIST_NAME) + for pyproject in PACKAGE_PYPROJECTS: + if canonicalize_name(_load_toml(pyproject)["project"]["name"]) == target: + return pyproject + msg = f"workspace has no {SDK_DIST_NAME!r} package" + raise ValueError(msg) + + def read_sdk_version() -> str: """Return the current ``__version__`` from the SDK package (source of truth).""" - text = INIT_PATHS[0].read_text(encoding="utf-8") - m = VERSION_ASSIGN_RE.search(text) + path = _hatch_version_path(_sdk_pyproject()) + text = path.read_text(encoding="utf-8") + m = _sole_match(VERSION_ASSIGN_RE, text) if not m: - msg = f"No __version__ assignment found in {INIT_PATHS[0]}" + msg = f"Expected exactly one __version__ assignment in {path}" raise ValueError(msg) return m.group(2) @@ -66,30 +165,119 @@ def write_version_to_all_files(new_version: str) -> None: """Replace ``__version__`` in each package ``__init__.py`` and root workspace meta.""" for path in INIT_PATHS: text = path.read_text(encoding="utf-8") - new_text, count = VERSION_ASSIGN_RE.subn( + new_text = _sub_exactly_one( + VERSION_ASSIGN_RE, rf'\1"{new_version}"', text, - count=1, + what="__version__ assignment", + where=path, ) - if count != 1: - msg = f"Expected one __version__ assignment in {path}, replaced {count}" - raise ValueError(msg) path.write_text(new_text, encoding="utf-8") root_text = ROOT_PYPROJECT.read_text(encoding="utf-8") - new_root, root_count = ROOT_PROJECT_VERSION_RE.subn( + new_root = _sub_exactly_one( + ROOT_PROJECT_VERSION_RE, rf'\1"{new_version}"', root_text, - count=1, + what="[project] version assignment", + where=ROOT_PYPROJECT, ) - if root_count != 1: - msg = ( - f"Expected one [project] version assignment in {ROOT_PYPROJECT}, " - f"replaced {root_count}" - ) - raise ValueError(msg) ROOT_PYPROJECT.write_text(new_root, encoding="utf-8") + manifest_text = PLUGIN_MANIFEST.read_text(encoding="utf-8") + new_manifest = _sub_exactly_one( + PLUGIN_MANIFEST_VERSION_RE, + rf"\g{new_version}\g", + manifest_text, + what='"version" key', + where=PLUGIN_MANIFEST, + ) + PLUGIN_MANIFEST.write_text(new_manifest, encoding="utf-8") + + +def workspace_dep_pin_re(dep_name: str) -> re.Pattern[str]: + """Match a quoted ``dep_name`` requirement; group 2 captures its ``==`` pin. + + Group 2 is the pinned version, or ``None`` when the requirement is unpinned + (``"pipefy"``), so this one pattern serves both the writer (rewrites the + pin) and the verifier (reads it, treating ``None`` as unpinned). + + The name must fill the entire quoted string, so an unquoted + ``[tool.uv.sources]`` key (``pipefy = { workspace = true }``) and a bare + mention inside a description are not matched, and ``pipefy`` does not match + ``"pipefy-infra"`` (the closing quote must follow immediately). A package's + own ``name = "..."`` field IS a full quoted string and would match; that + collision cannot arise because the pinned names come from a package's own + ``[project.dependencies]``, which never lists itself. + """ + return re.compile(rf'(["\']){re.escape(dep_name)}(?:\s*==([^"\']*))?\1') + + +def write_dep_pins(new_version: str) -> None: + """Pin every workspace-sibling dependency each package declares to ``new_version``. + + Each published package pins its workspace siblings to the exact lockstep + version in its built metadata, so ``pip install pipefy-cli==X`` pulls its + siblings at X rather than a newer sibling that happens to be on PyPI (the + ``[tool.uv.sources]`` workspace mapping overrides these pins for in-repo + dev). The set to pin is derived from each package's real + ``[project.dependencies]``, so a newly added inter-package dependency is + pinned automatically with no hand-maintained list to update. + """ + for path, deps in _packages_with_sibling_deps(): + text = path.read_text(encoding="utf-8") + for dep in deps: + text = _sub_exactly_one( + workspace_dep_pin_re(dep), + rf"\g<1>{dep}=={new_version}\g<1>", + text, + what=f"{dep!r} dependency", + where=path, + ) + path.write_text(text, encoding="utf-8") + + +def workspace_members() -> set[str]: + """Canonical distribution names of every workspace package.""" + from packaging.utils import canonicalize_name + + return { + canonicalize_name(_load_toml(path)["project"]["name"]) + for path in PACKAGE_PYPROJECTS + } + + +def declared_sibling_deps(path: Path, members: set[str]) -> set[str]: + """Names of the workspace siblings a package's ``[project]`` depends on. + + Reads ``[project.dependencies]`` and keeps the requirements whose + canonicalized name is another workspace member. Returns the names as + written in the file, so callers can match them against the raw dependency + text; a package never depends on itself, so its own name is never returned. + """ + from packaging.requirements import Requirement + from packaging.utils import canonicalize_name + + deps = _load_toml(path).get("project", {}).get("dependencies", []) + return { + req.name + for spec in deps + if canonicalize_name((req := Requirement(spec)).name) in members + } + + +def _packages_with_sibling_deps() -> Iterator[tuple[Path, list[str]]]: + """Yield each package and its declared workspace-sibling deps, skipping none. + + Shared by the pin writer and the verifier so both walk the workspace and + resolve siblings the same way. + """ + members = workspace_members() + for path in PACKAGE_PYPROJECTS: + deps = sorted(declared_sibling_deps(path, members)) + if deps: + yield path, deps + def parse_core(version: str) -> tuple[int, int, int]: """Parse leading ``X.Y.Z`` from a version string.""" @@ -189,8 +377,7 @@ def refresh_lockfile() -> None: def read_root_pyproject_version() -> str: """Return ``[project].version`` from the root pyproject.toml.""" - data = tomllib.loads(ROOT_PYPROJECT.read_text(encoding="utf-8")) - return data["project"]["version"] + return _load_toml(ROOT_PYPROJECT)["project"]["version"] def read_uv_lock_workspace_version() -> str: @@ -200,7 +387,7 @@ def read_uv_lock_workspace_version() -> str: stores ``0.2.0b2.dev1`` for a pyproject value of ``0.2.0-beta.2.dev1``). Callers must normalize the comparison side, not this one. """ - data = tomllib.loads((REPO_ROOT / "uv.lock").read_text(encoding="utf-8")) + data = _load_toml(REPO_ROOT / "uv.lock") for pkg in data.get("package", []): if pkg.get("name") == "pipefy-workspace": return pkg["version"] @@ -210,22 +397,35 @@ def read_uv_lock_workspace_version() -> str: def verify_lockstep() -> int: """Assert every version-bearing file holds the same version; print mismatches. - Returns 0 on success, 1 on any mismatch or missing field. Compares - canonical PEP 440 forms via ``packaging.version.Version`` so the - pyproject string ``0.2.0-beta.2.dev1`` matches uv.lock's normalized - ``0.2.0b2.dev1``. + The version-bearing files include each package's declared workspace-sibling + ``==`` pins, so an unpinned sibling dependency fails here too. Returns 0 on + success, 1 on any mismatch or missing field. Compares canonical PEP 440 + forms via ``packaging.version.Version`` so the pyproject string + ``0.2.0-beta.2.dev1`` matches uv.lock's normalized ``0.2.0b2.dev1``. """ from packaging.version import Version raw: dict[str, str] = {} for path in INIT_PATHS: text = path.read_text(encoding="utf-8") - m = VERSION_ASSIGN_RE.search(text) + m = _sole_match(VERSION_ASSIGN_RE, text) if not m: - print(f"missing __version__ in {path}", file=sys.stderr) + print(f"expected exactly one __version__ in {path}", file=sys.stderr) return 1 raw[str(path.relative_to(REPO_ROOT))] = m.group(2) + for path, deps in _packages_with_sibling_deps(): + text = path.read_text(encoding="utf-8") + for dep in deps: + m = _sole_match(workspace_dep_pin_re(dep), text) + if not m or m.group(2) is None: + print( + f"missing or ambiguous pinned {dep!r} dependency in {path}", + file=sys.stderr, + ) + return 1 + raw[f"{path.relative_to(REPO_ROOT)}::{dep}"] = m.group(2) + try: raw["pyproject.toml"] = read_root_pyproject_version() except (KeyError, tomllib.TOMLDecodeError, OSError) as exc: @@ -244,6 +444,17 @@ def verify_lockstep() -> int: ) return 1 + try: + manifest_text = PLUGIN_MANIFEST.read_text(encoding="utf-8") + except OSError as exc: + print(f"could not read {PLUGIN_MANIFEST}: {exc}", file=sys.stderr) + return 1 + m = _sole_match(PLUGIN_MANIFEST_VERSION_RE, manifest_text) + if not m: + print(f'expected exactly one "version" in {PLUGIN_MANIFEST}', file=sys.stderr) + return 1 + raw[str(PLUGIN_MANIFEST.relative_to(REPO_ROOT))] = m.group("value") + canonical = {label: str(Version(v)) for label, v in raw.items()} if len(set(canonical.values())) != 1: print(f"version mismatch across packages: {raw}", file=sys.stderr) @@ -285,6 +496,7 @@ def main() -> int: return 2 write_version_to_all_files(new_version) + write_dep_pins(new_version) refresh_lockfile() print(f"Bumped {current} -> {new_version}") return 0 diff --git a/tests/test_bump_version.py b/tests/test_bump_version.py index e7043ad9..8834f172 100644 --- a/tests/test_bump_version.py +++ b/tests/test_bump_version.py @@ -85,3 +85,211 @@ def test_root_project_version_re_rejects_missing_version(pyproject: str) -> None r'\1"REPLACED"', pyproject, count=1 ) assert count == 0, f"expected no match in {pyproject!r}" + + +@pytest.mark.parametrize( + ("dep", "text"), + [ + # Unpinned dependency + ("pipefy", ' "pipefy",\n'), + # Already pinned (re-pin to a new version) + ("pipefy", ' "pipefy==0.1.0",\n'), + ("pipefy-auth", ' "pipefy-auth==0.2.0-beta.3",\n'), + ], +) +def test_workspace_dep_pin_re_matches_and_repins(dep: str, text: str) -> None: + new_text, count = _bump.workspace_dep_pin_re(dep).subn( + rf"\g<1>{dep}==9.9.9\g<1>", text, count=1 + ) + assert count == 1 + assert f'"{dep}==9.9.9"' in new_text + + +@pytest.mark.parametrize( + "text", + [ + # `pipefy` must not match the longer sibling name + ' "pipefy-infra",\n', + ' "pipefy-infra==0.1.0",\n', + # Unquoted [tool.uv.sources] key + "pipefy = { workspace = true }\n", + # A package's own name field is `pipefy-cli`, not a bare `pipefy` + 'name = "pipefy-cli"\n', + # Bare mention inside a quoted description + 'description = "Typer CLI for Pipefy (pipefy)."\n', + ], +) +def test_workspace_dep_pin_re_pipefy_does_not_overmatch(text: str) -> None: + _new_text, count = _bump.workspace_dep_pin_re("pipefy").subn( + r"\g<1>pipefy==9.9.9\g<1>", text, count=1 + ) + assert count == 0, f"expected no match in {text!r}" + + +@pytest.mark.parametrize( + ("text", "expected_version"), + [ + (' "pipefy==0.3.0-alpha.1",\n', "0.3.0-alpha.1"), + (' "pipefy==1.2.3",\n', "1.2.3"), + # Unpinned: matches, but group 2 is None (verify treats this as missing) + (' "pipefy",\n', None), + ], +) +def test_workspace_dep_pin_re_captures_pin( + text: str, expected_version: str | None +) -> None: + m = _bump.workspace_dep_pin_re("pipefy").search(text) + assert m is not None + assert m.group(2) == expected_version + + +@pytest.mark.parametrize( + "manifest", + [ + '{\n "name": "pipefy",\n "version": "0.2.0-beta.1"\n}\n', + # version before name, and with extra whitespace around the colon + '{\n "version" : "0.2.0-beta.1",\n "name": "pipefy"\n}\n', + ], +) +def test_plugin_manifest_version_re_replaces_version(manifest: str) -> None: + new_text, count = _bump.PLUGIN_MANIFEST_VERSION_RE.subn( + r"\g9.9.9\g", manifest, count=1 + ) + assert count == 1 + assert '"version"' in new_text and "9.9.9" in new_text + # The name key must not be rewritten. + assert '"pipefy"' in new_text + + +def test_plugin_manifest_version_matches_real_manifest() -> None: + text = _bump.PLUGIN_MANIFEST.read_text(encoding="utf-8") + m = _bump.PLUGIN_MANIFEST_VERSION_RE.search(text) + assert m is not None + assert m.group("value") == _bump.read_sdk_version() + + +def _write_pkg(path: Path, name: str, deps: tuple[str, ...]) -> None: + dep_lines = "".join(f' "{d}",\n' for d in deps) + path.write_text( + f'[project]\nname = "{name}"\nversion = "0.1.0"\n' + f"dependencies = [\n{dep_lines}]\n", + encoding="utf-8", + ) + + +def _write_hatch_pkg(root: Path, name: str, init_body: str) -> Path: + """Write a hatch package (src/__init__.py + pyproject) under root/name. + + Returns the pyproject path. Used by tests that exercise version reading via + the [tool.hatch.version] path. + """ + (root / name / "src").mkdir(parents=True) + (root / name / "src" / "__init__.py").write_text(init_body, encoding="utf-8") + pyproject = root / name / "pyproject.toml" + pyproject.write_text( + f'[project]\nname = "{name}"\nversion = "0.0.0"\n' + '[tool.hatch.version]\npath = "src/__init__.py"\n', + encoding="utf-8", + ) + return pyproject + + +def test_package_pyprojects_derived_from_workspace() -> None: + # Derived from [tool.uv.workspace].members, not a hand-maintained list, so a + # new member cannot skip sibling-dependency pinning. + assert _bump.PACKAGE_PYPROJECTS + for path in _bump.PACKAGE_PYPROJECTS: + assert path.exists() + + +def test_init_paths_derived_from_hatch_config() -> None: + # Derived from each package's [tool.hatch.version].path, not a hardcoded + # list, so a new member's __version__ file is bumped and verified too. + assert len(_bump.INIT_PATHS) == len(_bump.PACKAGE_PYPROJECTS) + for path in _bump.INIT_PATHS: + assert path.exists() + assert path.name == "__init__.py" + + +def test_read_sdk_version_locates_sdk_by_name( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # A non-SDK package is listed first; read_sdk_version must still find pipefy + # by name rather than trusting member order. + auth = _write_hatch_pkg(tmp_path, "pipefy-auth", '__version__ = "1.1.1"\n') + sdk = _write_hatch_pkg(tmp_path, "pipefy", '__version__ = "2.2.2"\n') + monkeypatch.setattr(_bump, "PACKAGE_PYPROJECTS", (auth, sdk)) + + assert _bump.read_sdk_version() == "2.2.2" + + +def test_declared_sibling_deps_keeps_only_workspace_members(tmp_path: Path) -> None: + members = {"pipefy", "pipefy-auth", "pipefy-infra"} + pyproject = tmp_path / "pyproject.toml" + _write_pkg( + pyproject, + "pipefy-cli", + ("pipefy==0.3.0-alpha.1", "pipefy-auth==0.3.0-alpha.1", "typer>=0.12"), + ) + assert _bump.declared_sibling_deps(pyproject, members) == {"pipefy", "pipefy-auth"} + + +def test_write_dep_pins_pins_declared_siblings( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # The set to pin is derived from each package's real dependencies: the + # sibling `pipefy` gets pinned (even though it was unpinned), the non-member + # `typer` is left alone, and no hand-maintained map is consulted. + sdk = tmp_path / "sdk.toml" + cli = tmp_path / "cli.toml" + _write_pkg(sdk, "pipefy", ()) + _write_pkg(cli, "pipefy-cli", ("pipefy", "typer>=0.12")) + monkeypatch.setattr(_bump, "PACKAGE_PYPROJECTS", (sdk, cli)) + + _bump.write_dep_pins("9.9.9") + + cli_text = cli.read_text(encoding="utf-8") + assert '"pipefy==9.9.9"' in cli_text + assert '"typer>=0.12"' in cli_text + + +def test_sole_match_treats_zero_or_many_as_no_match() -> None: + pat = _bump.VERSION_ASSIGN_RE + assert _bump._sole_match(pat, "") is None + assert _bump._sole_match(pat, '__version__ = "1.0.0"\n').group(2) == "1.0.0" + two = '__version__ = "1.0.0"\n__version__ = "2.0.0"\n' + assert _bump._sole_match(pat, two) is None + + +def test_read_sdk_version_rejects_duplicate_version( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # A decoy second __version__ (e.g. in a docstring example) must fail loudly, + # not resolve to the first hit. + pyproject = _write_hatch_pkg( + tmp_path, "pipefy", '__version__ = "1.0.0"\n__version__ = "2.0.0"\n' + ) + monkeypatch.setattr(_bump, "PACKAGE_PYPROJECTS", (pyproject,)) + + with pytest.raises(ValueError, match="Expected exactly one __version__"): + _bump.read_sdk_version() + + +def test_write_dep_pins_rejects_duplicate_dep_occurrence( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # `pipefy` is quoted in both [project.dependencies] and a dependency group; + # pinning the first and leaving the other would drift, so it must fail. + sdk = tmp_path / "sdk.toml" + cli = tmp_path / "cli.toml" + _write_pkg(sdk, "pipefy", ()) + cli.write_text( + '[project]\nname = "pipefy-cli"\nversion = "0.1.0"\n' + 'dependencies = ["pipefy"]\n' + '[dependency-groups]\ndev = ["pipefy==0.0.0"]\n', + encoding="utf-8", + ) + monkeypatch.setattr(_bump, "PACKAGE_PYPROJECTS", (sdk, cli)) + + with pytest.raises(ValueError, match="Expected one 'pipefy' dependency"): + _bump.write_dep_pins("9.9.9")