diff --git a/crates/uv-resolver/src/lock/export/mod.rs b/crates/uv-resolver/src/lock/export/mod.rs index 87d774b57..f1fd16106 100644 --- a/crates/uv-resolver/src/lock/export/mod.rs +++ b/crates/uv-resolver/src/lock/export/mod.rs @@ -16,11 +16,12 @@ use uv_pep508::MarkerTree; use uv_pypi_types::ConflictItem; use crate::graph_ops::{Reachable, marker_reachability}; +use crate::lock::LockErrorKind; pub(crate) use crate::lock::export::pylock_toml::PylockTomlPackage; pub use crate::lock::export::pylock_toml::{PylockToml, PylockTomlErrorKind}; pub use crate::lock::export::requirements_txt::RequirementsTxtExport; use crate::universal_marker::resolve_conflicts; -use crate::{Installable, Package}; +use crate::{Installable, LockError, Package}; mod pylock_toml; mod requirements_txt; @@ -49,7 +50,7 @@ impl<'lock> ExportableRequirements<'lock> { groups: &DependencyGroupsWithDefaults, annotate: bool, install_options: &'lock InstallOptions, - ) -> Self { + ) -> Result { let size_guess = target.lock().packages.len(); let mut graph = Graph::, Edge<'lock>>::with_capacity(size_guess, size_guess); let mut inverse = FxHashMap::with_capacity_and_hasher(size_guess, FxBuildHasher); @@ -73,8 +74,12 @@ impl<'lock> ExportableRequirements<'lock> { let dist = target .lock() .find_by_name(root_name) - .expect("found too many packages matching root") - .expect("could not find root"); + .map_err(|_| LockErrorKind::MultipleRootPackages { + name: root_name.clone(), + })? + .ok_or_else(|| LockErrorKind::MissingRootPackage { + name: root_name.clone(), + })?; if groups.prod() { // Add the workspace package to the graph. @@ -330,7 +335,7 @@ impl<'lock> ExportableRequirements<'lock> { .filter(|requirement| !requirement.marker.is_false()) .collect::>(); - Self(nodes) + Ok(Self(nodes)) } } diff --git a/crates/uv-resolver/src/lock/export/pylock_toml.rs b/crates/uv-resolver/src/lock/export/pylock_toml.rs index 2889842b5..4f97ee9c6 100644 --- a/crates/uv-resolver/src/lock/export/pylock_toml.rs +++ b/crates/uv-resolver/src/lock/export/pylock_toml.rs @@ -631,7 +631,7 @@ impl<'lock> PylockToml { dev, annotate, install_options, - ); + )?; // Sort the nodes. nodes.sort_unstable_by_key(|node| &node.package.id); diff --git a/crates/uv-resolver/src/lock/export/requirements_txt.rs b/crates/uv-resolver/src/lock/export/requirements_txt.rs index 61a8daa44..96fabcbeb 100644 --- a/crates/uv-resolver/src/lock/export/requirements_txt.rs +++ b/crates/uv-resolver/src/lock/export/requirements_txt.rs @@ -46,7 +46,7 @@ impl<'lock> RequirementsTxtExport<'lock> { dev, annotate, install_options, - ); + )?; // Sort the nodes, such that unnamed URLs (editables) appear at the top. nodes.sort_unstable_by(|a, b| { diff --git a/crates/uv-resolver/src/lock/mod.rs b/crates/uv-resolver/src/lock/mod.rs index b446ba1fa..0b76bed86 100644 --- a/crates/uv-resolver/src/lock/mod.rs +++ b/crates/uv-resolver/src/lock/mod.rs @@ -1269,7 +1269,7 @@ impl Lock { /// Returns the package with the given name. If there are multiple /// matching packages, then an error is returned. If there are no /// matching packages, then `Ok(None)` is returned. - fn find_by_name(&self, name: &PackageName) -> Result, String> { + pub fn find_by_name(&self, name: &PackageName) -> Result, String> { let mut found_dist = None; for dist in &self.packages { if &dist.id.name == name { diff --git a/crates/uv/src/commands/project/lock.rs b/crates/uv/src/commands/project/lock.rs index 5363a365a..de0690246 100644 --- a/crates/uv/src/commands/project/lock.rs +++ b/crates/uv/src/commands/project/lock.rs @@ -334,6 +334,17 @@ impl<'env> LockOperation<'env> { .read() .await? .ok_or_else(|| ProjectError::MissingLockfile)?; + // Check if the discovered workspace members match the locked workspace members. + if let LockTarget::Workspace(workspace) = target { + for package_name in workspace.packages().keys() { + existing + .find_by_name(package_name) + .map_err(|_| ProjectError::LockWorkspaceMismatch(package_name.clone()))? + .ok_or_else(|| { + ProjectError::LockWorkspaceMismatch(package_name.clone()) + })?; + } + } Ok(LockResult::Unchanged(existing)) } LockMode::Locked(interpreter, lock_source) => { diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index 785874c6e..644642eb1 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -87,6 +87,11 @@ pub(crate) enum ProjectError { )] MissingLockfile, + #[error( + "The lockfile at `uv.lock` needs to be updated, but `--frozen` was provided: Missing workspace member `{0}`. To update the lockfile, run `uv lock`." + )] + LockWorkspaceMismatch(PackageName), + #[error( "The lockfile at `uv.lock` uses an unsupported schema version (v{1}, but only v{0} is supported). Downgrade to a compatible uv version, or remove the `uv.lock` prior to running `uv lock` or `uv sync`." )] diff --git a/crates/uv/tests/it/export.rs b/crates/uv/tests/it/export.rs index 649f81955..df23cf09a 100644 --- a/crates/uv/tests/it/export.rs +++ b/crates/uv/tests/it/export.rs @@ -4618,3 +4618,45 @@ fn export_only_group_and_extra_conflict() -> Result<()> { Ok(()) } + +/// The members in the workspace (`foo`) and in the lockfile (`bar`) mismatch. +#[test] +fn export_lock_workspace_mismatch_with_frozen() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "foo" + version = "0.1.0" + requires-python = ">=3.12" + "#, + )?; + + let pyproject_toml = context.temp_dir.child("uv.lock"); + pyproject_toml.write_str( + r#" + version = 1 + revision = 3 + requires-python = ">=3.12" + + [[package]] + name = "bar" + version = "0.1.0" + source = { virtual = "." } + dependencies = [] + "#, + )?; + + uv_snapshot!(context.filters(), context.export().arg("--frozen"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: The lockfile at `uv.lock` needs to be updated, but `--frozen` was provided: Missing workspace member `foo`. To update the lockfile, run `uv lock`. + "); + + Ok(()) +}