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
89 changes: 76 additions & 13 deletions crates/uv-resolver/src/lock/export/pylock_toml.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)]
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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(
Expand All @@ -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,
Expand All @@ -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)?,
Expand All @@ -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)?,
Expand All @@ -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 {
Expand All @@ -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);
Expand Down Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion crates/uv/src/commands/pip/install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion crates/uv/src/commands/pip/sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
187 changes: 186 additions & 1 deletion crates/uv/tests/it/pip_sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
"
);

Expand Down
Loading