Skip to content

Commit

Permalink
implement --no-dedupe for uv pip tree (#4449)
Browse files Browse the repository at this point in the history
<!--
Thank you for contributing to uv! To help us out with reviewing, please
consider the following:

- Does this pull request include a summary of the change? (See below.)
- Does this pull request include a descriptive title?
- Does this pull request include references to any relevant issues?
-->

## Summary
Resolves #4439 partially.

Implements for `uv pip tree`:
- `--no-dedupe` flag, similar to `cargo tree --no-dedupe` .
- denote dependency cycles with `(#)` and add a footnote if there's a
cycle (using `(*)` would require keeping track of the cycle state, so
opted to do this instead).
<!-- What's the purpose of the change? What does it do, and why? -->

## Test Plan
The existing tests pass + added a couple of tests to validate
`--no-dedupe` behavior.
<!-- How was it tested? -->
  • Loading branch information
ChannyClaus committed Jun 24, 2024
1 parent 03cfdc2 commit c127632
Show file tree
Hide file tree
Showing 5 changed files with 232 additions and 24 deletions.
8 changes: 8 additions & 0 deletions crates/uv-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1385,6 +1385,14 @@ pub struct PipShowArgs {
#[derive(Args)]
#[allow(clippy::struct_excessive_bools)]
pub struct PipTreeArgs {
/// Do not de-duplicate repeated dependencies.
/// Usually, when a package has already displayed its dependencies,
/// further occurrences will not re-display its dependencies,
/// and will include a (*) to indicate it has already been shown.
/// This flag will cause those duplicates to be repeated.
#[arg(long)]
pub no_dedupe: bool,

/// Validate the virtual environment, to detect packages with missing dependencies or other
/// issues.
#[arg(long, overrides_with("no_strict"))]
Expand Down
44 changes: 22 additions & 22 deletions crates/uv/src/commands/pip/tree.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ use pypi_types::VerbatimParsedUrl;

/// Display the installed packages in the current environment as a dependency tree.
pub(crate) fn pip_tree(
no_dedupe: bool,
strict: bool,
python: Option<&str>,
system: bool,
Expand All @@ -43,7 +44,7 @@ pub(crate) fn pip_tree(
// Build the installed index.
let site_packages = SitePackages::from_executable(&environment)?;

let rendered_tree = DisplayDependencyGraph::new(&site_packages)
let rendered_tree = DisplayDependencyGraph::new(&site_packages, no_dedupe)
.render()
.join("\n");
writeln!(printer.stdout(), "{rendered_tree}").unwrap();
Expand All @@ -54,6 +55,9 @@ pub(crate) fn pip_tree(
"(*) Package tree already displayed".italic()
)?;
}
if rendered_tree.contains('#') {
writeln!(printer.stdout(), "{}", "(#) Dependency cycle".italic())?;
}

// Validate that the environment is consistent.
if strict {
Expand Down Expand Up @@ -91,22 +95,6 @@ fn required_with_no_extra(dist: &InstalledDist) -> Vec<pep508_rs::Requirement<Ve
.collect::<Vec<_>>();
}

// Render the line for the given installed distribution in the dependency tree.
fn render_line(installed_dist: &InstalledDist, is_visited: bool) -> String {
let mut line = String::new();
write!(
&mut line,
"{} v{}",
installed_dist.name(),
installed_dist.version()
)
.unwrap();

if is_visited {
line.push_str(" (*)");
}
line
}
#[derive(Debug)]
struct DisplayDependencyGraph<'a> {
site_packages: &'a SitePackages,
Expand All @@ -116,11 +104,14 @@ struct DisplayDependencyGraph<'a> {
// It is used to determine the starting nodes when recursing the
// dependency graph.
required_packages: HashSet<PackageName>,

// Whether to de-duplicate the displayed dependencies.
no_dedupe: bool,
}

impl<'a> DisplayDependencyGraph<'a> {
/// Create a new [`DisplayDependencyGraph`] for the set of installed distributions.
fn new(site_packages: &'a SitePackages) -> DisplayDependencyGraph<'a> {
fn new(site_packages: &'a SitePackages, no_dedupe: bool) -> DisplayDependencyGraph<'a> {
let mut dist_by_package_name = HashMap::new();
let mut required_packages = HashSet::new();
for site_package in site_packages.iter() {
Expand All @@ -136,6 +127,7 @@ impl<'a> DisplayDependencyGraph<'a> {
site_packages,
dist_by_package_name,
required_packages,
no_dedupe,
}
}

Expand All @@ -146,14 +138,22 @@ impl<'a> DisplayDependencyGraph<'a> {
visited: &mut HashSet<String>,
path: &mut Vec<String>,
) -> Vec<String> {
let mut lines = Vec::new();
let package_name = installed_dist.name().to_string();
let is_visited = visited.contains(&package_name);
lines.push(render_line(installed_dist, is_visited));
if is_visited {
return lines;
let line = format!("{} v{}", package_name, installed_dist.version());

if path.contains(&package_name) {
return vec![format!("{} (#)", line)];
}

// If the package has been visited and de-duplication is enabled (default),
// skip the traversal.
if is_visited && !self.no_dedupe {
return vec![format!("{} (*)", line)];
}

let mut lines = vec![line];

path.push(package_name.clone());
visited.insert(package_name.clone());
let required_packages = required_with_no_extra(installed_dist);
Expand Down
1 change: 1 addition & 0 deletions crates/uv/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -546,6 +546,7 @@ async fn run() -> Result<ExitStatus> {
let cache = cache.init()?;

commands::pip_tree(
args.no_dedupe,
args.shared.strict,
args.shared.python.as_deref(),
args.shared.system,
Expand Down
3 changes: 3 additions & 0 deletions crates/uv/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -954,6 +954,7 @@ impl PipShowSettings {
#[allow(clippy::struct_excessive_bools)]
#[derive(Debug, Clone)]
pub(crate) struct PipTreeSettings {
pub(crate) no_dedupe: bool,
// CLI-only settings.
pub(crate) shared: PipSettings,
}
Expand All @@ -962,6 +963,7 @@ impl PipTreeSettings {
/// Resolve the [`PipTreeSettings`] from the CLI and workspace configuration.
pub(crate) fn resolve(args: PipTreeArgs, filesystem: Option<FilesystemOptions>) -> Self {
let PipTreeArgs {
no_dedupe,
strict,
no_strict,
python,
Expand All @@ -970,6 +972,7 @@ impl PipTreeSettings {
} = args;

Self {
no_dedupe,
// Shared settings.
shared: PipSettings::combine(
PipOptions {
Expand Down
200 changes: 198 additions & 2 deletions crates/uv/tests/pip_tree.rs
Original file line number Diff line number Diff line change
Expand Up @@ -328,8 +328,8 @@ fn cyclic_dependency() {
uv-cyclic-dependencies-c v0.1.0
└── uv-cyclic-dependencies-a v0.1.0
└── uv-cyclic-dependencies-b v0.1.0
└── uv-cyclic-dependencies-a v0.1.0 (*)
(*) Package tree already displayed
└── uv-cyclic-dependencies-a v0.1.0 (#)
(#) Dependency cycle
----- stderr -----
"###
Expand Down Expand Up @@ -541,6 +541,202 @@ fn multiple_packages_shared_descendant() {
);
}

// Ensure that --no-dedupe behaves as expected
// in the presence of dependency cycles.
#[test]
#[cfg(not(windows))]
fn no_dedupe_and_cycle() {
let context = TestContext::new("3.12");

let requirements_txt = context.temp_dir.child("requirements.txt");
requirements_txt
.write_str(
r"
pendulum==3.0.0
boto3==1.34.69
",
)
.unwrap();

uv_snapshot!(install_command(&context)
.arg("-r")
.arg("requirements.txt")
.arg("--strict"), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 10 packages in [TIME]
Prepared 10 packages in [TIME]
Installed 10 packages in [TIME]
+ boto3==1.34.69
+ botocore==1.34.69
+ jmespath==1.0.1
+ pendulum==3.0.0
+ python-dateutil==2.9.0.post0
+ s3transfer==0.10.1
+ six==1.16.0
+ time-machine==2.14.1
+ tzdata==2024.1
+ urllib3==2.2.1
"###
);

let mut command = Command::new(get_bin());
command
.arg("pip")
.arg("install")
.arg("uv-cyclic-dependencies-c==0.1.0")
.arg("--cache-dir")
.arg(context.cache_dir.path())
.arg("--index-url")
.arg("https://test.pypi.org/simple/")
.env("VIRTUAL_ENV", context.venv.as_os_str())
.env("UV_NO_WRAP", "1")
.current_dir(&context.temp_dir);
if cfg!(all(windows, debug_assertions)) {
// TODO(konstin): Reduce stack usage in debug mode enough that the tests pass with the
// default windows stack of 1MB
command.env("UV_STACK_SIZE", (2 * 1024 * 1024).to_string());
}

uv_snapshot!(context.filters(), command, @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 3 packages in [TIME]
Prepared 3 packages in [TIME]
Installed 3 packages in [TIME]
+ uv-cyclic-dependencies-a==0.1.0
+ uv-cyclic-dependencies-b==0.1.0
+ uv-cyclic-dependencies-c==0.1.0
"###
);

uv_snapshot!(context.filters(), Command::new(get_bin())
.arg("pip")
.arg("tree")
.arg("--cache-dir")
.arg(context.cache_dir.path())
.arg("--no-dedupe")
.env("VIRTUAL_ENV", context.venv.as_os_str())
.env("UV_NO_WRAP", "1")
.current_dir(&context.temp_dir), @r###"
success: true
exit_code: 0
----- stdout -----
boto3 v1.34.69
├── botocore v1.34.69
│ ├── jmespath v1.0.1
│ └── python-dateutil v2.9.0.post0
│ └── six v1.16.0
├── jmespath v1.0.1
└── s3transfer v0.10.1
└── botocore v1.34.69
├── jmespath v1.0.1
└── python-dateutil v2.9.0.post0
└── six v1.16.0
pendulum v3.0.0
├── python-dateutil v2.9.0.post0
│ └── six v1.16.0
└── tzdata v2024.1
time-machine v2.14.1
└── python-dateutil v2.9.0.post0
└── six v1.16.0
urllib3 v2.2.1
uv-cyclic-dependencies-c v0.1.0
└── uv-cyclic-dependencies-a v0.1.0
└── uv-cyclic-dependencies-b v0.1.0
└── uv-cyclic-dependencies-a v0.1.0 (#)
(#) Dependency cycle
----- stderr -----
"###
);
}

#[test]
#[cfg(not(windows))]
fn no_dedupe() {
let context = TestContext::new("3.12");

let requirements_txt = context.temp_dir.child("requirements.txt");
requirements_txt
.write_str(
r"
pendulum==3.0.0
boto3==1.34.69
",
)
.unwrap();

uv_snapshot!(install_command(&context)
.arg("-r")
.arg("requirements.txt")
.arg("--strict"), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 10 packages in [TIME]
Prepared 10 packages in [TIME]
Installed 10 packages in [TIME]
+ boto3==1.34.69
+ botocore==1.34.69
+ jmespath==1.0.1
+ pendulum==3.0.0
+ python-dateutil==2.9.0.post0
+ s3transfer==0.10.1
+ six==1.16.0
+ time-machine==2.14.1
+ tzdata==2024.1
+ urllib3==2.2.1
"###
);

uv_snapshot!(context.filters(), Command::new(get_bin())
.arg("pip")
.arg("tree")
.arg("--cache-dir")
.arg(context.cache_dir.path())
.arg("--no-dedupe")
.env("VIRTUAL_ENV", context.venv.as_os_str())
.env("UV_NO_WRAP", "1")
.current_dir(&context.temp_dir), @r###"
success: true
exit_code: 0
----- stdout -----
boto3 v1.34.69
├── botocore v1.34.69
│ ├── jmespath v1.0.1
│ └── python-dateutil v2.9.0.post0
│ └── six v1.16.0
├── jmespath v1.0.1
└── s3transfer v0.10.1
└── botocore v1.34.69
├── jmespath v1.0.1
└── python-dateutil v2.9.0.post0
└── six v1.16.0
pendulum v3.0.0
├── python-dateutil v2.9.0.post0
│ └── six v1.16.0
└── tzdata v2024.1
time-machine v2.14.1
└── python-dateutil v2.9.0.post0
└── six v1.16.0
urllib3 v2.2.1
----- stderr -----
"###
);
}

#[test]
fn with_editable() {
let context = TestContext::new("3.12");
Expand Down

0 comments on commit c127632

Please sign in to comment.