implement --no-dedupe for uv pip tree (#4449)

<!--
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 https://github.com/astral-sh/uv/issues/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? -->
This commit is contained in:
Chan Kang 2024-06-24 12:54:55 -04:00 committed by GitHub
parent 03cfdc2275
commit c127632419
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 232 additions and 24 deletions

View file

@ -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"))]

View file

@ -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,
@ -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();
@ -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 {
@ -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,
@ -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() {
@ -136,6 +127,7 @@ impl<'a> DisplayDependencyGraph<'a> {
site_packages,
dist_by_package_name,
required_packages,
no_dedupe,
}
}
@ -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);

View file

@ -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,

View file

@ -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,
}
@ -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,
@ -970,6 +972,7 @@ impl PipTreeSettings {
} = args;
Self {
no_dedupe,
// Shared settings.
shared: PipSettings::combine(
PipOptions {

View file

@ -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 -----
"###
@ -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");