Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 2 commits into from
Jun 24, 2024
Merged
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
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
Loading