diff --git a/crates/uv-requirements/src/lookahead.rs b/crates/uv-requirements/src/lookahead.rs index b55bbfd6b..4889d2073 100644 --- a/crates/uv-requirements/src/lookahead.rs +++ b/crates/uv-requirements/src/lookahead.rs @@ -141,38 +141,11 @@ impl<'a, Context: BuildContext> LookaheadResolver<'a, Context> { requirement: Requirement, ) -> Result, LookaheadError> { trace!("Performing lookahead for {requirement}"); + // Determine whether the requirement represents a local distribution and convert to a // buildable distribution. - let dist = match requirement.source { - RequirementSource::Registry { .. } => return Ok(None), - RequirementSource::Url { - subdirectory, - location, - url, - } => Dist::from_http_url(requirement.name, url, location, subdirectory)?, - RequirementSource::Git { - repository, - reference, - precise, - subdirectory, - url, - } => { - let mut git_url = GitUrl::new(repository, reference); - if let Some(precise) = precise { - git_url = git_url.with_precise(precise); - } - Dist::Source(SourceDist::Git(GitSourceDist { - name: requirement.name, - git: Box::new(git_url), - subdirectory, - url, - })) - } - RequirementSource::Path { - path, - url, - editable, - } => Dist::from_file_url(requirement.name, url, &path, editable)?, + let Some(dist) = required_dist(&requirement)? else { + return Ok(None); }; // Fetch the metadata for the distribution. @@ -217,6 +190,21 @@ impl<'a, Context: BuildContext> LookaheadResolver<'a, Context> { } }; + // Respect recursive extras by propagating the source extras to the dependencies. + let requires_dist = requires_dist + .into_iter() + .map(|dependency| { + if dependency.name == requirement.name { + Requirement { + source: requirement.source.clone(), + ..dependency + } + } else { + dependency + } + }) + .collect(); + // Consider the dependencies to be "direct" if the requirement is a local source tree. let direct = if let Dist::Source(source_dist) = &dist { source_dist.as_path().is_some_and(std::path::Path::is_dir) @@ -232,3 +220,43 @@ impl<'a, Context: BuildContext> LookaheadResolver<'a, Context> { ))) } } + +/// Convert a [`Requirement`] into a [`Dist`], if it is a direct URL. +fn required_dist(requirement: &Requirement) -> Result, distribution_types::Error> { + Ok(Some(match &requirement.source { + RequirementSource::Registry { .. } => return Ok(None), + RequirementSource::Url { + subdirectory, + location, + url, + } => Dist::from_http_url( + requirement.name.clone(), + url.clone(), + location.clone(), + subdirectory.clone(), + )?, + RequirementSource::Git { + repository, + reference, + precise, + subdirectory, + url, + } => { + let mut git_url = GitUrl::new(repository.clone(), reference.clone()); + if let Some(precise) = precise { + git_url = git_url.with_precise(*precise); + } + Dist::Source(SourceDist::Git(GitSourceDist { + name: requirement.name.clone(), + git: Box::new(git_url), + subdirectory: subdirectory.clone(), + url: url.clone(), + })) + } + RequirementSource::Path { + path, + url, + editable, + } => Dist::from_file_url(requirement.name.clone(), url.clone(), path, *editable)?, + })) +} diff --git a/crates/uv/tests/pip_install.rs b/crates/uv/tests/pip_install.rs index 1a89e6e03..4f3f50b00 100644 --- a/crates/uv/tests/pip_install.rs +++ b/crates/uv/tests/pip_install.rs @@ -5211,3 +5211,41 @@ fn tool_uv_sources_is_in_preview() -> Result<()> { Ok(()) } + +/// Allow transitive URLs via recursive extras. +#[test] +fn recursive_extra_transitive_url() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(indoc! {r#" + [project] + name = "project" + version = "0.0.0" + dependencies = [] + + [project.optional-dependencies] + all = [ + "project[docs]", + ] + docs = [ + "iniconfig @ https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", + ] + "#})?; + + uv_snapshot!(context.filters(), context.install() + .arg(".[all]"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 2 packages in [TIME] + Downloaded 2 packages in [TIME] + Installed 2 packages in [TIME] + + iniconfig==2.0.0 (from https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl) + + project==0.0.0 (from file://[TEMP_DIR]/) + "###); + + Ok(()) +}