diff --git a/crates/uv/src/commands/build_frontend.rs b/crates/uv/src/commands/build_frontend.rs index eabd58f897e0d..17b552a54c587 100644 --- a/crates/uv/src/commands/build_frontend.rs +++ b/crates/uv/src/commands/build_frontend.rs @@ -23,8 +23,8 @@ use uv_distribution_filename::{ DistFilename, SourceDistExtension, SourceDistFilename, WheelFilename, }; use uv_distribution_types::{ - ConfigSettings, DependencyMetadata, ExtraBuildVariables, Index, IndexLocations, - PackageConfigSettings, RequiresPython, SourceDist, + ConfigSettings, DependencyMetadata, ExtraBuildRequires, ExtraBuildVariables, Index, + IndexLocations, PackageConfigSettings, RequiresPython, SourceDist, }; use uv_fs::{Simplified, relative_to}; use uv_install_wheel::LinkMode; @@ -40,7 +40,6 @@ use uv_requirements::RequirementsSource; use uv_resolver::{ExcludeNewer, FlatIndex}; use uv_settings::PythonInstallMirrors; use uv_types::{AnyErrorBuild, BuildContext, BuildStack, HashStrategy}; -use uv_workspace::pyproject::ExtraBuildDependencies; use uv_workspace::{DiscoveryOptions, Workspace, WorkspaceCache, WorkspaceError}; use crate::commands::ExitStatus; @@ -251,6 +250,38 @@ async fn build_impl( ) .await; + // Discover the settings workspace (from the project directory) for lowering extra build + // dependencies. This may differ from the source workspace when building a subdirectory + // (e.g., `uv build child`). + let settings_workspace = if src.directory() == project_dir { + workspace.as_ref().ok() + } else { + None + }; + let settings_workspace_owned; + let settings_workspace = if let Some(workspace) = settings_workspace { + Some(workspace) + } else { + settings_workspace_owned = + Workspace::discover(project_dir, &DiscoveryOptions::default(), &workspace_cache).await; + settings_workspace_owned.as_ref().ok() + }; + + // Lower the extra build dependencies with source resolution. + let extra_build_requires = if let Some(workspace) = settings_workspace { + LoweredExtraBuildDependencies::from_workspace( + extra_build_dependencies.clone(), + workspace, + index_locations, + sources, + client_builder.credentials_cache(), + ) + .map_err(ProjectError::from)? + } else { + LoweredExtraBuildDependencies::from_non_lowered(extra_build_dependencies.clone()) + } + .into_inner(); + // If a `--package` or `--all-packages` was provided, adjust the source directory. let packages = if let Some(package) = package { if matches!(src, Source::File(_)) { @@ -354,7 +385,7 @@ async fn build_impl( clear, build_constraints, build_isolation, - extra_build_dependencies, + &extra_build_requires, extra_build_variables, *index_strategy, *keyring_provider, @@ -463,7 +494,7 @@ async fn build_package( clear: bool, build_constraints: &[RequirementsSource], build_isolation: &BuildIsolation, - extra_build_dependencies: &ExtraBuildDependencies, + extra_build_requires: &ExtraBuildRequires, extra_build_variables: &ExtraBuildVariables, index_strategy: IndexStrategy, keyring_provider: KeyringProviderType, @@ -604,10 +635,6 @@ async fn build_package( let state = SharedState::default(); let workspace_cache = WorkspaceCache::default(); - let extra_build_requires = - LoweredExtraBuildDependencies::from_non_lowered(extra_build_dependencies.clone()) - .into_inner(); - // Create a build dispatch. let build_dispatch = BuildDispatch::new( &client, @@ -622,7 +649,7 @@ async fn build_package( config_setting, config_settings_package, types_build_isolation, - &extra_build_requires, + extra_build_requires, extra_build_variables, link_mode, build_options, diff --git a/crates/uv/src/commands/project/environment.rs b/crates/uv/src/commands/project/environment.rs index 8d9d21b7c77d3..0d11650c0860d 100644 --- a/crates/uv/src/commands/project/environment.rs +++ b/crates/uv/src/commands/project/environment.rs @@ -18,6 +18,7 @@ use uv_distribution_types::{Name, Resolution}; use uv_fs::PythonExt; use uv_preview::Preview; use uv_python::{Interpreter, PythonEnvironment, canonicalize_executable}; +use uv_workspace::Workspace; /// An ephemeral [`PythonEnvironment`] for running an individual command. #[derive(Debug)] @@ -114,6 +115,7 @@ impl CachedEnvironment { interpreter: &Interpreter, python_platform: Option<&TargetTriple>, settings: &ResolverInstallerSettings, + workspace: Option<&Workspace>, client_builder: &BaseClientBuilder<'_>, state: &PlatformState, resolve: Box, @@ -134,6 +136,7 @@ impl CachedEnvironment { python_platform, build_constraints.clone(), &settings.resolver, + workspace, client_builder, state, resolve, @@ -199,6 +202,7 @@ impl CachedEnvironment { Modifications::Exact, build_constraints, settings.into(), + workspace, client_builder, state, install, diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index 5f79fefe5eb06..45c0f5022b2c2 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -1743,6 +1743,7 @@ pub(crate) async fn resolve_names( requirements: Vec, interpreter: &Interpreter, settings: &ResolverInstallerSettings, + workspace: Option<&Workspace>, client_builder: &BaseClientBuilder<'_>, state: &SharedState, concurrency: Concurrency, @@ -1798,6 +1799,21 @@ pub(crate) async fn resolve_names( let client_builder = client_builder.clone().keyring(*keyring_provider); + // Lower the extra build dependencies, if any. + let extra_build_requires = if let Some(workspace) = workspace { + LoweredExtraBuildDependencies::from_workspace( + extra_build_dependencies.clone(), + workspace, + index_locations, + sources, + client_builder.credentials_cache(), + ) + .map_err(uv_distribution::Error::from)? + } else { + LoweredExtraBuildDependencies::from_non_lowered(extra_build_dependencies.clone()) + } + .into_inner(); + // Determine the PyTorch backend. let torch_backend = torch_backend .map(|mode| { @@ -1844,11 +1860,6 @@ pub(crate) async fn resolve_names( let build_constraints = Constraints::default(); let build_hasher = HashStrategy::default(); - // Lower the extra build dependencies, if any. - let extra_build_requires = - LoweredExtraBuildDependencies::from_non_lowered(extra_build_dependencies.clone()) - .into_inner(); - // Create a build dispatch. let build_dispatch = BuildDispatch::new( &client, @@ -1936,6 +1947,7 @@ pub(crate) async fn resolve_environment( python_platform: Option<&TargetTriple>, build_constraints: Constraints, settings: &ResolverSettings, + workspace: Option<&Workspace>, client_builder: &BaseClientBuilder<'_>, state: &PlatformState, logger: Box, @@ -1980,6 +1992,20 @@ pub(crate) async fn resolve_environment( let client_builder = client_builder.clone().keyring(*keyring_provider); + // Lower the extra build dependencies, if any. + let extra_build_requires = if let Some(workspace) = workspace { + LoweredExtraBuildDependencies::from_workspace( + extra_build_dependencies.clone(), + workspace, + index_locations, + sources, + client_builder.credentials_cache(), + )? + } else { + LoweredExtraBuildDependencies::from_non_lowered(extra_build_dependencies.clone()) + } + .into_inner(); + // Determine the tags, markers, and interpreter to use for resolution. let tags = pip::resolution_tags(None, python_platform, interpreter)?; let marker_env = pip::resolution_markers(None, python_platform, interpreter); @@ -2080,11 +2106,6 @@ pub(crate) async fn resolve_environment( let workspace_cache = WorkspaceCache::default(); - // Lower the extra build dependencies, if any. - let extra_build_requires = - LoweredExtraBuildDependencies::from_non_lowered(extra_build_dependencies.clone()) - .into_inner(); - // Create a build dispatch. let resolve_dispatch = BuildDispatch::new( &client, @@ -2151,6 +2172,7 @@ pub(crate) async fn sync_environment( modifications: Modifications, build_constraints: Constraints, settings: InstallerSettingsRef<'_>, + workspace: Option<&Workspace>, client_builder: &BaseClientBuilder<'_>, state: &PlatformState, logger: Box, @@ -2180,6 +2202,20 @@ pub(crate) async fn sync_environment( let client_builder = client_builder.clone().keyring(keyring_provider); + // Lower the extra build dependencies, if any. + let extra_build_requires = if let Some(workspace) = workspace { + LoweredExtraBuildDependencies::from_workspace( + extra_build_dependencies.clone(), + workspace, + index_locations, + &sources, + client_builder.credentials_cache(), + )? + } else { + LoweredExtraBuildDependencies::from_non_lowered(extra_build_dependencies.clone()) + } + .into_inner(); + let site_packages = SitePackages::from_environment(&venv)?; // Determine the markers tags to use for resolution. @@ -2219,11 +2255,6 @@ pub(crate) async fn sync_environment( FlatIndex::from_entries(entries, Some(tags), &hasher, build_options) }; - // Lower the extra build dependencies, if any. - let extra_build_requires = - LoweredExtraBuildDependencies::from_non_lowered(extra_build_dependencies.clone()) - .into_inner(); - // Create a build dispatch. let build_dispatch = BuildDispatch::new( &client, diff --git a/crates/uv/src/commands/project/run.rs b/crates/uv/src/commands/project/run.rs index 12f82e4d77e2f..903fcd33c2c42 100644 --- a/crates/uv/src/commands/project/run.rs +++ b/crates/uv/src/commands/project/run.rs @@ -205,6 +205,9 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl // The lockfile used for the base environment. let mut base_lock: Option<(Lock, PathBuf)> = None; + // The workspace for the base environment. + let mut workspace: Option = None; + // Determine whether the command to execute is a PEP 723 script. let temp_dir; let script_interpreter = if let Some(script) = script { @@ -639,6 +642,8 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl } } + workspace = project.as_ref().map(|p| p.workspace().clone()); + if let Some(project) = project { if let Some(project_name) = project.project_name() { debug!( @@ -1015,6 +1020,7 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl &base_interpreter, python_platform.as_ref(), &settings, + workspace.as_ref(), &client_builder, &sync_state, if show_resolution { diff --git a/crates/uv/src/commands/tool/install.rs b/crates/uv/src/commands/tool/install.rs index 7885d54375518..303fbf872a62f 100644 --- a/crates/uv/src/commands/tool/install.rs +++ b/crates/uv/src/commands/tool/install.rs @@ -172,6 +172,7 @@ pub(crate) async fn install( requirement, &interpreter, &settings, + None, &client_builder, &state, concurrency, @@ -360,6 +361,7 @@ pub(crate) async fn install( spec.requirements.clone(), &interpreter, &settings, + None, &client_builder, &state, concurrency, @@ -386,6 +388,7 @@ pub(crate) async fn install( spec.overrides, &interpreter, &settings, + None, &client_builder, &state, concurrency, @@ -616,6 +619,7 @@ pub(crate) async fn install( python_platform.as_ref(), Constraints::from_requirements(build_constraints.iter().cloned()), &settings.resolver, + None, &client_builder, &state, Box::new(DefaultResolveLogger), @@ -671,6 +675,7 @@ pub(crate) async fn install( python_platform.as_ref(), Constraints::from_requirements(build_constraints.iter().cloned()), &settings.resolver, + None, &client_builder, &state, Box::new(DefaultResolveLogger), @@ -711,6 +716,7 @@ pub(crate) async fn install( Modifications::Exact, Constraints::from_requirements(build_constraints.iter().cloned()), (&settings).into(), + None, &client_builder, &state, Box::new(DefaultInstallLogger), diff --git a/crates/uv/src/commands/tool/run.rs b/crates/uv/src/commands/tool/run.rs index 0cf66f0868bc5..8bcde6bc62b72 100644 --- a/crates/uv/src/commands/tool/run.rs +++ b/crates/uv/src/commands/tool/run.rs @@ -833,6 +833,7 @@ async fn get_or_create_environment( vec![spec], &interpreter, settings, + None, client_builder, &state, concurrency, @@ -992,6 +993,7 @@ async fn get_or_create_environment( spec.requirements.clone(), &interpreter, settings, + None, client_builder, &state, concurrency, @@ -1019,6 +1021,7 @@ async fn get_or_create_environment( spec.overrides.clone(), &interpreter, settings, + None, client_builder, &state, concurrency, @@ -1135,6 +1138,7 @@ async fn get_or_create_environment( &interpreter, python_platform.as_ref(), settings, + None, client_builder, &state, if show_resolution { @@ -1195,6 +1199,7 @@ async fn get_or_create_environment( &interpreter, python_platform.as_ref(), settings, + None, client_builder, &state, if show_resolution { diff --git a/crates/uv/src/commands/tool/upgrade.rs b/crates/uv/src/commands/tool/upgrade.rs index 8e843cc414c7b..c6fa1fe10096c 100644 --- a/crates/uv/src/commands/tool/upgrade.rs +++ b/crates/uv/src/commands/tool/upgrade.rs @@ -350,6 +350,7 @@ async fn upgrade_tool( python_platform, build_constraints.clone(), &settings.resolver, + None, client_builder, &state, Box::new(SummaryResolveLogger), @@ -368,6 +369,7 @@ async fn upgrade_tool( Modifications::Exact, build_constraints, (&settings).into(), + None, client_builder, &state, Box::new(DefaultInstallLogger), diff --git a/crates/uv/tests/it/build.rs b/crates/uv/tests/it/build.rs index 0a80736ba2bc4..8594586887323 100644 --- a/crates/uv/tests/it/build.rs +++ b/crates/uv/tests/it/build.rs @@ -2359,3 +2359,108 @@ fn build_no_gitignore() -> Result<()> { Ok(()) } + +/// Test that `uv build` respects `[tool.uv.sources]` index pinning for extra-build-dependencies. +#[test] +fn build_extra_build_dependencies_index() -> Result<()> { + let context = uv_test::test_context!("3.12").with_filtered_counts(); + + let filters = context + .filters() + .into_iter() + .chain([(r"\\\.", "")]) + .collect::>(); + + // Write a test package that arbitrarily requires `anyio` at build time + let child = context.temp_dir.child("child"); + child.create_dir_all()?; + let child_pyproject_toml = child.child("pyproject.toml"); + child_pyproject_toml.write_str(indoc! {r#" + [project] + name = "child" + version = "0.1.0" + requires-python = ">=3.9" + + [build-system] + requires = ["hatchling", "anyio"] + backend-path = ["."] + build-backend = "build_backend" + "#})?; + + // Create a build backend that checks for a specific version of anyio + let build_backend = child.child("build_backend.py"); + build_backend.write_str(indoc! {r#" + import os + import sys + from hatchling.build import * + + expected_version = os.environ.get("EXPECTED_ANYIO_VERSION", "") + if not expected_version: + print("`EXPECTED_ANYIO_VERSION` not set", file=sys.stderr) + sys.exit(1) + + try: + import anyio + except ModuleNotFoundError: + print("Missing `anyio` module", file=sys.stderr) + sys.exit(1) + + from importlib.metadata import version + anyio_version = version("anyio") + + if not anyio_version.startswith(expected_version): + print(f"Expected `anyio` version {expected_version} but got {anyio_version}", file=sys.stderr) + sys.exit(1) + + print(f"Found expected `anyio` version {anyio_version}", file=sys.stderr) + "#})?; + child.child("src/child/__init__.py").touch()?; + + let parent = &context.temp_dir; + let pyproject_toml = parent.child("pyproject.toml"); + + // Pin `anyio` to the Test PyPI via sources. + pyproject_toml.write_str(indoc! {r#" + [project] + name = "parent" + version = "0.1.0" + requires-python = ">=3.9" + dependencies = ["child"] + + [build-system] + requires = ["hatchling"] + build-backend = "hatchling.build" + + [tool.uv.sources] + child = { path = "child" } + anyio = { index = "test" } + + [tool.uv.extra-build-dependencies] + child = ["anyio"] + + [[tool.uv.index]] + url = "https://test.pypi.org/simple" + name = "test" + explicit = true + "#})?; + parent.child("src/parent/__init__.py").touch()?; + + // Building the child should use the Test PyPI index for anyio (3.5.x), not regular PyPI (4.3.x). + uv_snapshot!(&filters, context.build().arg("child").env(EnvVars::EXPECTED_ANYIO_VERSION, "3.5"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Building source distribution... + Found expected `anyio` version 3.5.0 + Found expected `anyio` version 3.5.0 + Building wheel from source distribution... + Found expected `anyio` version 3.5.0 + Found expected `anyio` version 3.5.0 + Successfully built child/dist/child-0.1.0.tar.gz + Successfully built child/dist/child-0.1.0-py3-none-any.whl + "); + + Ok(()) +}