Don't panic in uv export --frozen when the lockfile is outdated (#16407)

Provide a good error message when the discovered workspace members
mismatch with the locked workspace members in `uv export --frozen`,
instead of panicking.

Fixes #16406
This commit is contained in:
konsti 2025-10-23 22:07:14 +02:00 committed by GitHub
parent 940a3f63ce
commit 491293362f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 71 additions and 8 deletions

View file

@ -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<Self, LockError> {
let size_guess = target.lock().packages.len();
let mut graph = Graph::<Node<'lock>, 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::<Vec<_>>();
Self(nodes)
Ok(Self(nodes))
}
}

View file

@ -631,7 +631,7 @@ impl<'lock> PylockToml {
dev,
annotate,
install_options,
);
)?;
// Sort the nodes.
nodes.sort_unstable_by_key(|node| &node.package.id);

View file

@ -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| {

View file

@ -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<Option<&Package>, String> {
pub fn find_by_name(&self, name: &PackageName) -> Result<Option<&Package>, String> {
let mut found_dist = None;
for dist in &self.packages {
if &dist.id.name == name {

View file

@ -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) => {

View file

@ -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`."
)]

View file

@ -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(())
}