Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
73 changes: 53 additions & 20 deletions fastship/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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)
Expand All @@ -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

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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)
Expand Down
77 changes: 76 additions & 1 deletion tests/test_rs.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down Expand Up @@ -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)
Expand Down