diff --git a/crates/uv-resolver/src/resolution/display.rs b/crates/uv-resolver/src/resolution/display.rs index 2be6e756e03f2..b18a90172ff3b 100644 --- a/crates/uv-resolver/src/resolution/display.rs +++ b/crates/uv-resolver/src/resolution/display.rs @@ -1,4 +1,5 @@ use std::collections::BTreeSet; +use std::path::Path; use owo_colors::OwoColorize; use petgraph::visit::EdgeRef; @@ -35,6 +36,10 @@ pub struct DisplayResolutionGraph<'a> { /// The style of annotation comments, used to indicate the dependencies that requested each /// package. annotation_style: AnnotationStyle, + /// The directory to which local paths in the output should be relative. Used to rewrite + /// editable paths discovered transitively, which would otherwise be emitted relative to the + /// `pyproject.toml` that declared them. + relative_to: &'a Path, } #[derive(Debug)] @@ -61,6 +66,7 @@ impl<'a> DisplayResolutionGraph<'a> { include_annotations: bool, include_index_annotation: bool, annotation_style: AnnotationStyle, + relative_to: &'a Path, ) -> Self { for fork_marker in &underlying.fork_markers { assert!( @@ -79,6 +85,7 @@ impl<'a> DisplayResolutionGraph<'a> { include_annotations, include_index_annotation, annotation_style, + relative_to, } } } @@ -186,7 +193,11 @@ impl std::fmt::Display for DisplayResolutionGraph<'_> { for (index, node) in nodes { // Display the node itself. let mut line = node - .to_requirements_txt(&self.resolution.requires_python, self.include_markers) + .to_requirements_txt( + &self.resolution.requires_python, + self.include_markers, + self.relative_to, + ) .to_string(); // Display the distribution hashes, if any. diff --git a/crates/uv-resolver/src/resolution/requirements_txt.rs b/crates/uv-resolver/src/resolution/requirements_txt.rs index bfb4caef671e6..0c17730d8adc7 100644 --- a/crates/uv-resolver/src/resolution/requirements_txt.rs +++ b/crates/uv-resolver/src/resolution/requirements_txt.rs @@ -8,6 +8,7 @@ use uv_distribution_types::{ DistributionMetadata, Name, RequiresPython, ResolvedDist, SimplifiedMarkerTree, Verbatim, VersionOrUrlRef, }; +use uv_fs::{PortablePath, try_relative_to_if}; use uv_normalize::{ExtraName, PackageName}; use uv_pep440::Version; use uv_pep508::{MarkerTree, Scheme, split_scheme}; @@ -36,10 +37,29 @@ impl<'dist> RequirementsTxtDist<'dist> { &self, requires_python: &RequiresPython, include_markers: bool, + relative_to: &Path, ) -> Cow<'_, str> { // If the URL is editable, write it as an editable requirement. if self.dist.is_editable() { if let VersionOrUrlRef::Url(url) = self.dist.version_or_url() { + // If the URL was originally given as a relative path, recompute the path so it's + // relative to the requirements file's anchor (typically the directory containing + // the output `requirements.txt` or, when writing to stdout, the working directory). + // Without this, transitive editable dependencies that were declared in another + // package's `pyproject.toml` would have paths relative to that package, not to the + // requirements file we're emitting. + // + // Skip the rewrite when the input contained an environment variable reference + // (e.g., `${PROJECT_ROOT}`), since callers typically want those preserved verbatim. + // Match the `${NAME}` form expanded by `uv_pep508::expand_env_vars`; bare `$` in + // a path (legitimate on Unix) shouldn't suppress the rewrite. + if !url.was_given_absolute() + && url.given().is_some_and(|given| !given.contains("${")) + && let Some(install_path) = self.dist.source_tree() + && let Ok(path) = try_relative_to_if(install_path, relative_to, true) + { + return Cow::Owned(format!("-e {}", PortablePath::from(&path))); + } let given = url.verbatim(); return Cow::Owned(format!("-e {given}")); } diff --git a/crates/uv/src/commands/pip/compile.rs b/crates/uv/src/commands/pip/compile.rs index b0d38e6742e3c..57985730bde52 100644 --- a/crates/uv/src/commands/pip/compile.rs +++ b/crates/uv/src/commands/pip/compile.rs @@ -609,6 +609,18 @@ pub(crate) async fn pip_compile( // Write the resolved dependencies to the output channel. let mut writer = OutputWriter::new(!quiet || output_file.is_none(), output_file); + // Determine the directory relative to which local paths in the output should be written. When + // the user passes `-o requirements.txt`, this is the directory containing that file; otherwise, + // it's the working directory. This anchor is used to rewrite editable paths discovered + // transitively (e.g., from a dependency's `pyproject.toml`) so they're meaningful from the + // perspective of the emitted requirements file. + let absolute_output_file = output_file.map(std::path::absolute).transpose()?; + let output_anchor: &Path = if let Some(output_file) = absolute_output_file.as_deref() { + output_file.parent().unwrap_or(&CWD) + } else { + &CWD + }; + if include_header { writeln!( writer, @@ -719,6 +731,7 @@ pub(crate) async fn pip_compile( include_annotations, include_index_annotation, annotation_style, + output_anchor, ) )?; } @@ -749,19 +762,11 @@ pub(crate) async fn pip_compile( ); } - // Determine the directory relative to which the output file should be written. - let output_file = output_file.map(std::path::absolute).transpose()?; - let install_path = if let Some(output_file) = output_file.as_deref() { - output_file.parent().unwrap() - } else { - &*CWD - }; - // Convert the resolution to a `pylock.toml` file. let export = PylockToml::from_resolution( &resolution, &no_emit_packages, - install_path, + output_anchor, tags.as_deref(), &build_options, )?; diff --git a/crates/uv/tests/it/pip_compile.rs b/crates/uv/tests/it/pip_compile.rs index 67dac87b0ec74..4163247e78375 100644 --- a/crates/uv/tests/it/pip_compile.rs +++ b/crates/uv/tests/it/pip_compile.rs @@ -5058,6 +5058,89 @@ fn compile_editable_url_requirement() -> Result<()> { Ok(()) } +/// Regression test for : a transitive editable +/// dependency declared via `[tool.uv.sources]` should be emitted with a path relative to the +/// requirements file's directory, not relative to the `pyproject.toml` that declared it. +#[test] +fn compile_transitive_editable_relative_path() -> Result<()> { + let context = uv_test::test_context!("3.12"); + + // Create `demo-lib-1` (a leaf editable dependency). + let demo_lib_1 = context.temp_dir.child("demo-lib-1"); + demo_lib_1.create_dir_all()?; + demo_lib_1.child("pyproject.toml").write_str(indoc! {r#" + [project] + name = "demo-lib-1" + version = "1.0.0" + requires-python = ">=3.10" + "#})?; + demo_lib_1.child("demo_lib_1").create_dir_all()?; + demo_lib_1.child("demo_lib_1/__init__.py").write_str("")?; + + // Create `demo-lib-2`, which depends on `demo-lib-1` as an editable source via a path that's + // relative to `demo-lib-2`'s own `pyproject.toml`. + let demo_lib_2 = context.temp_dir.child("demo-lib-2"); + demo_lib_2.create_dir_all()?; + demo_lib_2.child("pyproject.toml").write_str(indoc! {r#" + [project] + name = "demo-lib-2" + version = "1.0.0" + requires-python = ">=3.10" + dependencies = ["demo-lib-1"] + + [tool.uv.sources] + demo-lib-1 = { path = "../demo-lib-1", editable = true } + "#})?; + demo_lib_2.child("demo_lib_2").create_dir_all()?; + demo_lib_2.child("demo_lib_2/__init__.py").write_str("")?; + + // Place the `requirements.in` two levels deep so a buggy emission (path verbatim from + // `demo-lib-2`'s `pyproject.toml`) would be visibly wrong. + let my_app_reqs = context.temp_dir.child("subdir/my_app_reqs"); + my_app_reqs.create_dir_all()?; + my_app_reqs + .child("requirements.in") + .write_str("-e ../../demo-lib-2\n")?; + + // Stdout path: anchor falls back to cwd. + uv_snapshot!(context.filters(), context.pip_compile() + .arg("requirements.in") + .arg("--no-annotate") + .current_dir(my_app_reqs.path()), @r" + success: true + exit_code: 0 + ----- stdout ----- + # This file was autogenerated by uv via the following command: + # uv pip compile --cache-dir [CACHE_DIR] requirements.in --no-annotate + -e ../../demo-lib-2 + -e ../../demo-lib-1 + + ----- stderr ----- + Resolved 2 packages in [TIME] + "); + + // `-o` path: anchor comes from the output file's parent directory. Same expected paths. + uv_snapshot!(context.filters(), context.pip_compile() + .arg("requirements.in") + .arg("--no-annotate") + .arg("-o") + .arg("requirements.txt") + .current_dir(my_app_reqs.path()), @r" + success: true + exit_code: 0 + ----- stdout ----- + # This file was autogenerated by uv via the following command: + # uv pip compile --cache-dir [CACHE_DIR] requirements.in --no-annotate -o requirements.txt + -e ../../demo-lib-2 + -e ../../demo-lib-1 + + ----- stderr ----- + Resolved 2 packages in [TIME] + "); + + Ok(()) +} + /// Resolve a distribution from an HTML-only registry. #[test] #[cfg(not(target_env = "musl"))] // No musllinux wheels in the torch index @@ -13658,7 +13741,7 @@ fn tool_uv_sources() -> Result<()> { ----- stdout ----- # This file was autogenerated by uv via the following command: # uv pip compile --cache-dir [CACHE_DIR] some_dir/pyproject.toml --extra utils - -e ../poetry_editable + -e poetry_editable # via project (some_dir/pyproject.toml) anyio==4.3.0 # via poetry-editable