Skip to content
Draft
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
18 changes: 18 additions & 0 deletions crates/uv-resolver/src/lock/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1858,6 +1858,22 @@ impl Lock {
.collect::<BTreeSet<_>>()
});

// Validate every locked registry package against the current build policy. Immutable
// packages do not recurse through their dependencies below, so checking only the queue
// would miss a forbidden artifact behind an immutable parent. Accept incompatible wheels
// here to preserve universal lock validation; installation checks compatibility later.
for package in &self.packages {
if matches!(&package.id.source, Source::Registry(..))
&& (build_options.no_binary_package(&package.id.name)
|| build_options.no_build_package(&package.id.name))
&& package
.to_dist(root, TagPolicy::Preferred(tags), build_options, markers)
.is_err()
{
return Ok(SatisfiesResult::MismatchedBuildOptions(&package.id.name));
}
}

// Add the workspace packages to the queue.
for root_name in packages.keys() {
let root = self
Expand Down Expand Up @@ -2461,6 +2477,8 @@ pub enum SatisfiesResult<'lock> {
),
/// The lockfile uses different static metadata.
MismatchedStaticMetadata(BTreeSet<StaticMetadata>, &'lock BTreeSet<StaticMetadata>),
/// A locked registry package has no artifact permitted by the current build policy.
MismatchedBuildOptions(&'lock PackageName),
/// The lockfile is missing a workspace member.
MissingRoot(PackageName),
/// The lockfile referenced a remote index that was not provided
Expand Down
6 changes: 6 additions & 0 deletions crates/uv/src/commands/project/lock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1433,6 +1433,12 @@ impl ValidatedLock {
);
Ok(Self::Preferable(lock))
}
SatisfiesResult::MismatchedBuildOptions(name) => {
debug!(
"Resolving despite existing lockfile due to incompatible build options for: `{name}`"
);
Ok(Self::Preferable(lock))
}
SatisfiesResult::MissingRoot(name) => {
debug!("Resolving despite existing lockfile due to missing root package: `{name}`");
Ok(Self::Preferable(lock))
Expand Down
128 changes: 128 additions & 0 deletions crates/uv/tests/lock/lock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ use anyhow::Result;
use assert_cmd::assert::OutputAssertExt;
use assert_fs::prelude::*;
use indoc::{formatdoc, indoc};
#[cfg(feature = "test-universal")]
use insta::allow_duplicates;
use insta::assert_snapshot;
#[cfg(feature = "test-universal")]
use serde_json::json;
Expand Down Expand Up @@ -28438,6 +28440,132 @@ fn lock_no_build_static_metadata() -> Result<()> {
Ok(())
}

/// Changing the build policy must invalidate a lock that only contains a forbidden artifact.
#[cfg(feature = "test-universal")]
#[test]
fn lock_build_policy_invalidates_unusable_artifacts() -> Result<()> {
allow_duplicates! {
for (scenario, flag) in [
("wheels/only-wheels.toml", "--no-binary-package"),
("wheels/no-wheels.toml", "--no-build-package"),
] {
let server = PackseServer::new(scenario);
let context = uv_test::test_context!("3.12");

context.temp_dir.child("pyproject.toml").write_str(
r#"
[project]
name = "project"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["a==1.0.0"]
"#,
)?;

context
.lock()
.arg("--index-url")
.arg(server.index_url())
.assert()
.success();

let mut command = context.lock();
command
.arg("--index-url")
.arg(server.index_url())
.arg("--locked")
.arg(flag)
.arg("a");

if flag == "--no-binary-package" {
uv_snapshot!(context.filters(), command, @"
exit_code: 1 (failure)
----- stderr -----
× No solution found when resolving dependencies:
╰─▶ Because a==1.0.0 has no source distribution and your project depends on a==1.0.0, we can conclude that your project's requirements are unsatisfiable.

hint: A source distribution is required for `a` because using pre-built wheels is disabled for `a` (i.e., with `--no-binary-package a`)
");
} else {
uv_snapshot!(context.filters(), command, @"
exit_code: 1 (failure)
----- stderr -----
× No solution found when resolving dependencies:
╰─▶ Because a==1.0.0 has no usable wheels and your project depends on a==1.0.0, we can conclude that your project's requirements are unsatisfiable.

hint: Wheels are required for `a` because building from source is disabled for `a` (i.e., with `--no-build-package a`)
");
}
}
Ok::<(), anyhow::Error>(())
}?;

// A wheel for another platform remains valid during universal lock validation. Installation
// will enforce compatibility if that package is active on the current platform.
let server = PackseServer::new("wheels/requires-python-subset.toml");
let context = uv_test::test_context!("3.12");
context.temp_dir.child("pyproject.toml").write_str(
r#"
[project]
name = "project"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["win-only; sys_platform == 'win32'"]

[tool.uv]
required-environments = ["sys_platform == 'linux'", "sys_platform == 'win32'"]
"#,
)?;
context
.lock()
.arg("--index-url")
.arg(server.index_url())
.assert()
.success();
context
.lock()
.arg("--index-url")
.arg(server.index_url())
.arg("--locked")
.arg("--no-build-package")
.arg("win-only")
.assert()
.success();

// The same checks must apply behind an immutable registry parent.
let server = PackseServer::new("wheels/transitive-artifacts.toml");
let context = uv_test::test_context!("3.12");
context.temp_dir.child("pyproject.toml").write_str(
r#"
[project]
name = "project"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["a==1.0.0"]
"#,
)?;
context
.lock()
.arg("--index-url")
.arg(server.index_url())
.assert()
.success();

for (flag, package) in [("--no-binary-package", "b"), ("--no-build-package", "c")] {
context
.lock()
.arg("--index-url")
.arg(server.index_url())
.arg("--locked")
.arg(flag)
.arg(package)
.assert()
.failure();
}

Ok(())
}

#[cfg(feature = "test-universal")]
#[test]
fn lock_no_build_dynamic_metadata() -> Result<()> {
Expand Down
81 changes: 65 additions & 16 deletions crates/uv/tests/sync/sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7614,11 +7614,52 @@ fn no_binary_error() -> Result<()> {

context.lock().assert().success();

uv_snapshot!(context.filters(), context.sync().arg("--no-binary-package").arg("odrive"), @"
exit_code: 2 (failure)
let mut filters = context.filters();
filters.push((
r"(?m)^ \+ (pexpect==4\.9\.0|ptyprocess==0\.7\.0|pywin32==306)\n",
"",
));

uv_snapshot!(filters, context.sync().arg("--no-binary-package").arg("odrive"), @"
exit_code: 0 (success)
----- stderr -----
Resolved 31 packages in [TIME]
error: Distribution `odrive==0.6.8 @ registry+https://pypi.org/simple` can't be installed because it is marked as `--no-binary` but has no source distribution
Resolved 39 packages in [TIME]
Prepared 35 packages in [TIME]
Installed 36 packages in [TIME]
+ asttokens==2.4.1
+ certifi==2024.2.2
+ charset-normalizer==3.3.2
+ contourpy==1.2.0
+ cycler==0.12.1
+ decorator==5.1.1
+ executing==2.0.1
+ fonttools==4.50.0
+ idna==3.6
+ intelhex==2.3.0
+ ipython==8.22.2
+ jedi==0.19.1
+ kiwisolver==1.4.5
+ matplotlib==3.8.3
+ matplotlib-inline==0.1.6
+ monotonic==1.6
+ numpy==1.26.4
+ odrive==0.5.4
+ packaging==24.0
+ parso==0.8.3
+ pillow==10.2.0
+ prompt-toolkit==3.0.43
+ pure-eval==0.2.2
+ pygments==2.17.2
+ pyparsing==3.1.2
+ python-dateutil==2.9.0.post0
+ pyusb==1.2.1
+ requests==2.31.0
+ setuptools==69.2.0
+ six==1.16.0
+ stack-data==0.6.3
+ traitlets==5.14.2
+ urllib3==2.2.1
+ wcwidth==0.2.13
");

assert!(context.temp_dir.child("uv.lock").exists());
Expand Down Expand Up @@ -7691,31 +7732,39 @@ fn no_build_error() -> Result<()> {
.success();

uv_snapshot!(context.filters(), context.sync().arg("--index-url").arg(server.index_url()).arg("--no-build-package").arg("a"), @"
exit_code: 2 (failure)
exit_code: 1 (failure)
----- stderr -----
Resolved 2 packages in [TIME]
error: Distribution `a==1.0.0 @ registry+http://[LOCALHOST]/simple/` can't be installed because it is marked as `--no-build` but has no binary distribution
× No solution found when resolving dependencies:
╰─▶ Because a==1.0.0 has no usable wheels and your project depends on a==1.0.0, we can conclude that your project's requirements are unsatisfiable.

hint: Wheels are required for `a` because building from source is disabled for `a` (i.e., with `--no-build-package a`)
");

uv_snapshot!(context.filters(), context.sync().arg("--index-url").arg(server.index_url()).arg("--no-build"), @"
exit_code: 2 (failure)
exit_code: 1 (failure)
----- stderr -----
Resolved 2 packages in [TIME]
error: Distribution `a==1.0.0 @ registry+http://[LOCALHOST]/simple/` can't be installed because it is marked as `--no-build` but has no binary distribution
× No solution found when resolving dependencies:
╰─▶ Because a==1.0.0 has no usable wheels and your project depends on a==1.0.0, we can conclude that your project's requirements are unsatisfiable.

hint: Wheels are required for `a` because building from source is disabled for all packages (i.e., with `--no-build`)
");

uv_snapshot!(context.filters(), context.sync().arg("--index-url").arg(server.index_url()).arg("--reinstall").env(EnvVars::UV_NO_BUILD, "1"), @"
exit_code: 2 (failure)
exit_code: 1 (failure)
----- stderr -----
Resolved 2 packages in [TIME]
error: Distribution `a==1.0.0 @ registry+http://[LOCALHOST]/simple/` can't be installed because it is marked as `--no-build` but has no binary distribution
× No solution found when resolving dependencies:
╰─▶ Because a==1.0.0 has no usable wheels and your project depends on a==1.0.0, we can conclude that your project's requirements are unsatisfiable.

hint: Wheels are required for `a` because building from source is disabled for all packages (i.e., with `--no-build`)
");

uv_snapshot!(context.filters(), context.sync().arg("--index-url").arg(server.index_url()).arg("--reinstall").env(EnvVars::UV_NO_BUILD_PACKAGE, "a"), @"
exit_code: 2 (failure)
exit_code: 1 (failure)
----- stderr -----
Resolved 2 packages in [TIME]
error: Distribution `a==1.0.0 @ registry+http://[LOCALHOST]/simple/` can't be installed because it is marked as `--no-build` but has no binary distribution
× No solution found when resolving dependencies:
╰─▶ Because a==1.0.0 has no usable wheels and your project depends on a==1.0.0, we can conclude that your project's requirements are unsatisfiable.

hint: Wheels are required for `a` because building from source is disabled for `a` (i.e., with `--no-build-package a`)
");

uv_snapshot!(context.filters(), context.sync().arg("--index-url").arg(server.index_url()).arg("--reinstall").env(EnvVars::UV_NO_BUILD, "a"), @"
Expand Down
20 changes: 20 additions & 0 deletions test/scenarios/wheels/transitive-artifacts.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
name = "transitive-artifacts"
description = "An immutable parent has transitive wheel-only and source-only dependencies."

[root]
requires = ["a"]

[expected]
satisfiable = true

[testgen]
disable = true

[packages.a.versions."1.0.0"]
requires = ["b==1.0.0", "c==1.0.0"]

[packages.b.versions."1.0.0"]
sdist = false

[packages.c.versions."1.0.0"]
wheel = false
Loading