diff --git a/crates/uv-distribution/src/error.rs b/crates/uv-distribution/src/error.rs index b9f85c98929ea..5e9b265cfd642 100644 --- a/crates/uv-distribution/src/error.rs +++ b/crates/uv-distribution/src/error.rs @@ -37,6 +37,8 @@ impl fmt::Display for PythonVersion { pub enum Error { #[error("Building source distributions is disabled")] NoBuild, + #[error("Building source distributions for `{0}` is disabled")] + NoBuildPackage(PackageName), // Network error #[error(transparent)] diff --git a/crates/uv-distribution/src/source/mod.rs b/crates/uv-distribution/src/source/mod.rs index 49d9da75edd00..80df8bac38a86 100644 --- a/crates/uv-distribution/src/source/mod.rs +++ b/crates/uv-distribution/src/source/mod.rs @@ -2616,6 +2616,22 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> { ) -> Result, Error> { debug!("Preparing metadata for: {source}"); + let source_name = source.name(); + if self + .build_context + .build_options() + .no_build_requirement(source_name) + // Editable requirements without a known name need metadata to apply + // package-specific build settings; named editables must respect `--no-build`. + && !(source_name.is_none() && source.is_editable()) + { + return if let Some(name) = source_name { + Err(Error::NoBuildPackage(name.clone())) + } else { + Err(Error::NoBuild) + }; + } + // Ensure that the _installed_ Python version is compatible with the `requires-python` // specifier. if let Some(requires_python) = source.requires_python() { diff --git a/crates/uv-resolver/src/lock/mod.rs b/crates/uv-resolver/src/lock/mod.rs index aa138a9ad3143..893e75074e946 100644 --- a/crates/uv-resolver/src/lock/mod.rs +++ b/crates/uv-resolver/src/lock/mod.rs @@ -23,7 +23,7 @@ use uv_configuration::{ BuildOptions, Constraints, DependencyGroupsWithDefaults, ExtrasSpecificationWithDefaults, InstallTarget, }; -use uv_distribution::{DistributionDatabase, FlatRequiresDist}; +use uv_distribution::{DistributionDatabase, FlatRequiresDist, RequiresDist}; use uv_distribution_filename::{ BuildTag, DistExtension, ExtensionError, SourceDistExtension, WheelFilename, }; @@ -1627,6 +1627,7 @@ impl Lock { indexes: Option<&IndexLocations>, tags: &Tags, markers: &MarkerEnvironment, + build_options: &BuildOptions, hasher: &HashStrategy, index: &InMemoryIndex, database: &DistributionDatabase<'_, Context>, @@ -1986,84 +1987,135 @@ impl Lock { } if let Some(version) = package.id.version.as_ref() { - // For a non-dynamic package, fetch the metadata from the distribution database. - let HashedDist { dist, .. } = package.to_dist( - root, - TagPolicy::Preferred(tags), - &BuildOptions::default(), - markers, - )?; - - let metadata = { - let id = dist.distribution_id(); - if let Some(archive) = - index - .distributions() - .get(&id) - .as_deref() - .and_then(|response| { - if let MetadataResponse::Found(archive, ..) = response { - Some(archive) - } else { - None - } - }) - { - // If the metadata is already in the index, return it. - archive.metadata.clone() - } else { - // Run the PEP 517 build process to extract metadata from the source distribution. - let archive = database - .get_or_build_wheel_metadata(&dist, hasher.get(&dist)) - .await - .map_err(|err| LockErrorKind::Resolution { - id: package.id.clone(), - err, - })?; + // If the distribution is a source tree, attempt to validate it from statically + // available `pyproject.toml` metadata before converting it to an installable + // distribution. This avoids requiring build permission for static local packages. + let statically_satisfied = if let Some(source_tree) = + package.id.source.as_source_tree() + && let Some(SourceTreeRequiresDist { + version: static_version, + metadata, + }) = Self::source_tree_requires_dist(source_tree, root, package, database) + .await? + { + // If this local package has become dynamic, the locked package should + // no longer contain a version. + if metadata.dynamic { + return Ok(SatisfiesResult::MismatchedDynamic(&package.id.name, false)); + } - let metadata = archive.metadata.clone(); + if let Some(static_version) = static_version { + // Validate the static `version` metadata. + if static_version != *version { + return Ok(SatisfiesResult::MismatchedVersion( + &package.id.name, + version.clone(), + Some(static_version), + )); + } - // Insert the metadata into the index. - index - .distributions() - .done(id, Arc::new(MetadataResponse::Found(archive))); + // Validate the static `provides-extras` metadata. + match self.satisfies_provides_extra(metadata.provides_extra, package) { + SatisfiesResult::Satisfied => {} + result => return Ok(result), + } - metadata + // Validate that the static requirements are unchanged. + match self.satisfies_requires_dist( + metadata.requires_dist, + metadata.dependency_groups, + package, + root, + )? { + SatisfiesResult::Satisfied => true, + result => return Ok(result), + } + } else { + false } + } else { + false }; - // If this is a local package, validate that it hasn't become dynamic (in which - // case, we'd expect the version to be omitted). - if package.id.source.is_source_tree() { - if metadata.dynamic { + if !statically_satisfied { + // For a non-dynamic package without usable static metadata, fetch the metadata + // from the distribution database. + let HashedDist { dist, .. } = package.to_dist( + root, + TagPolicy::Preferred(tags), + build_options, + markers, + )?; + + let metadata = { + let id = dist.distribution_id(); + if let Some(archive) = + index + .distributions() + .get(&id) + .as_deref() + .and_then(|response| { + if let MetadataResponse::Found(archive, ..) = response { + Some(archive) + } else { + None + } + }) + { + // If the metadata is already in the index, return it. + archive.metadata.clone() + } else { + // Run the PEP 517 build process to extract metadata from the source distribution. + let archive = database + .get_or_build_wheel_metadata(&dist, hasher.get(&dist)) + .await + .map_err(|err| LockErrorKind::Resolution { + id: package.id.clone(), + err, + })?; + + let metadata = archive.metadata.clone(); + + // Insert the metadata into the index. + index + .distributions() + .done(id, Arc::new(MetadataResponse::Found(archive))); + + metadata + } + }; + + // If this is a local package, validate that it hasn't become dynamic (in which + // case, we'd expect the version to be omitted). + if package.id.source.is_source_tree() && metadata.dynamic { return Ok(SatisfiesResult::MismatchedDynamic(&package.id.name, false)); } - } - // Validate the `version` metadata. - if metadata.version != *version { - return Ok(SatisfiesResult::MismatchedVersion( - &package.id.name, - version.clone(), - Some(metadata.version.clone()), - )); - } + // Validate the `version` metadata. + if metadata.version != *version { + return Ok(SatisfiesResult::MismatchedVersion( + &package.id.name, + version.clone(), + Some(metadata.version.clone()), + )); + } - // Validate the `provides-extras` metadata. - match self.satisfies_provides_extra(metadata.provides_extra, package) { - SatisfiesResult::Satisfied => {} - result => return Ok(result), - } + // Validate the `provides-extras` metadata. + match self.satisfies_provides_extra(metadata.provides_extra, package) { + SatisfiesResult::Satisfied => {} + result => return Ok(result), + } - // Validate that the requirements are unchanged. - match self.satisfies_requires_dist( - metadata.requires_dist, - metadata.dependency_groups, - package, - root, - )? { - SatisfiesResult::Satisfied => {} - result => return Ok(result), + // Validate that the requirements are unchanged. + match self.satisfies_requires_dist( + metadata.requires_dist, + metadata.dependency_groups, + package, + root, + )? { + SatisfiesResult::Satisfied => {} + result => return Ok(result), + } } } else if let Some(source_tree) = package.id.source.as_source_tree() { // For dynamic packages, we don't need the version. We only need to know that the @@ -2075,30 +2127,10 @@ impl Lock { // even if the version is dynamic, we can still extract the requirements without // performing a build, unlike in the database where we typically construct a "complete" // metadata object. - let parent = root.join(source_tree); - let path = parent.join("pyproject.toml"); - let metadata = match fs_err::tokio::read_to_string(&path).await { - Ok(contents) => { - let pyproject_toml = - PyProjectToml::from_toml(&contents, path.user_display()).map_err( - |err| LockErrorKind::InvalidPyprojectToml { - path: path.clone(), - err, - }, - )?; - database - .requires_dist(&parent, &pyproject_toml) - .await - .map_err(|err| LockErrorKind::Resolution { - id: package.id.clone(), - err, - })? - } - Err(err) if err.kind() == io::ErrorKind::NotFound => None, - Err(err) => { - return Err(LockErrorKind::UnreadablePyprojectToml { path, err }.into()); - } - }; + let metadata = + Self::source_tree_requires_dist(source_tree, root, package, database) + .await? + .map(|metadata| metadata.metadata); let satisfied = metadata.is_some_and(|metadata| { // Validate that the package is still dynamic. @@ -2142,7 +2174,7 @@ impl Lock { let HashedDist { dist, .. } = package.to_dist( root, TagPolicy::Preferred(tags), - &BuildOptions::default(), + build_options, markers, )?; @@ -2274,6 +2306,39 @@ impl Lock { Ok(SatisfiesResult::Satisfied) } + + async fn source_tree_requires_dist( + source_tree: &Path, + root: &Path, + package: &Package, + database: &DistributionDatabase<'_, Context>, + ) -> Result, LockError> { + let parent = root.join(source_tree); + let path = parent.join("pyproject.toml"); + match fs_err::tokio::read_to_string(&path).await { + Ok(contents) => { + let pyproject_toml = PyProjectToml::from_toml(&contents, path.user_display()) + .map_err(|err| LockErrorKind::InvalidPyprojectToml { + path: path.clone(), + err, + })?; + let version = pyproject_toml + .project + .as_ref() + .and_then(|project| project.version.clone()); + let metadata = database + .requires_dist(&parent, &pyproject_toml) + .await + .map_err(|err| LockErrorKind::Resolution { + id: package.id.clone(), + err, + })?; + Ok(metadata.map(|metadata| SourceTreeRequiresDist { version, metadata })) + } + Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(None), + Err(err) => Err(LockErrorKind::UnreadablePyprojectToml { path, err }.into()), + } + } } /// The set of lockfile packages that should be audited, materialized from a @@ -2289,6 +2354,11 @@ pub struct Auditable<'lock> { packages: Vec<(&'lock Package, &'lock Version)>, } +struct SourceTreeRequiresDist { + version: Option, + metadata: RequiresDist, +} + impl<'lock> Auditable<'lock> { /// Return the number of distinct `(name, version)` pairs to audit. pub fn len(&self) -> usize { @@ -5644,6 +5714,14 @@ impl LockError { pub fn is_resolution(&self) -> bool { matches!(&*self.kind, LockErrorKind::Resolution { .. }) } + + /// Returns true if the [`LockError`] is caused by disabled builds. + pub fn is_no_build(&self) -> bool { + matches!( + &*self.kind, + LockErrorKind::NoBuild { .. } | LockErrorKind::NoBinaryNoBuild { .. } + ) + } } impl From for LockError diff --git a/crates/uv/src/commands/project/lock.rs b/crates/uv/src/commands/project/lock.rs index 35f40c5ac0cb0..344b072d5b81e 100644 --- a/crates/uv/src/commands/project/lock.rs +++ b/crates/uv/src/commands/project/lock.rs @@ -853,9 +853,13 @@ async fn do_lock( .await { Ok(result) => Some(result), - Err(ProjectError::Lock(err)) if err.is_resolution() => { + Err(ProjectError::Lock(err)) if err.is_resolution() || err.is_no_build() => { // Resolver errors are not recoverable, as such errors can leave the resolver in a // broken state. Specifically, tasks that fail with an error can be left as pending. + // + // Disabled builds are user policy errors. Static local projects are validated + // before this point, so reaching this case means validation genuinely needs + // metadata that cannot be obtained under `--no-build`. return Err(ProjectError::Lock(err)); } Err(err) => { @@ -1259,6 +1263,7 @@ impl ValidatedLock { indexes, interpreter.tags()?, interpreter.markers(), + &options.build_options, hasher, index, database, diff --git a/crates/uv/tests/it/lock.rs b/crates/uv/tests/it/lock.rs index 8c97df3acb7b2..3850490f5cddd 100644 --- a/crates/uv/tests/it/lock.rs +++ b/crates/uv/tests/it/lock.rs @@ -19981,11 +19981,13 @@ fn lock_explicit_default_index() -> Result<()> { DEBUG Checking for Python environment at: `.venv` DEBUG The project environment's Python version satisfies the request: `Python >=3.12` DEBUG Using request connect timeout of [TIME] and read timeout of [TIME] - DEBUG Found static `pyproject.toml` for: project @ file://[TEMP_DIR]/ + DEBUG Found static `requires-dist` for: [TEMP_DIR]/ DEBUG No workspace root found, using project root DEBUG Resolving despite existing lockfile due to mismatched requirements for: `project==0.1.0` Requested: {Requirement { name: PackageName("anyio"), extras: [], groups: [], marker: true, source: Registry { specifier: VersionSpecifiers([]), index: None, conflict: None }, origin: None }} Existing: {Requirement { name: PackageName("iniconfig"), extras: [], groups: [], marker: true, source: Registry { specifier: VersionSpecifiers([VersionSpecifier { operator: Equal, version: "2.0.0" }]), index: Some(IndexMetadata { url: Url(VerbatimUrl { url: DisplaySafeUrl { scheme: "https", cannot_be_a_base: false, username: "", password: None, host: Some(Domain("test.pypi.org")), port: None, path: "/simple", query: None, fragment: None }, given: None, expanded: false }), format: Simple }), conflict: None }, origin: None }} + DEBUG Found static `pyproject.toml` for: project @ file://[TEMP_DIR]/ + DEBUG No workspace root found, using project root DEBUG Solving with installed Python version: 3.12.[X] DEBUG Solving with target Python version: >=3.12 DEBUG Solving with exclude-newer: global: 2024-03-25T00:00:00Z diff --git a/crates/uv/tests/it/pip_install.rs b/crates/uv/tests/it/pip_install.rs index 65312fdbc369c..9ac05a06d8165 100644 --- a/crates/uv/tests/it/pip_install.rs +++ b/crates/uv/tests/it/pip_install.rs @@ -1996,6 +1996,36 @@ fn install_editable_bare_cli() { ); } +#[test] +fn install_editable_unnamed_no_build() -> Result<()> { + let context = uv_test::test_context!("3.12"); + + let editable_dir = context.temp_dir.child("editable"); + editable_dir.child("setup.py").write_str(indoc! {r#" + from setuptools import setup + + setup(name="example", version="0.1.0") + "#})?; + + uv_snapshot!(context.filters(), context.pip_install() + .arg("--no-build") + .arg("-e") + .arg("editable"), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 1 package in [TIME] + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + example==0.1.0 (from file://[TEMP_DIR]/editable) + " + ); + + Ok(()) +} + #[test] fn install_editable_bare_requirements_txt() -> Result<()> { let context = uv_test::test_context!("3.12"); diff --git a/crates/uv/tests/it/sync.rs b/crates/uv/tests/it/sync.rs index fd524a6992bc6..a9bbc519bffd7 100644 --- a/crates/uv/tests/it/sync.rs +++ b/crates/uv/tests/it/sync.rs @@ -6116,6 +6116,68 @@ fn no_install_project_no_build() -> Result<()> { Ok(()) } +/// Ensure that lock validation respects `--no-build` when the project itself won't be installed. +#[test] +fn no_install_project_no_build_locked_dynamic_metadata() -> Result<()> { + let context = uv_test::test_context!("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(indoc! {r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dynamic = ["dependencies"] + + [build-system] + requires = [] + backend-path = ["."] + build-backend = "build_backend" + "#})?; + + let build_backend = context.temp_dir.child("build_backend.py"); + build_backend.write_str(indoc! {r#" + import pathlib + + def prepare_metadata_for_build_editable(metadata_directory, config_settings=None): + pathlib.Path("validation-hook-called").write_text("called") + dist_info = pathlib.Path(metadata_directory, "project-0.1.0.dist-info") + dist_info.mkdir() + dist_info.joinpath("METADATA").write_text( + "Metadata-Version: 2.1\n" + "Name: project\n" + "Version: 0.1.0\n" + "Requires-Dist: anyio==3.7.0\n" + ) + return dist_info.name + "#})?; + + // Generate a lockfile, then remove the cached metadata so validation would need to invoke the + // build backend again if it ignored `--no-build`. + context.lock().assert().success(); + + let marker = context.temp_dir.child("validation-hook-called"); + assert!(marker.exists()); + fs_err::remove_file(marker.path())?; + fs_err::remove_dir_all(&context.cache_dir)?; + + uv_snapshot!(context.filters(), context.sync() + .arg("--no-install-project") + .arg("--no-build") + .arg("--locked"), @" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Distribution `project==0.1.0 @ editable+.` can't be installed because it is marked as `--no-build` but has no binary distribution + "); + + assert!(!marker.exists()); + + Ok(()) +} + #[test] fn sync_extra_build_dependencies_script() -> Result<()> { let context = uv_test::test_context!("3.12").with_filtered_counts();