diff --git a/crates/uv-resolver/src/lock/mod.rs b/crates/uv-resolver/src/lock/mod.rs index 36d656975eeaa..8178a0a8dc95e 100644 --- a/crates/uv-resolver/src/lock/mod.rs +++ b/crates/uv-resolver/src/lock/mod.rs @@ -12,7 +12,7 @@ use jiff::Timestamp; use owo_colors::OwoColorize; use petgraph::graph::NodeIndex; use petgraph::visit::EdgeRef; -use rustc_hash::{FxHashMap, FxHashSet}; +use rustc_hash::{FxBuildHasher, FxHashMap, FxHashSet}; use serde::Serializer; use toml_edit::{Array, ArrayOfTables, InlineTable, Item, Table, Value, value}; use tracing::debug; @@ -1660,8 +1660,90 @@ impl Lock { return Ok(SatisfiesResult::MissingRoot(root_name.clone())); }; - // Add the base package. - queue.push_back(root); + if seen.insert(&root.id) { + queue.push_back(root); + } + } + + // Add requirements attached directly to the target root (e.g., PEP 723 requirements or + // dependency groups in workspaces without a `[project]` table). + let root_requirements = requirements + .iter() + .chain(dependency_groups.values().flatten()) + .collect::>(); + + for requirement in &root_requirements { + if let RequirementSource::Registry { + index: Some(index), .. + } = &requirement.source + { + match &index.url { + IndexUrl::Pypi(_) | IndexUrl::Url(_) => { + if let Some(remotes) = remotes.as_mut() { + remotes.insert(UrlString::from( + index.url().without_credentials().as_ref(), + )); + } + } + IndexUrl::Path(url) => { + if let Some(locals) = locals.as_mut() { + if let Some(path) = url.to_file_path().ok().and_then(|path| { + relative_to(&path, root) + .or_else(|_| std::path::absolute(path)) + .ok() + }) { + locals.insert(path.into_boxed_path()); + } + } + } + } + } + } + + if !root_requirements.is_empty() { + let names = root_requirements + .iter() + .map(|requirement| &requirement.name) + .collect::>(); + + let by_name: FxHashMap<_, Vec<_>> = self.packages.iter().fold( + FxHashMap::with_capacity_and_hasher(self.packages.len(), FxBuildHasher), + |mut by_name, package| { + if names.contains(&package.id.name) { + by_name.entry(&package.id.name).or_default().push(package); + } + by_name + }, + ); + + for requirement in root_requirements { + for package in by_name.get(&requirement.name).into_iter().flatten() { + if !package.id.source.is_source_tree() { + continue; + } + + let marker = if package.fork_markers.is_empty() { + requirement.marker + } else { + let mut combined = MarkerTree::FALSE; + for fork_marker in &package.fork_markers { + combined.or(fork_marker.pep508()); + } + combined.and(requirement.marker); + combined + }; + if marker.is_false() { + continue; + } + if !marker.evaluate(markers, &[]) { + continue; + } + + if seen.insert(&package.id) { + queue.push_back(package); + } + } + } } while let Some(package) = queue.pop_front() { diff --git a/crates/uv/tests/it/lock.rs b/crates/uv/tests/it/lock.rs index b956fdb6ddc23..4891e2845042d 100644 --- a/crates/uv/tests/it/lock.rs +++ b/crates/uv/tests/it/lock.rs @@ -28818,6 +28818,182 @@ fn lock_script_path() -> Result<()> { Ok(()) } +/// Repro for: +/// +/// `uv lock --script` should invalidate a script lockfile when a local editable dependency's +/// `pyproject.toml` changes. +#[test] +fn lock_script_editable_path_dependency_change() -> Result<()> { + let context = uv_test::test_context!("3.12"); + + let script = context.temp_dir.child("script.py"); + script.write_str(indoc! { r#" + # /// script + # requires-python = ">=3.11" + # dependencies = [ + # "child", + # ] + # + # [tool.uv.sources] + # child = { path = "child", editable = true } + # /// + + import child + "# + })?; + + let child = context.temp_dir.child("child"); + fs_err::create_dir_all(&child)?; + + let child_pyproject_toml = child.child("pyproject.toml"); + child_pyproject_toml.write_str( + r#" + [project] + name = "child" + version = "0.1.0" + requires-python = ">=3.11" + dependencies = ["iniconfig"] + + [build-system] + requires = ["uv_build>=0.7,<10000"] + build-backend = "uv_build" + "#, + )?; + + context + .lock() + .arg("--script") + .arg("script.py") + .assert() + .success(); + + let lock = context.read("script.py.lock"); + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, + @r#" + version = 1 + revision = 3 + requires-python = ">=3.11" + + [options] + exclude-newer = "2024-03-25T00:00:00Z" + + [manifest] + requirements = [{ name = "child", editable = "child" }] + + [[package]] + name = "child" + version = "0.1.0" + source = { editable = "child" } + dependencies = [ + { name = "iniconfig" }, + ] + + [package.metadata] + requires-dist = [{ name = "iniconfig" }] + + [[package]] + name = "iniconfig" + version = "2.0.0" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646, upload-time = "2023-01-07T11:08:11.254Z" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892, upload-time = "2023-01-07T11:08:09.864Z" }, + ] + "# + ); + }); + + context + .lock() + .arg("--script") + .arg("script.py") + .arg("--locked") + .assert() + .success(); + + // Update the editable dependency's transitive requirements. + child_pyproject_toml.write_str( + r#" + [project] + name = "child" + version = "0.1.0" + requires-python = ">=3.11" + dependencies = ["sniffio"] + + [build-system] + requires = ["uv_build>=0.7,<10000"] + build-backend = "uv_build" + "#, + )?; + + // Expected behavior: this should fail because the script lockfile is stale. + let output = context + .lock() + .arg("--script") + .arg("script.py") + .arg("--locked") + .output()?; + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + !output.status.success(), + "expected `uv lock --script script.py --locked` to fail after editable dependency changes, but it succeeded\n{stderr}" + ); + assert!(stderr.contains("needs to be updated"), "{stderr}"); + + // Expected behavior: re-locking should pick up the new transitive dependency. + context + .lock() + .arg("--script") + .arg("script.py") + .assert() + .success(); + let lock = context.read("script.py.lock"); + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, + @r#" + version = 1 + revision = 3 + requires-python = ">=3.11" + + [options] + exclude-newer = "2024-03-25T00:00:00Z" + + [manifest] + requirements = [{ name = "child", editable = "child" }] + + [[package]] + name = "child" + version = "0.1.0" + source = { editable = "child" } + dependencies = [ + { name = "sniffio" }, + ] + + [package.metadata] + requires-dist = [{ name = "sniffio" }] + + [[package]] + name = "sniffio" + version = "1.3.1" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, + ] + "# + ); + }); + + Ok(()) +} + /// `uv lock --script` should add a PEP 723 tag, if it doesn't exist already. #[test] fn lock_script_initialize() -> Result<()> {