Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion crates/uv-resolver/src/resolution/display.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use std::collections::BTreeSet;
use std::path::Path;

use owo_colors::OwoColorize;
use petgraph::visit::EdgeRef;
Expand Down Expand Up @@ -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)]
Expand All @@ -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!(
Expand All @@ -79,6 +85,7 @@ impl<'a> DisplayResolutionGraph<'a> {
include_annotations,
include_index_annotation,
annotation_style,
relative_to,
}
}
}
Expand Down Expand Up @@ -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.
Expand Down
20 changes: 20 additions & 0 deletions crates/uv-resolver/src/resolution/requirements_txt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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}"));
}
Expand Down
23 changes: 14 additions & 9 deletions crates/uv/src/commands/pip/compile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -719,6 +731,7 @@ pub(crate) async fn pip_compile(
include_annotations,
include_index_annotation,
annotation_style,
output_anchor,
)
)?;
}
Expand Down Expand Up @@ -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,
)?;
Expand Down
85 changes: 84 additions & 1 deletion crates/uv/tests/it/pip_compile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5058,6 +5058,89 @@ fn compile_editable_url_requirement() -> Result<()> {
Ok(())
}

/// Regression test for <https://github.com/astral-sh/uv/issues/19091>: 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
Expand Down Expand Up @@ -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
Expand Down
Loading