diff --git a/README.md b/README.md index eaff23e..9ee2b79 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,8 @@ name = "my_project" dynamic = ["version"] ``` +A Cargo workspace whose crates inherit `version.workspace = true` is read and bumped through `[workspace.package]`. `[tool.fastship].version-files` lists synchronized copies of the version, such as an npm `package.json` beside a wasm crate, and `ship-bump` rewrites them with the same exactly-once check as Python projects. + Commands: ```bash diff --git a/fastship/release.py b/fastship/release.py index c5caf6d..a5cfd66 100644 --- a/fastship/release.py +++ b/fastship/release.py @@ -11,7 +11,7 @@ "ship_rs_new", "ship_rs_init", "ship_rs_build", "ship_rs_bump", "ship_zig_new", "ship_zig_build"] import os, re, sys, json, shutil, subprocess, ast, importlib.resources, shlex -from dataclasses import dataclass +from dataclasses import dataclass, field try: import tomllib except ImportError: import tomli as tomllib # pragma: no cover @@ -200,6 +200,7 @@ class RustConfig: branch: str changelog_file: Path label_groups: dict + version_files: list[Path] @property def version(self) -> str: return _cargo_version(self.manifest_path) @@ -221,13 +222,9 @@ def get_config(start: str | Path | None = None) -> ShipConfig: branch = ship.get("branch") or os.getenv("FASTSHIP_BRANCH") or _git_branch() label_groups = _load_release_yml(root) or ship.get("label_groups") or DEFAULT_LABEL_GROUPS wheel_only = ship.get("wheel-only", False) - version_files = ship.get("version-files") or [] - if not isinstance(version_files, list) or not all(isinstance(o, str) for o in version_files): - raise ValueError("[tool.fastship].version-files must be a list of paths") - return ShipConfig(root=root, pyproject=pyproj, data=data, pkg=pkg, pkg_path=pkg_path, init_file=init_file, changelog_file=changelog_file, branch=branch, label_groups=label_groups, - wheel_only=wheel_only, version_files=[root/o for o in version_files]) + wheel_only=wheel_only, version_files=_version_files(root, ship)) def get_rs_config(start: str | Path | None = None) -> RustConfig: @@ -246,7 +243,15 @@ def get_rs_config(start: str | Path | None = None) -> RustConfig: changelog_file = root / ship.get("changelog_file", "CHANGELOG.md") label_groups = _load_release_yml(root) or ship.get("label_groups") or DEFAULT_LABEL_GROUPS return RustConfig(root=root, pyproject=pyproj, data=data, manifest_path=manifest_path, branch=branch, - changelog_file=changelog_file, label_groups=label_groups) + changelog_file=changelog_file, label_groups=label_groups, version_files=_version_files(root, ship)) + + +def _version_files(root:Path, ship:dict) -> list[Path]: + "Synchronized version copies from `[tool.fastship].version-files`, resolved against `root`." + files = ship.get("version-files") or [] + if not isinstance(files, list) or not all(isinstance(o, str) for o in files): + raise ValueError("[tool.fastship].version-files must be a list of paths") + return [root/o for o in files] def _project_type(root:Path, data:dict)->str: @@ -292,6 +297,7 @@ class CrateConfig: manifest_path: Path name: str branch: str + version_files: list[Path] = field(default_factory=list) @property def version(self) -> str: return _cargo_version(self.manifest_path) @@ -306,10 +312,13 @@ def get_crate_config(start: str | Path | None = None) -> CrateConfig: def _cargo_bump(cfg, part:int = None, unbump:bool = False): - "Bump `[package].version` in Cargo.toml, printing old and new." - print(f"Old version: {cfg.version}") - new = bump_version(cfg.version, part=part, unbump=unbump) - _replace_toml_section_key(cfg.manifest_path, "package", "version", new) + "Bump the version in Cargo.toml (`[package]`, or `[workspace.package]` when inherited), printing old and new." + old = cfg.version + print(f"Old version: {old}") + new = bump_version(old, part=part, unbump=unbump) + copies = _read_copies(cfg.version_files, old) + _replace_toml_section_key(cfg.manifest_path, _cargo_version_section(cfg.manifest_path), "version", new) + _write_copies(copies, old, new) print(f"New version: {new}") return new @@ -371,15 +380,26 @@ def _write_version(init_file: Path, version: str): def _write_config_version(cfg:ShipConfig, version:str): "Write the version back to the source used by this project." old = cfg.version + copies = _read_copies(cfg.version_files, old) + if (_load_toml(cfg.pyproject).get("project") or {}).get("version") is not None: + _replace_toml_section_key(cfg.pyproject, "project", "version", version) + else: _write_version(cfg.init_file, version) + _write_copies(copies, old, version) + + +def _read_copies(paths:list[Path], old:str) -> dict[Path,str]: + "Contents of each version copy, checked to hold `old` exactly once before anything is written." copies = {} - for path in cfg.version_files: + for path in paths: text = path.read_text(encoding="utf-8") if text.count(old) != 1: raise ValueError(f"Expected exactly one {old!r} in {path}") copies[path] = text - if (_load_toml(cfg.pyproject).get("project") or {}).get("version") is not None: - _replace_toml_section_key(cfg.pyproject, "project", "version", version) - else: _write_version(cfg.init_file, version) - for path, text in copies.items(): path.write_text(text.replace(old, version), encoding="utf-8") + return copies + + +def _write_copies(copies:dict[Path,str], old:str, new:str): + "Rewrite each copy read by `_read_copies` with `old` replaced by `new`." + for path, text in copies.items(): path.write_text(text.replace(old, new), encoding="utf-8") def bump_version(version: str, part: int = None, unbump: bool = False) -> str: @@ -434,10 +454,23 @@ def _is_maturin_project(data:dict) -> bool: return "maturin" in build_backend or bool(nested_idx(data, "tool", "maturin")) +def _cargo_version_section(manifest:Path) -> str: + "The version section in Cargo.toml; workspace root packages must inherit the shared version." + data = _load_toml(manifest) + ver = (data.get("package") or {}).get("version") + inherited = isinstance(ver, dict) and ver.get("workspace") is True + workspace = "workspace" in data + if workspace and "package" in data and not inherited: + raise ValueError(f"Fastship requires shared versioning in Cargo workspaces: {manifest}. " + "Set version.workspace = true in [package] and define the version in [workspace.package].") + return "workspace.package" if workspace or inherited else "package" + + def _cargo_version(manifest:Path) -> str: - "Read `[package].version` from Cargo.toml." - ver = (_load_toml(manifest).get("package") or {}).get("version") - if not ver: raise ValueError(f"Could not find [package].version in {manifest}") + "Read the version from Cargo.toml, following `version.workspace = true` to `[workspace.package]`." + sec = _cargo_version_section(manifest) + ver = nested_idx(_load_toml(manifest), *sec.split("."), "version") + if not isinstance(ver, str) or not ver: raise ValueError(f"Could not find [{sec}].version in {manifest}") return ver @@ -927,7 +960,7 @@ def ship_zig_build( def ship_rs_bump(part: int = 2, unbump: bool = False): - "Bump `[package].version` in Cargo.toml, then refresh the local editable install." + "Bump the version in Cargo.toml (`[package]`, or `[workspace.package]` when inherited), then refresh the local editable install." cfg = get_rs_config() _cargo_bump(cfg, part=part, unbump=unbump) os.chdir(cfg.root) diff --git a/tests/test_rs.py b/tests/test_rs.py index 0768afe..1bb75fa 100644 --- a/tests/test_rs.py +++ b/tests/test_rs.py @@ -1,6 +1,6 @@ from pathlib import Path -import fastship.release as relmod +import pytest, fastship.release as relmod def _make_rs_project(root: Path, configured: bool = True, dynamic: bool = True): @@ -66,6 +66,81 @@ def test_ship_bump_uses_static_project_version_when_present(tmp_path, monkeypatc assert '__version__ = "0.1.2"' in (pkg / "__init__.py").read_text(encoding="utf-8") +def _add_version_file(root: Path, text: str): + pyproj = root / "pyproject.toml" + pyproj.write_text(pyproj.read_text(encoding="utf-8") + 'version-files = ["wasm/package.json"]\n', encoding="utf-8") + (root / "wasm").mkdir() + (root / "wasm" / "package.json").write_text(text, encoding="utf-8") + + +def test_rs_version_files_bump_with_cargo(tmp_path, monkeypatch): + _make_rs_project(tmp_path) + _add_version_file(tmp_path, '{\n "name": "@acme/exhash",\n "version": "0.1.2"\n}\n') + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(relmod, "run", lambda cmd, *a, **k: None) + + relmod.ship_bump(part=2) + + assert 'version = "0.1.3"' in (tmp_path / "Cargo.toml").read_text(encoding="utf-8") + assert '"version": "0.1.3"' in (tmp_path / "wasm" / "package.json").read_text(encoding="utf-8") + + +def test_rs_version_files_refuse_stale_copy(tmp_path, monkeypatch): + _make_rs_project(tmp_path) + _add_version_file(tmp_path, '{"version": "0.0.9"}\n') + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(relmod, "run", lambda cmd, *a, **k: None) + + with pytest.raises(ValueError, match="exactly one"): relmod.ship_bump(part=2) + assert 'version = "0.1.2"' in (tmp_path / "Cargo.toml").read_text(encoding="utf-8") # nothing written + + +def test_single_crate_missing_version_names_package(tmp_path): + _make_rs_project(tmp_path) + (tmp_path / "Cargo.toml").write_text('[package]\nname = "exhash"\n', encoding="utf-8") + with pytest.raises(ValueError, match=r"\[package\]\.version"): relmod.get_rs_config(tmp_path).version + + +_ws_package = """[package] +name = "exhash" +version.workspace = true +edition = "2024" +""" +_ws_root = """[workspace] +members = ["py"] + +[workspace.package] +version = "0.1.2" +""" + + +@pytest.mark.parametrize("version", ["0.1.2", "9.8.7"]) +def test_workspace_rejects_independent_package_version(tmp_path, version): + _make_rs_project(tmp_path) + manifest = tmp_path / "Cargo.toml" + cargo = _ws_root + _ws_package.replace("version.workspace = true", f'version = "{version}"') + manifest.write_text(cargo, encoding="utf-8") + cfg = relmod.get_rs_config(tmp_path) + + with pytest.raises(ValueError, match=r"version\.workspace = true"): relmod._cargo_bump(cfg, part=2) + assert manifest.read_text(encoding="utf-8") == cargo + + +@pytest.mark.parametrize("cargo", [_ws_root + "\n" + _ws_package, _ws_package + "\n" + _ws_root]) +def test_workspace_version_read_and_bump(tmp_path, monkeypatch, cargo): + _make_rs_project(tmp_path) + (tmp_path / "Cargo.toml").write_text(cargo, encoding="utf-8") + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(relmod, "run", lambda cmd, *a, **k: None) + + assert relmod.get_rs_config(tmp_path).version == "0.1.2" + relmod.ship_bump(part=2) + + cargo = (tmp_path / "Cargo.toml").read_text(encoding="utf-8") + assert '[workspace.package]\nversion = "0.1.3"' in cargo + assert "version.workspace = true" in cargo # [package] untouched + + def test_ship_tag_release_needs_no_token_or_flags(tmp_path, monkeypatch): # The rust flow is pure tag-push: no changelog machinery, no GitHub client, no prompts. _make_rs_project(tmp_path)