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
88 changes: 85 additions & 3 deletions crates/uv-resolver/src/lock/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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::<Vec<_>>();

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::<FxHashSet<_>>();

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() {
Expand Down
176 changes: 176 additions & 0 deletions crates/uv/tests/it/lock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28818,6 +28818,182 @@ fn lock_script_path() -> Result<()> {
Ok(())
}

/// Repro for: <https://github.com/astral-sh/uv/issues/18312>
///
/// `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<()> {
Expand Down
Loading