Respect build options (--no-binary et al) in pylock.toml (#13134)

## Summary

Closes #13133.
This commit is contained in:
Charlie Marsh 2025-04-27 12:28:30 -04:00 committed by GitHub
parent 17b4ebed8e
commit 78756de027
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 266 additions and 16 deletions

View file

@ -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<Resolution, PylockTomlError> {
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<bool, PylockTomlError> {
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::<Path>::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.

View file

@ -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::<PylockToml>(&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)

View file

@ -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::<PylockToml>(&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)

View file

@ -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)
"
);