From 3b113ce55e4f82899061c0c6572458dd0de50ca6 Mon Sep 17 00:00:00 2001 From: okcodes <80954089+okcodes@users.noreply.github.com> Date: Wed, 9 Sep 2026 21:17:36 -0600 Subject: [PATCH 1/3] feat: track pyproject.toml and uv.lock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `[project].version` is the same in-place TOML edit vump already performs for Cargo. What was undecided was the lock, and it is settled the way npm and Cargo were — by measurement. Editing only the project's own entry in a uv.lock and asking uv to re-lock produced a byte-identical file, so the entry is computable with no network and no knowledge of the dependency graph, which is the standard Cargo.lock and package-lock.json are held to. A uv workspace is the Cargo shape rather than the npm one: one entry per member in a shared lock, keyed by the name each manifest declares. That is the pairing vump already implements, so members need nothing new. The two TOML locks differ in one thing — Cargo records a source for what it fetched and none for what it built, uv records one for both and marks the local ones editable — so the shared machinery takes that as a predicate. A published release of a workspace member appears under the same name as the member, and only the source tells them apart. A version named in `dynamic` is refused: PEP 621 means a build backend computes it, so nothing here is vump's to move. Also collapses detection and the refusal that lists the alternatives onto one list. They were two hand-written sets that had already drifted once, and the test guarding them was a third. Co-Authored-By: Claude Opus 5 --- src/app/change.rs | 2 +- src/app/init.rs | 2 +- src/app/mod.rs | 28 ++- src/domain/version_file.rs | 396 ++++++++++++++++++++++++++++++++----- 4 files changed, 362 insertions(+), 66 deletions(-) diff --git a/src/app/change.rs b/src/app/change.rs index 7ca69dd..eed62d1 100644 --- a/src/app/change.rs +++ b/src/app/change.rs @@ -525,7 +525,7 @@ pub fn apply( // The same pairing the read side uses: a shared workspace lock records // every member, and this project's manifests say which entries are its own. let packages = - crate::app::cargo_package_names(fs, root, changes.files.iter().map(|f| f.path.as_str())); + crate::app::local_package_names(fs, root, changes.files.iter().map(|f| f.path.as_str())); let mut written = Vec::with_capacity(changes.files.len()); for file in &changes.files { diff --git a/src/app/init.rs b/src/app/init.rs index d5f1581..32f0fb3 100644 --- a/src/app/init.rs +++ b/src/app/init.rs @@ -74,7 +74,7 @@ pub fn discover(fs: &dyn FileSystem, root: &Path) -> Vec { // A lock file's entries are identified by the manifests declared with it, // so every manifest has to be in hand before anything can be judged. - let packages = crate::app::cargo_package_names(fs, root, found.iter().map(String::as_str)); + let packages = crate::app::local_package_names(fs, root, found.iter().map(String::as_str)); found.retain(|path| readable(fs, root, path, &packages)); found diff --git a/src/app/mod.rs b/src/app/mod.rs index 4236727..3dc5ad3 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -134,7 +134,7 @@ pub fn read_project_versions( root: &Path, project: &Project, ) -> Result, AppError> { - let packages = cargo_package_names(fs, root, project.files.iter().map(String::as_str)); + let packages = local_package_names(fs, root, project.files.iter().map(String::as_str)); let mut versions = Vec::with_capacity(project.files.len()); for declared in &project.files { @@ -169,21 +169,28 @@ pub fn read_project_versions( Ok(versions) } -/// The package names this project's Cargo manifests declare. +/// The package names this project's manifests declare. /// /// A shared workspace lock records every member it builds, so the entries a -/// project means are the ones its own manifests name. Manifests that cannot be -/// read are skipped here and reported by the pass that reads their version. -pub(crate) fn cargo_package_names<'a>( +/// project means are the ones its own manifests name. Cargo and uv both work +/// this way and are read together, since one project can hold both. Manifests +/// that cannot be read are skipped here and reported by the pass that reads +/// their version. +pub(crate) fn local_package_names<'a>( fs: &dyn FileSystem, root: &Path, declared: impl IntoIterator, ) -> Vec { declared .into_iter() - .filter(|path| file_name_of(path) == "Cargo.toml") - .filter_map(|path| fs.read(&resolve(root, path)).ok()) - .filter_map(|contents| version_file::cargo_package_name(&contents)) + .filter_map(|path| { + let read = match file_name_of(path) { + "Cargo.toml" => version_file::cargo_package_name, + "pyproject.toml" => version_file::pyproject_package_name, + _ => return None, + }; + read(&fs.read(&resolve(root, path)).ok()?) + }) .collect() } @@ -253,6 +260,7 @@ fn companion_locks(declared: &str) -> Option<(Vec, LockFile)> { let (lock, format) = match name { "Cargo.toml" => ("Cargo.lock", LockFile::Cargo), "package.json" => ("package-lock.json", LockFile::Npm), + "pyproject.toml" => ("uv.lock", LockFile::Uv), _ => return None, }; @@ -388,8 +396,8 @@ mod tests { #[test] fn an_unsupported_filename_is_rejected_before_any_read() { let fs = MemoryFileSystem::new(); - let err = read_project_versions(&fs, Path::new("/repo"), &project(&["pyproject.toml"])) - .unwrap_err(); + let err = + read_project_versions(&fs, Path::new("/repo"), &project(&["setup.py"])).unwrap_err(); assert!(matches!( err, AppError::VersionFile(VersionFileError::UnsupportedFile { .. }) diff --git a/src/domain/version_file.rs b/src/domain/version_file.rs index 7e40350..af01d31 100644 --- a/src/domain/version_file.rs +++ b/src/domain/version_file.rs @@ -26,6 +26,8 @@ pub enum Format { /// `MSBuild` project — `.csproj`, `.fsproj` or `.vbproj`; the version /// lives at ``. MsBuild, + /// Python manifest; the version lives at `[project].version`. + PyProject, /// A file whose entire contents are the version. PlainText, } @@ -45,6 +47,47 @@ pub enum LockFile { /// npm lock; the version at the top level and, from lockfile version 2, /// again under `packages[""]`. Npm, + /// uv lock; a `[[package]]` entry per package, those built from the + /// repository carrying an editable `source`. + Uv, +} + +/// Every filename recognized in full, and what it is read as. +/// +/// This is the only list: [`Tracked::detect`] matches against it and +/// [`VersionFileError::UnsupportedFile`] names it, so a format cannot be +/// supported without being offered to whoever spelled one wrong. +/// +/// `Directory.Build.props` and `.targets` are authored and committed like a +/// project file, neither generated; they differ in when `MSBuild` imports them, +/// not in what they hold. +const RECOGNIZED: &[(&str, Tracked)] = &[ + ("package.json", Tracked::Manifest(Format::PackageJson)), + ("package-lock.json", Tracked::Lock(LockFile::Npm)), + ("Cargo.toml", Tracked::Manifest(Format::CargoToml)), + ("Cargo.lock", Tracked::Lock(LockFile::Cargo)), + ("pyproject.toml", Tracked::Manifest(Format::PyProject)), + ("uv.lock", Tracked::Lock(LockFile::Uv)), + ("Directory.Build.props", Tracked::Manifest(Format::MsBuild)), + ( + "Directory.Build.targets", + Tracked::Manifest(Format::MsBuild), + ), + ("VERSION", Tracked::Manifest(Format::PlainText)), +]; + +/// The recognized names, as the refusal message lists them. +fn recognized_names() -> String { + let names: Vec<&str> = RECOGNIZED.iter().map(|(name, _)| *name).collect(); + let extensions: Vec = MSBUILD_PROJECT_EXTENSIONS + .iter() + .map(|ext| format!(".{ext}")) + .collect(); + format!( + "{}, or a {} project", + names.join(", "), + extensions.join("/") + ) } /// A file vump tracks a version in. @@ -62,29 +105,15 @@ impl Tracked { /// Returns `None` when the name is not one vump recognizes. #[must_use] pub fn detect(file_name: &str) -> Option { - match file_name { - "package.json" => Some(Self::Manifest(Format::PackageJson)), - "Cargo.toml" => Some(Self::Manifest(Format::CargoToml)), - // Authored and committed like any project file, neither generated: - // where a solution keeps one version for the projects beneath it. - // The pair differ in when MSBuild imports them, not in what they - // hold, so the same element is read from both. - "Directory.Build.props" | "Directory.Build.targets" => { - Some(Self::Manifest(Format::MsBuild)) - } - "VERSION" => Some(Self::Manifest(Format::PlainText)), - // The only names recognized by extension rather than in full: an - // MSBuild project is named after the assembly it builds. - _ if MSBUILD_PROJECT_EXTENSIONS - .iter() - .any(|ext| has_extension(file_name, ext)) => - { - Some(Self::Manifest(Format::MsBuild)) - } - "Cargo.lock" => Some(Self::Lock(LockFile::Cargo)), - "package-lock.json" => Some(Self::Lock(LockFile::Npm)), - _ => None, + if let Some((_, tracked)) = RECOGNIZED.iter().find(|(name, _)| *name == file_name) { + return Some(*tracked); } + // The only names recognized by extension rather than in full: an + // MSBuild project is named after the assembly it builds. + MSBUILD_PROJECT_EXTENSIONS + .iter() + .any(|ext| has_extension(file_name, ext)) + .then_some(Self::Manifest(Format::MsBuild)) } /// Classifies a file by its name, erroring when it is not recognized. @@ -103,11 +132,7 @@ impl Tracked { #[derive(Debug, Clone, PartialEq, Eq, Error)] pub enum VersionFileError { /// The filename is not one vump recognizes. - #[error( - "unsupported file {name:?}; expected package.json, package-lock.json, Cargo.toml, \ - Cargo.lock, VERSION, Directory.Build.props, Directory.Build.targets, or a \ - .csproj, .fsproj or .vbproj project" - )] + #[error("unsupported file {name:?}; expected {}", recognized_names())] UnsupportedFile { /// The filename that could not be classified. name: String, @@ -185,6 +210,17 @@ pub enum VersionFileError { /// The file being read. file: String, }, + + /// The manifest declares its version dynamic, so a build backend computes + /// it and no version is recorded here to move. + #[error( + "{file} declares version in `dynamic`, so its build backend computes it; \ + track the file that backend reads instead" + )] + DynamicVersion { + /// The file being read. + file: String, + }, } impl Format { @@ -193,7 +229,7 @@ impl Format { pub fn describe(self) -> &'static str { match self { Self::PackageJson => "JSON", - Self::CargoToml => "TOML", + Self::CargoToml | Self::PyProject => "TOML", Self::MsBuild => "XML", Self::PlainText => "plain text", } @@ -206,6 +242,7 @@ impl Format { Self::PackageJson => "\"version\"", Self::CargoToml => "[package].version", Self::MsBuild => "", + Self::PyProject => "[project].version", Self::PlainText => "version", } } @@ -230,6 +267,7 @@ impl Format { contents[span].to_owned() } Self::CargoToml => read_cargo_version(file, contents)?, + Self::PyProject => read_pyproject_version(file, contents)?, Self::MsBuild => match find_msbuild_version(file, contents)? { Some(span) => contents[span].to_owned(), None => String::new(), @@ -266,6 +304,7 @@ impl Format { Ok(splice(contents, &[span], version)) } Self::CargoToml => write_cargo_version(file, contents, version), + Self::PyProject => write_pyproject_version(file, contents, version), Self::MsBuild => { let span = find_msbuild_version(file, contents)?.ok_or_else(|| { VersionFileError::MissingField { @@ -287,7 +326,7 @@ impl LockFile { #[must_use] pub fn field_description(self) -> &'static str { match self { - Self::Cargo => "[[package]].version", + Self::Cargo | Self::Uv => "[[package]].version", Self::Npm => "\"version\"", } } @@ -305,7 +344,8 @@ impl LockFile { packages: &[String], ) -> Result { let raw = match self { - Self::Cargo => read_cargo_lock_version(file, contents, packages)?, + Self::Cargo => read_cargo_lock_version(file, contents, packages, is_cargo_local)?, + Self::Uv => read_cargo_lock_version(file, contents, packages, is_uv_local)?, // An npm lock records only the root package until npm workspaces // are supported, so there is nothing yet for `packages` to select. Self::Npm => read_package_lock_version(file, contents)?, @@ -329,7 +369,10 @@ impl LockFile { packages: &[String], ) -> Result { match self { - Self::Cargo => write_cargo_lock_version(file, contents, version, packages), + Self::Cargo => { + write_cargo_lock_version(file, contents, version, packages, is_cargo_local) + } + Self::Uv => write_cargo_lock_version(file, contents, version, packages, is_uv_local), Self::Npm => write_package_lock_version(file, contents, version), } } @@ -544,6 +587,84 @@ fn read_cargo_version(file: &str, contents: &str) -> Result Result { + let doc = parse_cargo(file, contents)?; + + let project = doc + .get("project") + .and_then(toml_edit::Item::as_table_like) + .ok_or_else(|| VersionFileError::MissingField { + file: file.to_owned(), + field: "[project]".to_owned(), + })?; + + // PEP 621: a name listed in `dynamic` is supplied by the build backend, and + // declaring it in both places is invalid, so there is nothing here to move. + let dynamic = project + .get("dynamic") + .and_then(toml_edit::Item::as_array) + .is_some_and(|names| names.iter().any(|n| n.as_str() == Some("version"))); + if dynamic { + return Err(VersionFileError::DynamicVersion { + file: file.to_owned(), + }); + } + + project + .get("version") + .and_then(toml_edit::Item::as_str) + .map(str::to_owned) + .ok_or_else(|| VersionFileError::MissingField { + file: file.to_owned(), + field: "[project].version".to_owned(), + }) +} + +/// Rewrites `[project].version`, preserving the rest of the document. +fn write_pyproject_version( + file: &str, + contents: &str, + version: &Version, +) -> Result { + // Reading first rejects manifests with no writable version — a dynamic or + // absent one — before any mutation is attempted. + read_pyproject_version(file, contents)?; + + let mut doc = parse_cargo(file, contents)?; + let slot = + doc["project"]["version"] + .as_value_mut() + .ok_or_else(|| VersionFileError::MissingField { + file: file.to_owned(), + field: "[project].version".to_owned(), + })?; + + // Assigning through the existing value keeps its surrounding whitespace and + // any trailing comment on the line. + let decor = slot.decor().clone(); + *slot = toml_edit::Value::from(version.to_string()); + *slot.decor_mut() = decor; + + Ok(doc.to_string()) +} + +/// Reads the package name a Python manifest declares. +/// +/// This is what pairs a manifest with its entry in a shared uv workspace lock, +/// exactly as [`cargo_package_name`] does for Cargo. +#[must_use] +pub fn pyproject_package_name(contents: &str) -> Option { + contents + .parse::() + .ok()? + .get("project")? + .as_table_like()? + .get("name")? + .as_str() + .map(str::to_owned) +} + /// Reads the package name a Cargo manifest declares. /// /// This is what pairs a manifest with its entry in a shared workspace lock. @@ -656,9 +777,10 @@ fn read_cargo_lock_version( file: &str, contents: &str, packages: &[String], + is_local: IsLocal, ) -> Result { let doc = parse_cargo(file, contents)?; - let entries = local_packages(file, &doc, packages)?; + let entries = local_packages(file, &doc, packages, is_local)?; let mut versions = entries.iter().map(|entry| entry.version.as_str()); let first = versions.next().unwrap_or_default().to_owned(); @@ -680,13 +802,14 @@ fn write_cargo_lock_version( contents: &str, version: &Version, packages: &[String], + is_local: IsLocal, ) -> Result { // Reading first rejects a lock naming none of the project's packages, or // recording them inconsistently, before any mutation is attempted. - read_cargo_lock_version(file, contents, packages)?; + read_cargo_lock_version(file, contents, packages, is_local)?; let mut doc = parse_cargo(file, contents)?; - let indexes: Vec = local_packages(file, &doc, packages)? + let indexes: Vec = local_packages(file, &doc, packages, is_local)? .iter() .map(|entry| entry.index) .collect(); @@ -719,6 +842,27 @@ fn write_cargo_lock_version( Ok(doc.to_string()) } +/// Decides whether a `[[package]]` entry was built from the working tree. +/// +/// The two TOML locks agree on everything but this: Cargo records a `source` +/// for what it fetched and none for what it built, while uv records one for +/// both and marks the local ones editable. +type IsLocal = fn(&toml_edit::Table) -> bool; + +/// Cargo records a `source` only for packages it fetched. +fn is_cargo_local(table: &toml_edit::Table) -> bool { + table.get("source").is_none() +} + +/// uv records `source = { editable = "" }` for packages in the tree, and +/// `{ registry = "..." }` for everything it fetched. +fn is_uv_local(table: &toml_edit::Table) -> bool { + table + .get("source") + .and_then(toml_edit::Item::as_inline_table) + .is_some_and(|source| source.contains_key("editable")) +} + /// One `[[package]]` entry that belongs to the project being versioned. struct LocalPackage { index: usize, @@ -736,6 +880,7 @@ fn local_packages( file: &str, doc: &DocumentMut, packages: &[String], + is_local: IsLocal, ) -> Result, VersionFileError> { if packages.is_empty() { return Err(VersionFileError::UnidentifiedLock { @@ -761,7 +906,7 @@ fn local_packages( let selected: Vec = tables .iter() .enumerate() - .filter(|(_, table)| table.get("source").is_none()) + .filter(|(_, table)| is_local(table)) .filter(|(_, table)| field(table, "name").is_some_and(|name| packages.contains(&name))) .map(|(index, table)| LocalPackage { index, @@ -895,25 +1040,14 @@ mod tests { } #[test] - fn the_unsupported_message_names_every_supported_form() { - // The message is the only place the supported set is written out for a - // reader, and nothing tied it to the set itself: it named four of six - // for two releases, telling .NET users their format was not supported. - let message = Tracked::require("nope.txt").unwrap_err().to_string(); - for form in [ - "package.json", - "package-lock.json", - "Cargo.toml", - "Directory.Build.props", - "Directory.Build.targets", - "Cargo.lock", - "VERSION", - ".csproj", - ".fsproj", - ".vbproj", - ] { - assert!(message.contains(form), "{form} missing from {message:?}"); + fn an_unrecognized_name_is_offered_the_alternatives() { + // Detection and this message read one list, so they cannot disagree. + // What is still worth asserting is that the message reaches the reader. + let message = Tracked::require("setup.py").unwrap_err().to_string(); + for (name, _) in RECOGNIZED { + assert!(message.contains(name), "{name} missing from {message:?}"); } + assert!(message.contains(".csproj"), "{message:?}"); } #[test] @@ -943,7 +1077,11 @@ mod tests { manifest("VERSION"), Some(Tracked::Manifest(Format::PlainText)) ); - assert_eq!(Tracked::detect("pyproject.toml"), None); + assert_eq!( + manifest("pyproject.toml"), + Some(Tracked::Manifest(Format::PyProject)) + ); + assert_eq!(manifest("uv.lock"), Some(Tracked::Lock(LockFile::Uv))); // Detection is exact; casing is not normalized. assert_eq!(Tracked::detect("cargo.toml"), None); } @@ -1344,6 +1482,156 @@ dependencies = [ } /// Cargo's own output for a two-member workspace. See `testdata/README.md`. + const PYPROJECT: &str = "\ +[project] +name = \"demo\" +version = \"1.2.3\" # released from CI +requires-python = \">=3.11\" +dependencies = [\"idna>=3.6\"] +"; + + /// One editable entry for the project, one registry entry for a dependency + /// — the shape `uv lock` produces for a single project. + const UV_LOCK: &str = "\ +version = 1 +revision = 3 + +[[package]] +name = \"demo\" +version = \"1.2.3\" +source = { editable = \".\" } + +[[package]] +name = \"idna\" +version = \"3.19\" +source = { registry = \"https://pypi.org/simple\" } +"; + + #[test] + fn reads_pyproject_toml() { + assert_eq!( + Format::PyProject.read("pyproject.toml", PYPROJECT).unwrap(), + v("1.2.3") + ); + } + + #[test] + fn writing_a_pyproject_keeps_the_comment_on_the_line() { + let out = Format::PyProject + .write("pyproject.toml", PYPROJECT, &v("2.0.0-rc.1")) + .unwrap(); + assert!( + out.contains("version = \"2.0.0-rc.1\" # released from CI"), + "{out}" + ); + assert!(out.contains("dependencies = [\"idna>=3.6\"]"), "{out}"); + } + + #[test] + fn a_version_the_build_backend_computes_is_refused() { + // PEP 621: naming `version` in `dynamic` means a backend supplies it, + // and declaring it in both places is invalid — so there is nothing + // here to move, and guessing at where it lives is not vump's job. + let src = "[project]\nname = \"demo\"\ndynamic = [\"version\"]\n"; + let err = Format::PyProject.read("pyproject.toml", src).unwrap_err(); + assert!( + matches!(err, VersionFileError::DynamicVersion { .. }), + "{err:?}" + ); + assert!( + Format::PyProject + .write("pyproject.toml", src, &v("2.0.0")) + .is_err() + ); + } + + #[test] + fn a_uv_lock_records_the_project_and_not_its_dependencies() { + assert_eq!( + LockFile::Uv.read("uv.lock", UV_LOCK, &demo()).unwrap(), + v("1.2.3") + ); + + // idna is at 3.19 in the same file and must not move. + let out = LockFile::Uv + .write("uv.lock", UV_LOCK, &v("1.3.0"), &demo()) + .unwrap(); + assert!( + out.contains("name = \"demo\"\nversion = \"1.3.0\""), + "{out}" + ); + assert!(out.contains("name = \"idna\"\nversion = \"3.19\""), "{out}"); + } + + #[test] + fn a_registry_copy_of_the_project_is_left_alone() { + // The name filter alone cannot tell these apart: a workspace member and + // a published release of the same package both appear as `demo`. Only + // the editable source says which one the working tree builds. + let lock = "\ +version = 1 + +[[package]] +name = \"demo\" +version = \"1.2.3\" +source = { editable = \".\" } + +[[package]] +name = \"demo\" +version = \"0.9.0\" +source = { registry = \"https://pypi.org/simple\" } +"; + assert_eq!( + LockFile::Uv.read("uv.lock", lock, &demo()).unwrap(), + v("1.2.3") + ); + + let out = LockFile::Uv + .write("uv.lock", lock, &v("1.3.0"), &demo()) + .unwrap(); + assert!( + out.contains("version = \"1.3.0\"\nsource = { editable"), + "{out}" + ); + assert!( + out.contains("version = \"0.9.0\"\nsource = { registry"), + "{out}" + ); + } + + #[test] + fn a_uv_workspace_member_is_selected_by_name() { + // Members share one lock and are keyed by name, as Cargo's are, so a + // project writes only the entries its own manifests declare. + let lock = "\ +version = 1 + +[[package]] +name = \"api\" +version = \"2.0.0\" +source = { editable = \"packages/api\" } + +[[package]] +name = \"core\" +version = \"2.0.0\" +source = { editable = \"packages/core\" } +"; + let api = names(&["api"]); + assert_eq!( + LockFile::Uv.read("uv.lock", lock, &api).unwrap(), + v("2.0.0") + ); + + let out = LockFile::Uv + .write("uv.lock", lock, &v("2.1.0"), &api) + .unwrap(); + assert!(out.contains("name = \"api\"\nversion = \"2.1.0\""), "{out}"); + assert!( + out.contains("name = \"core\"\nversion = \"2.0.0\""), + "{out}" + ); + } + const WORKSPACE: &str = include_str!("testdata/cargo-workspace.lock"); /// The package the single-crate lock fixture was generated for. From 858b6d5db67e5e2fc8b2bf35074071e2847ce47d Mon Sep 17 00:00:00 2001 From: okcodes <80954089+okcodes@users.noreply.github.com> Date: Wed, 9 Sep 2026 21:20:00 -0600 Subject: [PATCH 2/3] feat: add a Python sandbox, and retire the backlog entry it settles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sandbox/py/single-project is a manifest and a lock moving together, the shape the entry existed to test. Verified against real uv rather than asserted: a bump writes both files, `uv lock` afterwards produces a byte-identical one, and `uv run python -m demo` reports the new version. The demo reads its version from installed metadata rather than repeating it in source, so the sandbox does not quietly demonstrate a second place a version can go stale. uv leaves a .venv and __pycache__ behind, which join the sandbox build output already ignored. The declarative-formats entry named pyproject.toml and .csproj as the formats motivating it. Both are now built in, neither costing more than a day, which is evidence against the entry rather than for it — recorded there. Co-Authored-By: Claude Opus 5 --- .gitignore | 2 ++ BACKLOG.md | 32 ++++++------------- DESIGN.md | 20 ++++++++---- README.md | 2 ++ sandbox/README.md | 10 ++++++ sandbox/py/single-project/pyproject.toml | 9 ++++++ .../py/single-project/src/demo/__init__.py | 6 ++++ .../py/single-project/src/demo/__main__.py | 3 ++ sandbox/py/single-project/uv.lock | 23 +++++++++++++ sandbox/py/single-project/vump.toml | 16 ++++++++++ tests/cli.rs | 2 ++ 11 files changed, 96 insertions(+), 29 deletions(-) create mode 100644 sandbox/py/single-project/pyproject.toml create mode 100644 sandbox/py/single-project/src/demo/__init__.py create mode 100644 sandbox/py/single-project/src/demo/__main__.py create mode 100644 sandbox/py/single-project/uv.lock create mode 100644 sandbox/py/single-project/vump.toml diff --git a/.gitignore b/.gitignore index f690743..b4e458c 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,5 @@ dist/ sandbox/**/bin/ sandbox/**/obj/ sandbox/**/node_modules/ +sandbox/**/.venv/ +sandbox/**/__pycache__/ diff --git a/BACKLOG.md b/BACKLOG.md index 367afde..b2fe11d 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -26,23 +26,6 @@ agreed, at which point it is ranked by value. Each says what would settle it. Nothing moves up until something does. -### Python projects - -`pyproject.toml` holds `[project].version`, which is the same in-place TOML -edit vump already performs. What is undecided is `uv`, which is the tool that -would be used here: it keeps a `uv.lock` recording the project's own version, -so the lock question arrives with it. - -Settling it is an experiment, not a discussion, and the same one that settled -npm and Cargo: put a project in `sandbox/py/`, bump the version by hand, run -`uv lock`, and diff. If the only change is the project's own version, the lock -is trackable on exactly the terms `Cargo.lock` and `package-lock.json` are — -computable with no network and no knowledge of the dependency graph. If `uv` -rewrites more than that, it is not, and `pyproject.toml` is tracked alone. - -Low priority by the only measure that matters here: barely any Python is -written in this repository's orbit, so it waits behind formats that are. - ### npm workspaces A Cargo workspace's shared lock is now written per member, matched by the @@ -57,13 +40,16 @@ inference that produced the original lock-file defect. ### Declarative version-file formats -Detection is by filename across the built-in formats. `pyproject.toml`, -`*.csproj`, `gradle.properties` and others need a per-entry extraction spec — a -path for structured formats, a pattern for the rest. +Detection is by filename. Anything else — `gradle.properties`, a `*.gemspec`, a +version in a shell script — would need a per-entry extraction spec: a path for +structured formats, a pattern for the rest. + +Weaker than it looked. Both formats this entry once named as motivation, +`pyproject.toml` and `*.csproj`, were added as built-ins instead, neither +costing more than a day. A spec would change the configuration schema +permanently to avoid work that keeps turning out to be small. -The reason this has not been designed: it changes the configuration schema, and -doing that well needs a real target format in hand rather than a guess at what -would be general enough. +What would settle it: a format someone needs that is not worth adding built-in. ### A Rust project in the sandbox diff --git a/DESIGN.md b/DESIGN.md index e09999b..3fe910d 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -90,6 +90,8 @@ builds. The three extensions are one language each and one format. | `package-lock.json` | JSON | `.version`, and `.packages[""].version` | | `Cargo.toml` | TOML | `[package].version` | | `Cargo.lock` | TOML | the sole `[[package]]` with no `source` | +| `pyproject.toml` | TOML | `[project].version` | +| `uv.lock` | TOML | each `[[package]]` with an editable `source` | | `VERSION` | plain text | entire file contents | **Writes must preserve the rest of the file byte-for-byte.** Update the version @@ -128,10 +130,10 @@ formats come first. ### Lock files A lock file that records the project's own version is a version file by the -definition above, and is tracked like any other. `Cargo.lock` and -`package-lock.json` qualify: each records the version of the project it locks, -and `cargo build --locked` and `npm ci` both reject a tree where a lock and its -manifest disagree. +definition above, and is tracked like any other. `Cargo.lock`, +`package-lock.json` and `uv.lock` qualify: each records the version of the +project it locks, and `cargo build --locked`, `npm ci` and `uv sync --locked` +each reject a tree where a lock and its manifest disagree. This does not weaken the non-goal. Resolving dependencies is a package manager's job and vump never does it; restating a version vump has just written @@ -148,10 +150,16 @@ In `Cargo.lock` the entries that belong to the repository are the everything it fetched. A single-crate repository has one, and it needs no naming. +`uv.lock` is the same shape with the marker inverted: uv records a `source` +for both, and the entries built from the tree are the ones marked `editable`. +That distinction is load-bearing rather than cosmetic — a published release of +a workspace member appears under the member's own name, and only the source +says which of the two the tree builds. + A workspace has one such entry per member, and the lock alone cannot say which of them a project means — so the manifests declared alongside it do. Each -`Cargo.toml` names its package, and the entries a project writes are exactly -the ones its own manifests name. That covers both shapes a workspace takes: +`Cargo.toml` or `pyproject.toml` names its package, and the entries a project +writes are exactly the ones its own manifests name. That covers both shapes a workspace takes: members held at one version declare every manifest in a single project and move together, while independently-versioned members declare one manifest each and touch only their own entry. Members that are not declared are not touched. diff --git a/README.md b/README.md index 20facff..99805ae 100644 --- a/README.md +++ b/README.md @@ -224,6 +224,8 @@ order, indentation, and comments elsewhere in the file survive untouched. | `package-lock.json` | top-level `version`, and the root `packages` entry | | `Cargo.toml` | `[package].version` | | `Cargo.lock` | the `[[package]]` entry for this crate | +| `pyproject.toml` | `[project].version` | +| `uv.lock` | the `[[package]]` entry for this project | | `VERSION` | the whole file | A `version` nested under `dependencies` is never mistaken for the project's diff --git a/sandbox/README.md b/sandbox/README.md index 0feeb47..f3d205a 100644 --- a/sandbox/README.md +++ b/sandbox/README.md @@ -16,6 +16,7 @@ This is not test coverage. The suite in `tests/` and the fixtures in | [`cs/single-project`](cs/single-project) | `` in a `.csproj`, and what stays put around it | | [`cs/multi-project`](cs/multi-project) | Two C# projects versioned independently | | [`cs/shared-version`](cs/shared-version) | An executable and its library on one `Directory.Build.props` | +| [`py/single-project`](py/single-project) | One Python project, `pyproject.toml` and `uv.lock` together | ## Git is off, deliberately @@ -90,6 +91,15 @@ vump minor --allow-nested # rewrites one file, not two dotnet run --project App # App 1.1.0 / Lib 1.1.0 ``` +For Python, the lock moves with the manifest and needs no network to do it — +`uv lock` afterwards produces the same file vump already wrote: + +```bash +cd sandbox/py/single-project +vump minor --allow-nested # pyproject.toml and uv.lock +uv run python -m demo # demo 1.1.0 +``` + ## Putting it back Experiments are meant to be thrown away: diff --git a/sandbox/py/single-project/pyproject.toml b/sandbox/py/single-project/pyproject.toml new file mode 100644 index 0000000..25ece6b --- /dev/null +++ b/sandbox/py/single-project/pyproject.toml @@ -0,0 +1,9 @@ +[project] +name = "demo" +version = "1.0.0" +requires-python = ">=3.11" +dependencies = ["idna>=3.6"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" diff --git a/sandbox/py/single-project/src/demo/__init__.py b/sandbox/py/single-project/src/demo/__init__.py new file mode 100644 index 0000000..598b26f --- /dev/null +++ b/sandbox/py/single-project/src/demo/__init__.py @@ -0,0 +1,6 @@ +"""Reads the version from the installed metadata, which is what pyproject.toml +declares — so nothing here repeats the number vump writes.""" + +from importlib.metadata import version + +__version__ = version("demo") diff --git a/sandbox/py/single-project/src/demo/__main__.py b/sandbox/py/single-project/src/demo/__main__.py new file mode 100644 index 0000000..b037b13 --- /dev/null +++ b/sandbox/py/single-project/src/demo/__main__.py @@ -0,0 +1,3 @@ +from . import __version__ + +print(f"demo {__version__}") diff --git a/sandbox/py/single-project/uv.lock b/sandbox/py/single-project/uv.lock new file mode 100644 index 0000000..e6b976b --- /dev/null +++ b/sandbox/py/single-project/uv.lock @@ -0,0 +1,23 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "demo" +version = "1.0.0" +source = { editable = "." } +dependencies = [ + { name = "idna" }, +] + +[package.metadata] +requires-dist = [{ name = "idna", specifier = ">=3.6" }] + +[[package]] +name = "idna" +version = "3.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" }, +] diff --git a/sandbox/py/single-project/vump.toml b/sandbox/py/single-project/vump.toml new file mode 100644 index 0000000..bdca1a6 --- /dev/null +++ b/sandbox/py/single-project/vump.toml @@ -0,0 +1,16 @@ +# One Python project, manifest and lock moving together. +# +# uv.lock records the project's own version in the [[package]] entry whose +# source is editable. Editing that entry is all a bump does — asking uv to +# re-lock afterwards produces the same file, so the lock never needs the +# network to be brought back in step. +# +# Git is off throughout the sandbox, and the tag pattern cannot be mistaken +# for vump's own — see ../../npm/single-project/vump.toml. + +files = ["pyproject.toml", "uv.lock"] + +[git] +through = "none" +tag_pattern = "sandbox-py-v{new_version}" +commit_message = "chore(sandbox): bump py/single-project to {new_version}" diff --git a/tests/cli.rs b/tests/cli.rs index ae2aafa..e2947a6 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -1729,6 +1729,7 @@ fn the_sandbox_projects_stay_usable() { "cs/single-project", "cs/multi-project", "cs/shared-version", + "py/single-project", ] { let output = Command::new(env!("CARGO_BIN_EXE_vump")) .args(["status", "--allow-nested"]) @@ -1764,6 +1765,7 @@ fn no_sandbox_project_can_produce_a_release_shaped_tag() { ("cs/single-project", None), ("cs/multi-project", Some("project-b")), ("cs/shared-version", None), + ("py/single-project", None), ] { // --allow-nested is the point of this test: it asks for the tag a // nested configuration would create, precisely to inspect its shape. From 13d4721738b3344ea3f677ba5f55d2e6b75d9ab4 Mon Sep 17 00:00:00 2001 From: okcodes <80954089+okcodes@users.noreply.github.com> Date: Wed, 9 Sep 2026 23:53:13 -0600 Subject: [PATCH 3/3] chore: bump version to v0.8.0-alpha.0 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e903233..ec607da 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1178,7 +1178,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "vump" -version = "0.7.0" +version = "0.8.0-alpha.0" dependencies = [ "clap", "inquire", diff --git a/Cargo.toml b/Cargo.toml index 05d22bf..95612b1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "vump" -version = "0.7.0" +version = "0.8.0-alpha.0" edition = "2024" rust-version = "1.85" description = "Keep semver version numbers in sync across a repository, and verify them in CI"