diff --git a/crates/uv-resolver/src/lock/export/pylock_toml.rs b/crates/uv-resolver/src/lock/export/pylock_toml.rs index 4837dee8c4fce..9a006585acefa 100644 --- a/crates/uv-resolver/src/lock/export/pylock_toml.rs +++ b/crates/uv-resolver/src/lock/export/pylock_toml.rs @@ -12,7 +12,9 @@ use toml_edit::{value, Array, ArrayOfTables, Item, Table}; use url::Url; use uv_cache_key::RepositoryUrl; -use uv_configuration::{DependencyGroupsWithDefaults, ExtrasSpecification, InstallOptions}; +use uv_configuration::{ + BuildOptions, DependencyGroupsWithDefaults, ExtrasSpecification, InstallOptions, +}; use uv_distribution_filename::{ BuildTag, DistExtension, ExtensionError, SourceDistExtension, SourceDistFilename, SourceDistFilenameError, WheelFilename, WheelFilenameError, @@ -80,6 +82,18 @@ pub enum PylockTomlError { PathToUrl, #[error("Failed to convert URL to path")] UrlToPath, + #[error("Package `{0}` can't be installed because it doesn't have a source distribution or wheel for the current platform")] + NeitherSourceDistNorWheel(PackageName), + #[error("Package `{0}` can't be installed because it is marked as both `--no-binary` and `--no-build`")] + NoBinaryNoBuild(PackageName), + #[error("Package `{0}` can't be installed because it is marked as `--no-binary` but has no source distribution")] + NoBinary(PackageName), + #[error("Package `{0}` can't be installed because it is marked as `--no-build` but has no binary distribution")] + NoBuild(PackageName), + #[error("Package `{0}` can't be installed because the binary distribution is incompatible with the current platform")] + IncompatibleWheelOnly(PackageName), + #[error("Package `{0}` can't be installed because it is marked as `--no-binary` but is itself a binary distribution")] + NoBinaryWheelOnly(PackageName), #[error(transparent)] WheelFilename(#[from] WheelFilenameError), #[error(transparent)] @@ -857,6 +871,7 @@ impl<'lock> PylockToml { install_path: &Path, markers: &MarkerEnvironment, tags: &Tags, + build_options: &BuildOptions, ) -> Result { let mut graph = petgraph::graph::DiGraph::with_capacity(self.packages.len(), self.packages.len()); @@ -914,8 +929,19 @@ impl<'lock> PylockToml { _ => {} } + let no_binary = build_options.no_binary_package(&package.name); + let no_build = build_options.no_build_package(&package.name); + let is_wheel = package + .archive + .as_ref() + .map(|archive| archive.is_wheel(&package.name)) + .transpose()? + .unwrap_or_default(); + // Search for a matching wheel. - let dist = if let Some(best_wheel) = package.find_best_wheel(tags) { + let dist = if let Some(best_wheel) = + package.find_best_wheel(tags).filter(|_| !no_binary) + { let hashes = HashDigests::from(best_wheel.hashes.clone()); let built_dist = Dist::Built(BuiltDist::Registry(RegistryBuiltDist { wheels: vec![best_wheel.to_registry_wheel( @@ -935,7 +961,7 @@ impl<'lock> PylockToml { hashes, install: true, } - } else if let Some(sdist) = package.sdist.as_ref() { + } else if let Some(sdist) = package.sdist.as_ref().filter(|_| !no_build) { let hashes = HashDigests::from(sdist.hashes.clone()); let sdist = Dist::Source(SourceDist::Registry(sdist.to_sdist( install_path, @@ -952,7 +978,7 @@ impl<'lock> PylockToml { hashes, install: true, } - } else if let Some(sdist) = package.directory.as_ref() { + } else if let Some(sdist) = package.directory.as_ref().filter(|_| !no_build) { let hashes = HashDigests::empty(); let sdist = Dist::Source(SourceDist::Directory( sdist.to_sdist(install_path, &package.name)?, @@ -966,7 +992,7 @@ impl<'lock> PylockToml { hashes, install: true, } - } else if let Some(sdist) = package.vcs.as_ref() { + } else if let Some(sdist) = package.vcs.as_ref().filter(|_| !no_build) { let hashes = HashDigests::empty(); let sdist = Dist::Source(SourceDist::Git( sdist.to_sdist(install_path, &package.name)?, @@ -980,7 +1006,12 @@ impl<'lock> PylockToml { hashes, install: true, } - } else if let Some(dist) = package.archive.as_ref() { + } else if let Some(dist) = + package + .archive + .as_ref() + .filter(|_| if is_wheel { !no_binary } else { !no_build }) + { let hashes = HashDigests::from(dist.hashes.clone()); let dist = dist.to_dist(install_path, &package.name, package.version.as_ref())?; let dist = ResolvedDist::Installable { @@ -993,13 +1024,20 @@ impl<'lock> PylockToml { install: true, } } else { - // This is only reachable if the package contains a `wheels` entry (and nothing - // else), but there are no wheels available for the current environment. (If the - // package doesn't contain _any_ of `wheels`, `sdist`, etc., then we error in the - // match above.) - // - // TODO(charlie): Include a hint, like in `uv.lock`. - return Err(PylockTomlError::MissingWheel(package.name.clone())); + return match (no_binary, no_build) { + (true, true) => Err(PylockTomlError::NoBinaryNoBuild(package.name.clone())), + (true, false) if is_wheel => { + Err(PylockTomlError::NoBinaryWheelOnly(package.name.clone())) + } + (true, false) => Err(PylockTomlError::NoBinary(package.name.clone())), + (false, true) => Err(PylockTomlError::NoBuild(package.name.clone())), + (false, false) if is_wheel => { + Err(PylockTomlError::IncompatibleWheelOnly(package.name.clone())) + } + (false, false) => Err(PylockTomlError::NeitherSourceDistNorWheel( + package.name.clone(), + )), + }; }; let index = graph.add_node(dist); @@ -1441,6 +1479,31 @@ impl PylockTomlArchive { return Err(PylockTomlError::ArchiveMissingPathUrl(name.clone())); } } + + /// Returns `true` if the [`PylockTomlArchive`] is a wheel. + fn is_wheel(&self, name: &PackageName) -> Result { + if let Some(url) = self.url.as_ref() { + let filename = url + .filename() + .map_err(|_| PylockTomlError::UrlMissingFilename(url.clone()))?; + + let ext = DistExtension::from_path(filename.as_ref())?; + Ok(matches!(ext, DistExtension::Wheel)) + } else if let Some(path) = self.path.as_ref() { + let filename = path + .as_ref() + .file_name() + .and_then(OsStr::to_str) + .ok_or_else(|| { + PylockTomlError::PathMissingFilename(Box::::from(path.clone())) + })?; + + let ext = DistExtension::from_path(filename)?; + Ok(matches!(ext, DistExtension::Wheel)) + } else { + return Err(PylockTomlError::ArchiveMissingPathUrl(name.clone())); + } + } } /// Convert a Jiff timestamp to a TOML datetime. diff --git a/crates/uv/src/commands/pip/install.rs b/crates/uv/src/commands/pip/install.rs index 1933e901f8074..50c23b67f8acf 100644 --- a/crates/uv/src/commands/pip/install.rs +++ b/crates/uv/src/commands/pip/install.rs @@ -441,7 +441,8 @@ pub(crate) async fn pip_install( let content = fs_err::tokio::read_to_string(&pylock).await?; let lock = toml::from_str::(&content)?; - let resolution = lock.to_resolution(install_path, marker_env.markers(), &tags)?; + let resolution = + lock.to_resolution(install_path, marker_env.markers(), &tags, &build_options)?; let hasher = HashStrategy::from_resolution(&resolution, HashCheckingMode::Verify)?; (resolution, hasher) diff --git a/crates/uv/src/commands/pip/sync.rs b/crates/uv/src/commands/pip/sync.rs index 7ddda26925639..75d2e7b5ad95c 100644 --- a/crates/uv/src/commands/pip/sync.rs +++ b/crates/uv/src/commands/pip/sync.rs @@ -376,7 +376,8 @@ pub(crate) async fn pip_sync( let content = fs_err::tokio::read_to_string(&pylock).await?; let lock = toml::from_str::(&content)?; - let resolution = lock.to_resolution(install_path, marker_env.markers(), &tags)?; + let resolution = + lock.to_resolution(install_path, marker_env.markers(), &tags, &build_options)?; let hasher = HashStrategy::from_resolution(&resolution, HashCheckingMode::Verify)?; (resolution, hasher) diff --git a/crates/uv/tests/it/pip_sync.rs b/crates/uv/tests/it/pip_sync.rs index 2d7893d46d2bc..f577612c37b83 100644 --- a/crates/uv/tests/it/pip_sync.rs +++ b/crates/uv/tests/it/pip_sync.rs @@ -5891,7 +5891,192 @@ fn pep_751_wheel_only() -> Result<()> { ----- stdout ----- ----- stderr ----- - error: Package `torch` does not include a compatible wheel for the current platform + error: Package `torch` can't be installed because it doesn't have a source distribution or wheel for the current platform + " + ); + + Ok(()) +} + +/// Respect `--no-binary` et al when installing from a `pylock.toml`. +#[test] +fn pep_751_build_options() -> Result<()> { + let context = TestContext::new("3.12").with_exclude_newer("2025-01-29T00:00:00Z"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["anyio"] + "#, + )?; + + context + .export() + .arg("-o") + .arg("pylock.toml") + .assert() + .success(); + + uv_snapshot!(context.filters(), context.pip_sync() + .arg("--preview") + .arg("pylock.toml") + .arg("--no-binary") + .arg("anyio"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Prepared 4 packages in [TIME] + Installed 4 packages in [TIME] + + anyio==4.8.0 + + idna==3.10 + + sniffio==1.3.1 + + typing-extensions==4.12.2 + " + ); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["odrive"] + "#, + )?; + + context + .export() + .arg("-o") + .arg("pylock.toml") + .assert() + .success(); + + uv_snapshot!(context.filters(), context.pip_sync() + .arg("--preview") + .arg("pylock.toml") + .arg("--no-binary") + .arg("odrive"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Package `odrive` can't be installed because it is marked as `--no-binary` but has no source distribution + " + ); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["source-distribution"] + "#, + )?; + + context + .export() + .arg("-o") + .arg("pylock.toml") + .assert() + .success(); + + uv_snapshot!(context.filters(), context.pip_sync() + .arg("--preview") + .arg("pylock.toml") + .arg("--only-binary") + .arg("source-distribution"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Package `source-distribution` can't be installed because it is marked as `--no-build` but has no binary distribution + " + ); + + uv_snapshot!(context.filters(), context.pip_sync() + .arg("--preview") + .arg("pylock.toml") + .arg("--no-binary") + .arg("source-distribution"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Prepared 1 package in [TIME] + Uninstalled 4 packages in [TIME] + Installed 1 package in [TIME] + - anyio==4.8.0 + - idna==3.10 + - sniffio==1.3.1 + + source-distribution==0.0.3 + - typing-extensions==4.12.2 + " + ); + + Ok(()) +} + +#[test] +fn pep_751_direct_url_tags() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["MarkupSafe @ https://files.pythonhosted.org/packages/6b/b0/18f76bba336fa5aecf79d45dcd6c806c280ec44538b3c13671d49099fdd0/MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl"] + "#, + )?; + + context + .export() + .arg("-o") + .arg("pylock.toml") + .assert() + .success(); + + uv_snapshot!(context.filters(), context.pip_sync() + .arg("--preview") + .arg("pylock.toml") + .arg("--python-platform") + .arg("linux"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Failed to determine installation plan + Caused by: A URL dependency is incompatible with the current platform: https://files.pythonhosted.org/packages/6b/b0/18f76bba336fa5aecf79d45dcd6c806c280ec44538b3c13671d49099fdd0/MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl + " + ); + + uv_snapshot!(context.filters(), context.pip_sync() + .arg("--preview") + .arg("pylock.toml") + .arg("--python-platform") + .arg("macos"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Installed 1 package in [TIME] + + markupsafe==3.0.2 (from https://files.pythonhosted.org/packages/6b/b0/18f76bba336fa5aecf79d45dcd6c806c280ec44538b3c13671d49099fdd0/MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl) " );