diff --git a/crates/uv-dispatch/src/lib.rs b/crates/uv-dispatch/src/lib.rs index 2817b70541708..f5b5f9f0f4ee5 100644 --- a/crates/uv-dispatch/src/lib.rs +++ b/crates/uv-dispatch/src/lib.rs @@ -205,6 +205,10 @@ impl BuildContext for BuildDispatch<'_> { self.build_options } + fn build_isolation(&self) -> BuildIsolation<'_> { + self.build_isolation + } + fn config_settings(&self) -> &ConfigSettings { self.config_settings } diff --git a/crates/uv-installer/src/plan.rs b/crates/uv-installer/src/plan.rs index 529babc74ae2b..616725fac591b 100644 --- a/crates/uv-installer/src/plan.rs +++ b/crates/uv-installer/src/plan.rs @@ -17,6 +17,7 @@ use uv_distribution_types::{ RequirementSource, Resolution, ResolvedDist, SourceDist, }; use uv_fs::Simplified; +use uv_normalize::PackageName; use uv_platform_tags::{IncompatibleTag, TagCompatibility, Tags}; use uv_pypi_types::VerbatimParsedUrl; use uv_python::PythonEnvironment; @@ -658,3 +659,76 @@ pub struct Plan { /// _not_ necessary to satisfy the requirements. pub extraneous: Vec, } + +impl Plan { + /// Returns `true` if the plan is empty. + pub fn is_empty(&self) -> bool { + self.cached.is_empty() + && self.remote.is_empty() + && self.reinstalls.is_empty() + && self.extraneous.is_empty() + } + + /// Partition the remote distributions based on a predicate function. + /// + /// Returns a tuple of plans, where the first plan contains the remote distributions that match + /// the predicate, and the second plan contains those that do not. + /// + /// Any extraneous and cached distributions will be returned in the first plan, while the second + /// plan will contain any `false` matches from the remote distributions, along with any + /// reinstalls for those distributions. + pub fn partition(self, mut f: F) -> (Self, Self) + where + F: FnMut(&PackageName) -> bool, + { + let Self { + cached, + remote, + reinstalls, + extraneous, + } = self; + + // Partition the remote distributions based on the predicate function. + let (left_remote, right_remote) = remote + .into_iter() + .partition::, _>(|dist| f(dist.name())); + + // If any remote distributions are not matched, but are already installed, ensure that + // they're uninstalled as part of the right plan. (Uninstalling them as part of the left + // plan risks uninstalling them from the environment _prior_ to the replacement being built.) + let (left_reinstalls, right_reinstalls) = reinstalls + .into_iter() + .partition::, _>(|dist| !right_remote.iter().any(|d| d.name() == dist.name())); + + // If the right plan is non-empty, then remove extraneous distributions as part of the + // right plan, so they're present until the very end. Otherwise, we risk removing extraneous + // packages that are actually build dependencies. + let (left_extraneous, right_extraneous) = if right_remote.is_empty() { + (extraneous, vec![]) + } else { + (vec![], extraneous) + }; + + // Always include the cached distributions in the left plan. + let (left_cached, right_cached) = (cached, vec![]); + + // Include all cached and extraneous distributions in the left plan. + let left_plan = Self { + cached: left_cached, + remote: left_remote, + reinstalls: left_reinstalls, + extraneous: left_extraneous, + }; + + // The right plan will only contain the remote distributions that did not match the predicate, + // along with any reinstalls for those distributions. + let right_plan = Self { + cached: right_cached, + remote: right_remote, + reinstalls: right_reinstalls, + extraneous: right_extraneous, + }; + + (left_plan, right_plan) + } +} diff --git a/crates/uv-types/src/traits.rs b/crates/uv-types/src/traits.rs index e6714a40203d2..2d49b368509ae 100644 --- a/crates/uv-types/src/traits.rs +++ b/crates/uv-types/src/traits.rs @@ -19,7 +19,7 @@ use uv_pep508::PackageName; use uv_python::{Interpreter, PythonEnvironment}; use uv_workspace::WorkspaceCache; -use crate::BuildArena; +use crate::{BuildArena, BuildIsolation}; /// Avoids cyclic crate dependencies between resolver, installer and builder. /// @@ -87,6 +87,9 @@ pub trait BuildContext { /// This method exists to avoid fetching source distributions if we know we can't build them. fn build_options(&self) -> &BuildOptions; + /// The isolation mode used for building source distributions. + fn build_isolation(&self) -> BuildIsolation<'_>; + /// The [`ConfigSettings`] used to build distributions. fn config_settings(&self) -> &ConfigSettings; diff --git a/crates/uv/src/commands/pip/loggers.rs b/crates/uv/src/commands/pip/loggers.rs index 1f9aaffa589c0..4752fd0d08056 100644 --- a/crates/uv/src/commands/pip/loggers.rs +++ b/crates/uv/src/commands/pip/loggers.rs @@ -20,7 +20,13 @@ pub(crate) trait InstallLogger { fn on_audit(&self, count: usize, start: std::time::Instant, printer: Printer) -> fmt::Result; /// Log the completion of the preparation phase. - fn on_prepare(&self, count: usize, start: std::time::Instant, printer: Printer) -> fmt::Result; + fn on_prepare( + &self, + count: usize, + suffix: Option<&str>, + start: std::time::Instant, + printer: Printer, + ) -> fmt::Result; /// Log the completion of the uninstallation phase. fn on_uninstall( @@ -64,14 +70,25 @@ impl InstallLogger for DefaultInstallLogger { } } - fn on_prepare(&self, count: usize, start: std::time::Instant, printer: Printer) -> fmt::Result { + fn on_prepare( + &self, + count: usize, + suffix: Option<&str>, + start: std::time::Instant, + printer: Printer, + ) -> fmt::Result { let s = if count == 1 { "" } else { "s" }; writeln!( printer.stderr(), "{}", format!( "Prepared {} {}", - format!("{count} package{s}").bold(), + if let Some(suffix) = suffix { + format!("{count} package{s} {suffix}") + } else { + format!("{count} package{s}") + } + .bold(), format!("in {}", elapsed(start.elapsed())).dimmed() ) .dimmed() @@ -192,6 +209,7 @@ impl InstallLogger for SummaryInstallLogger { fn on_prepare( &self, _count: usize, + _suffix: Option<&str>, _start: std::time::Instant, _printer: Printer, ) -> fmt::Result { @@ -262,6 +280,7 @@ impl InstallLogger for UpgradeInstallLogger { fn on_prepare( &self, _count: usize, + _suffix: Option<&str>, _start: std::time::Instant, _printer: Printer, ) -> fmt::Result { diff --git a/crates/uv/src/commands/pip/operations.rs b/crates/uv/src/commands/pip/operations.rs index b2e5e431c3602..7db3d6a88560b 100644 --- a/crates/uv/src/commands/pip/operations.rs +++ b/crates/uv/src/commands/pip/operations.rs @@ -502,6 +502,132 @@ pub(crate) async fn install( return Ok(Changelog::default()); } + // Partition into two sets: those that require build isolation, and those that disable it. This + // is effectively a heuristic to make `--no-build-isolation` work "more often" by way of giving + // `--no-build-isolation` packages "access" to the rest of the environment. + let (isolated_phase, shared_phase) = Plan { + cached, + remote, + reinstalls, + extraneous, + } + .partition(|name| build_dispatch.build_isolation().is_isolated(Some(name))); + + let has_isolated_phase = !isolated_phase.is_empty(); + let has_shared_phase = !shared_phase.is_empty(); + + let mut installs = vec![]; + let mut uninstalls = vec![]; + + // Execute the isolated-build phase. + if has_isolated_phase { + let (isolated_installs, isolated_uninstalls) = execute_plan( + isolated_phase, + None, + resolution, + build_options, + link_mode, + hasher, + tags, + client, + in_flight, + concurrency, + build_dispatch, + cache, + venv, + logger.as_ref(), + installer_metadata, + printer, + preview, + ) + .await?; + installs.extend(isolated_installs); + uninstalls.extend(isolated_uninstalls); + } + + if has_shared_phase { + let (shared_installs, shared_uninstalls) = execute_plan( + shared_phase, + if has_isolated_phase { + Some(InstallPhase::Shared) + } else { + None + }, + resolution, + build_options, + link_mode, + hasher, + tags, + client, + in_flight, + concurrency, + build_dispatch, + cache, + venv, + logger.as_ref(), + installer_metadata, + printer, + preview, + ) + .await?; + installs.extend(shared_installs); + uninstalls.extend(shared_uninstalls); + } + + if compile { + compile_bytecode(venv, &concurrency, cache, printer).await?; + } + + // Construct a summary of the changes made to the environment. + let changelog = Changelog::new(installs, uninstalls); + + // Notify the user of any environment modifications. + logger.on_complete(&changelog, printer)?; + + Ok(changelog) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum InstallPhase { + /// A dedicated phase for building and installing packages with build-isolation disabled. + Shared, +} + +impl InstallPhase { + fn label(self) -> &'static str { + match self { + Self::Shared => "without build isolation", + } + } +} + +/// Execute a [`Plan`] to install distributions into a Python environment. +async fn execute_plan( + plan: Plan, + phase: Option, + resolution: &Resolution, + build_options: &BuildOptions, + link_mode: LinkMode, + hasher: &HashStrategy, + tags: &Tags, + client: &RegistryClient, + in_flight: &InFlight, + concurrency: Concurrency, + build_dispatch: &BuildDispatch<'_>, + cache: &Cache, + venv: &PythonEnvironment, + logger: &dyn InstallLogger, + installer_metadata: bool, + printer: Printer, + preview: uv_configuration::Preview, +) -> Result<(Vec, Vec), Error> { + let Plan { + cached, + remote, + reinstalls, + extraneous, + } = plan; + // Download, build, and unzip any missing distributions. let wheels = if remote.is_empty() { vec![] @@ -523,7 +649,7 @@ pub(crate) async fn install( .prepare(remote.clone(), in_flight, resolution) .await?; - logger.on_prepare(wheels.len(), start, printer)?; + logger.on_prepare(wheels.len(), phase.map(InstallPhase::label), start, printer)?; wheels }; @@ -587,17 +713,7 @@ pub(crate) async fn install( logger.on_install(installs.len(), start, printer)?; } - if compile { - compile_bytecode(venv, &concurrency, cache, printer).await?; - } - - // Construct a summary of the changes made to the environment. - let changelog = Changelog::new(installs, uninstalls); - - // Notify the user of any environment modifications. - logger.on_complete(&changelog, printer)?; - - Ok(changelog) + Ok((installs, uninstalls)) } /// Display a message about the interpreter that was selected for the operation. diff --git a/crates/uv/tests/it/pip_install.rs b/crates/uv/tests/it/pip_install.rs index ba7552bcc1bc4..8edcf74f8909a 100644 --- a/crates/uv/tests/it/pip_install.rs +++ b/crates/uv/tests/it/pip_install.rs @@ -8693,18 +8693,20 @@ fn install_build_isolation_package() -> Result<()> { uv_snapshot!(context.filters(), context.pip_install() .arg("--no-build-isolation-package") .arg("iniconfig") - .arg(package.path()), @r###" + .arg(package.path()), @r" success: true exit_code: 0 ----- stdout ----- ----- stderr ----- Resolved 2 packages in [TIME] - Prepared 2 packages in [TIME] - Installed 2 packages in [TIME] + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + Prepared 1 package without build isolation in [TIME] + Installed 1 package in [TIME] + iniconfig==2.0.0 (from https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz) + project==0.1.0 (from file://[TEMP_DIR]/project) - "###); + "); Ok(()) } diff --git a/crates/uv/tests/it/sync.rs b/crates/uv/tests/it/sync.rs index d8ce377c6bdd2..5e7ffac481978 100644 --- a/crates/uv/tests/it/sync.rs +++ b/crates/uv/tests/it/sync.rs @@ -1393,7 +1393,7 @@ fn sync_build_isolation_package() -> Result<()> { "#, )?; - // Running `uv sync` should fail for iniconfig. + // Running `uv sync` should fail. uv_snapshot!(context.filters(), context.sync().arg("--no-build-isolation-package").arg("source-distribution"), @r#" success: false exit_code: 1 @@ -1401,6 +1401,8 @@ fn sync_build_isolation_package() -> Result<()> { ----- stderr ----- Resolved 2 packages in [TIME] + Prepared 1 package in [TIME] + Installed 1 package in [TIME] × Failed to build `source-distribution @ https://files.pythonhosted.org/packages/10/1f/57aa4cce1b1abf6b433106676e15f9fa2c92ed2bd4cf77c3b50a9e9ac773/source_distribution-0.0.1.tar.gz` ├─▶ The build backend returned an error ╰─▶ Call to `hatchling.build.build_wheel` failed (exit status: 1) @@ -1444,14 +1446,13 @@ fn sync_build_isolation_package() -> Result<()> { ----- stderr ----- Resolved 2 packages in [TIME] - Prepared 2 packages in [TIME] + Prepared 1 package in [TIME] Uninstalled 5 packages in [TIME] - Installed 2 packages in [TIME] + Installed 1 package in [TIME] - hatchling==1.22.4 - packaging==24.0 - pathspec==0.12.1 - pluggy==1.4.0 - + project==0.1.0 (from file://[TEMP_DIR]/) + source-distribution==0.0.1 (from https://files.pythonhosted.org/packages/10/1f/57aa4cce1b1abf6b433106676e15f9fa2c92ed2bd4cf77c3b50a9e9ac773/source_distribution-0.0.1.tar.gz) - trove-classifiers==2024.3.3 "); @@ -1461,6 +1462,156 @@ fn sync_build_isolation_package() -> Result<()> { Ok(()) } +/// By default, isolated dependencies should be installed before non-isolated dependencies. +#[test] +fn sync_build_isolation_package_order() -> 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 = [ + "source-distribution @ https://files.pythonhosted.org/packages/10/1f/57aa4cce1b1abf6b433106676e15f9fa2c92ed2bd4cf77c3b50a9e9ac773/source_distribution-0.0.1.tar.gz", + ] + "#, + )?; + + // Running `uv sync` should fail. + uv_snapshot!(context.filters(), context.sync().arg("--no-build-isolation-package").arg("source-distribution"), @r#" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + Resolved 2 packages in [TIME] + × Failed to build `source-distribution @ https://files.pythonhosted.org/packages/10/1f/57aa4cce1b1abf6b433106676e15f9fa2c92ed2bd4cf77c3b50a9e9ac773/source_distribution-0.0.1.tar.gz` + ├─▶ The build backend returned an error + ╰─▶ Call to `hatchling.build.build_wheel` failed (exit status: 1) + + [stderr] + Traceback (most recent call last): + File "", line 8, in + ModuleNotFoundError: No module named 'hatchling' + + hint: This error likely indicates that `source-distribution` depends on `hatchling`, but doesn't declare it as a build dependency. If `source-distribution` is a first-party package, consider adding `hatchling` to its `build-system.requires`. Otherwise, either add it to your `pyproject.toml` under: + + [tool.uv.extra-build-dependencies] + source-distribution = ["hatchling"] + + or `uv pip install hatchling` into the environment and re-run with `--no-build-isolation`. + help: `source-distribution` was included because `project` (v0.1.0) depends on `source-distribution` + "#); + + // Add `hatchling`. + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [ + "hatchling", + "source-distribution @ https://files.pythonhosted.org/packages/10/1f/57aa4cce1b1abf6b433106676e15f9fa2c92ed2bd4cf77c3b50a9e9ac773/source_distribution-0.0.1.tar.gz", + ] + "#, + )?; + + // Running `uv sync` should succeed; `hatchling` should be installed first. + uv_snapshot!(context.filters(), context.sync().arg("--no-build-isolation-package").arg("source-distribution"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 7 packages in [TIME] + Prepared 5 packages in [TIME] + Installed 5 packages in [TIME] + Prepared 1 package without build isolation in [TIME] + Installed 1 package in [TIME] + + hatchling==1.22.4 + + packaging==24.0 + + pathspec==0.12.1 + + pluggy==1.4.0 + + source-distribution==0.0.1 (from https://files.pythonhosted.org/packages/10/1f/57aa4cce1b1abf6b433106676e15f9fa2c92ed2bd4cf77c3b50a9e9ac773/source_distribution-0.0.1.tar.gz) + + trove-classifiers==2024.3.3 + "); + + // Modify the version. + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [ + "source-distribution @ https://files.pythonhosted.org/packages/1f/e5/5b016c945d745f8b108e759d428341488a6aee8f51f07c6c4e33498bb91f/source_distribution-0.0.3.tar.gz", + ] + "#, + )?; + + // Running `uv sync` should uninstall `hatchling`, then build `source-distribution`, then uninstall + // the existing `source-distribution`, and finally install the new one. + uv_snapshot!(context.filters(), context.sync().arg("--no-build-isolation-package").arg("source-distribution"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 2 packages in [TIME] + Prepared 1 package in [TIME] + Uninstalled 6 packages in [TIME] + Installed 1 package in [TIME] + - hatchling==1.22.4 + - packaging==24.0 + - pathspec==0.12.1 + - pluggy==1.4.0 + - source-distribution==0.0.1 (from https://files.pythonhosted.org/packages/10/1f/57aa4cce1b1abf6b433106676e15f9fa2c92ed2bd4cf77c3b50a9e9ac773/source_distribution-0.0.1.tar.gz) + + source-distribution==0.0.3 (from https://files.pythonhosted.org/packages/1f/e5/5b016c945d745f8b108e759d428341488a6aee8f51f07c6c4e33498bb91f/source_distribution-0.0.3.tar.gz) + - trove-classifiers==2024.3.3 + "); + + // Revert back. + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [ + "hatchling", + "source-distribution @ https://files.pythonhosted.org/packages/10/1f/57aa4cce1b1abf6b433106676e15f9fa2c92ed2bd4cf77c3b50a9e9ac773/source_distribution-0.0.1.tar.gz", + ] + "#, + )?; + + // Running `uv sync` should install everything in a single phase, since the build is cached. + uv_snapshot!(context.filters(), context.sync().arg("--no-build-isolation-package").arg("source-distribution"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 7 packages in [TIME] + Uninstalled 1 package in [TIME] + Installed 6 packages in [TIME] + + hatchling==1.22.4 + + packaging==24.0 + + pathspec==0.12.1 + + pluggy==1.4.0 + - source-distribution==0.0.3 (from https://files.pythonhosted.org/packages/1f/e5/5b016c945d745f8b108e759d428341488a6aee8f51f07c6c4e33498bb91f/source_distribution-0.0.3.tar.gz) + + source-distribution==0.0.1 (from https://files.pythonhosted.org/packages/10/1f/57aa4cce1b1abf6b433106676e15f9fa2c92ed2bd4cf77c3b50a9e9ac773/source_distribution-0.0.1.tar.gz) + + trove-classifiers==2024.3.3 + "); + + assert!(context.temp_dir.child("uv.lock").exists()); + + Ok(()) +} + /// Use dedicated extra groups to install dependencies for `--no-build-isolation-package`. #[test] fn sync_build_isolation_extra() -> Result<()> { @@ -1496,6 +1647,8 @@ fn sync_build_isolation_extra() -> Result<()> { ----- stderr ----- Resolved [N] packages in [TIME] + Prepared [N] packages in [TIME] + Installed [N] packages in [TIME] × Failed to build `source-distribution @ https://files.pythonhosted.org/packages/10/1f/57aa4cce1b1abf6b433106676e15f9fa2c92ed2bd4cf77c3b50a9e9ac773/source_distribution-0.0.1.tar.gz` ├─▶ The build backend returned an error ╰─▶ Call to `hatchling.build.build_wheel` failed (exit status: 1) @@ -1514,31 +1667,32 @@ fn sync_build_isolation_extra() -> Result<()> { help: `source-distribution` was included because `project[compile]` (v0.1.0) depends on `source-distribution` "#); - // Running `uv sync` with `--all-extras` should also fail. - uv_snapshot!(context.filters(), context.sync().arg("--all-extras"), @r#" - success: false - exit_code: 1 + // Running `uv sync` with `--all-extras` should succeed, because we install the build dependencies + // first. + uv_snapshot!(context.filters(), context.sync().arg("--all-extras"), @r" + success: true + exit_code: 0 ----- stdout ----- ----- stderr ----- Resolved [N] packages in [TIME] - × Failed to build `source-distribution @ https://files.pythonhosted.org/packages/10/1f/57aa4cce1b1abf6b433106676e15f9fa2c92ed2bd4cf77c3b50a9e9ac773/source_distribution-0.0.1.tar.gz` - ├─▶ The build backend returned an error - ╰─▶ Call to `hatchling.build.build_wheel` failed (exit status: 1) - - [stderr] - Traceback (most recent call last): - File "", line 8, in - ModuleNotFoundError: No module named 'hatchling' - - hint: This error likely indicates that `source-distribution` depends on `hatchling`, but doesn't declare it as a build dependency. If `source-distribution` is a first-party package, consider adding `hatchling` to its `build-system.requires`. Otherwise, either add it to your `pyproject.toml` under: + Prepared [N] packages in [TIME] + Installed [N] packages in [TIME] + Prepared [N] packages without build isolation in [TIME] + Installed [N] packages in [TIME] + + hatchling==1.22.4 + + packaging==24.0 + + pathspec==0.12.1 + + pluggy==1.4.0 + + source-distribution==0.0.1 (from https://files.pythonhosted.org/packages/10/1f/57aa4cce1b1abf6b433106676e15f9fa2c92ed2bd4cf77c3b50a9e9ac773/source_distribution-0.0.1.tar.gz) + + trove-classifiers==2024.3.3 + "); - [tool.uv.extra-build-dependencies] - source-distribution = ["hatchling"] + // Clear the virtual environment. + context.venv().arg("--clear").assert().success(); - or `uv pip install hatchling` into the environment and re-run with `--no-build-isolation`. - help: `source-distribution` was included because `project[compile]` (v0.1.0) depends on `source-distribution` - "#); + // Clear the cache. + fs_err::remove_dir_all(&context.cache_dir)?; // Install the build dependencies. uv_snapshot!(context.filters(), context.sync().arg("--extra").arg("build"), @r"