mirror of
https://github.com/astral-sh/uv.git
synced 2025-10-02 06:51:14 +00:00
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:
parent
03cfdc2275
commit
c127632419
5 changed files with 232 additions and 24 deletions
|
@ -1385,6 +1385,14 @@ pub struct PipShowArgs {
|
||||||
#[derive(Args)]
|
#[derive(Args)]
|
||||||
#[allow(clippy::struct_excessive_bools)]
|
#[allow(clippy::struct_excessive_bools)]
|
||||||
pub struct PipTreeArgs {
|
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
|
/// Validate the virtual environment, to detect packages with missing dependencies or other
|
||||||
/// issues.
|
/// issues.
|
||||||
#[arg(long, overrides_with("no_strict"))]
|
#[arg(long, overrides_with("no_strict"))]
|
||||||
|
|
|
@ -20,6 +20,7 @@ use pypi_types::VerbatimParsedUrl;
|
||||||
|
|
||||||
/// Display the installed packages in the current environment as a dependency tree.
|
/// Display the installed packages in the current environment as a dependency tree.
|
||||||
pub(crate) fn pip_tree(
|
pub(crate) fn pip_tree(
|
||||||
|
no_dedupe: bool,
|
||||||
strict: bool,
|
strict: bool,
|
||||||
python: Option<&str>,
|
python: Option<&str>,
|
||||||
system: bool,
|
system: bool,
|
||||||
|
@ -43,7 +44,7 @@ pub(crate) fn pip_tree(
|
||||||
// Build the installed index.
|
// Build the installed index.
|
||||||
let site_packages = SitePackages::from_executable(&environment)?;
|
let site_packages = SitePackages::from_executable(&environment)?;
|
||||||
|
|
||||||
let rendered_tree = DisplayDependencyGraph::new(&site_packages)
|
let rendered_tree = DisplayDependencyGraph::new(&site_packages, no_dedupe)
|
||||||
.render()
|
.render()
|
||||||
.join("\n");
|
.join("\n");
|
||||||
writeln!(printer.stdout(), "{rendered_tree}").unwrap();
|
writeln!(printer.stdout(), "{rendered_tree}").unwrap();
|
||||||
|
@ -54,6 +55,9 @@ pub(crate) fn pip_tree(
|
||||||
"(*) Package tree already displayed".italic()
|
"(*) Package tree already displayed".italic()
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
|
if rendered_tree.contains('#') {
|
||||||
|
writeln!(printer.stdout(), "{}", "(#) Dependency cycle".italic())?;
|
||||||
|
}
|
||||||
|
|
||||||
// Validate that the environment is consistent.
|
// Validate that the environment is consistent.
|
||||||
if strict {
|
if strict {
|
||||||
|
@ -91,22 +95,6 @@ fn required_with_no_extra(dist: &InstalledDist) -> Vec<pep508_rs::Requirement<Ve
|
||||||
.collect::<Vec<_>>();
|
.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)]
|
#[derive(Debug)]
|
||||||
struct DisplayDependencyGraph<'a> {
|
struct DisplayDependencyGraph<'a> {
|
||||||
site_packages: &'a SitePackages,
|
site_packages: &'a SitePackages,
|
||||||
|
@ -116,11 +104,14 @@ struct DisplayDependencyGraph<'a> {
|
||||||
// It is used to determine the starting nodes when recursing the
|
// It is used to determine the starting nodes when recursing the
|
||||||
// dependency graph.
|
// dependency graph.
|
||||||
required_packages: HashSet<PackageName>,
|
required_packages: HashSet<PackageName>,
|
||||||
|
|
||||||
|
// Whether to de-duplicate the displayed dependencies.
|
||||||
|
no_dedupe: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> DisplayDependencyGraph<'a> {
|
impl<'a> DisplayDependencyGraph<'a> {
|
||||||
/// Create a new [`DisplayDependencyGraph`] for the set of installed distributions.
|
/// 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 dist_by_package_name = HashMap::new();
|
||||||
let mut required_packages = HashSet::new();
|
let mut required_packages = HashSet::new();
|
||||||
for site_package in site_packages.iter() {
|
for site_package in site_packages.iter() {
|
||||||
|
@ -136,6 +127,7 @@ impl<'a> DisplayDependencyGraph<'a> {
|
||||||
site_packages,
|
site_packages,
|
||||||
dist_by_package_name,
|
dist_by_package_name,
|
||||||
required_packages,
|
required_packages,
|
||||||
|
no_dedupe,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -146,14 +138,22 @@ impl<'a> DisplayDependencyGraph<'a> {
|
||||||
visited: &mut HashSet<String>,
|
visited: &mut HashSet<String>,
|
||||||
path: &mut Vec<String>,
|
path: &mut Vec<String>,
|
||||||
) -> Vec<String> {
|
) -> Vec<String> {
|
||||||
let mut lines = Vec::new();
|
|
||||||
let package_name = installed_dist.name().to_string();
|
let package_name = installed_dist.name().to_string();
|
||||||
let is_visited = visited.contains(&package_name);
|
let is_visited = visited.contains(&package_name);
|
||||||
lines.push(render_line(installed_dist, is_visited));
|
let line = format!("{} v{}", package_name, installed_dist.version());
|
||||||
if is_visited {
|
|
||||||
return lines;
|
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());
|
path.push(package_name.clone());
|
||||||
visited.insert(package_name.clone());
|
visited.insert(package_name.clone());
|
||||||
let required_packages = required_with_no_extra(installed_dist);
|
let required_packages = required_with_no_extra(installed_dist);
|
||||||
|
|
|
@ -546,6 +546,7 @@ async fn run() -> Result<ExitStatus> {
|
||||||
let cache = cache.init()?;
|
let cache = cache.init()?;
|
||||||
|
|
||||||
commands::pip_tree(
|
commands::pip_tree(
|
||||||
|
args.no_dedupe,
|
||||||
args.shared.strict,
|
args.shared.strict,
|
||||||
args.shared.python.as_deref(),
|
args.shared.python.as_deref(),
|
||||||
args.shared.system,
|
args.shared.system,
|
||||||
|
|
|
@ -954,6 +954,7 @@ impl PipShowSettings {
|
||||||
#[allow(clippy::struct_excessive_bools)]
|
#[allow(clippy::struct_excessive_bools)]
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub(crate) struct PipTreeSettings {
|
pub(crate) struct PipTreeSettings {
|
||||||
|
pub(crate) no_dedupe: bool,
|
||||||
// CLI-only settings.
|
// CLI-only settings.
|
||||||
pub(crate) shared: PipSettings,
|
pub(crate) shared: PipSettings,
|
||||||
}
|
}
|
||||||
|
@ -962,6 +963,7 @@ impl PipTreeSettings {
|
||||||
/// Resolve the [`PipTreeSettings`] from the CLI and workspace configuration.
|
/// Resolve the [`PipTreeSettings`] from the CLI and workspace configuration.
|
||||||
pub(crate) fn resolve(args: PipTreeArgs, filesystem: Option<FilesystemOptions>) -> Self {
|
pub(crate) fn resolve(args: PipTreeArgs, filesystem: Option<FilesystemOptions>) -> Self {
|
||||||
let PipTreeArgs {
|
let PipTreeArgs {
|
||||||
|
no_dedupe,
|
||||||
strict,
|
strict,
|
||||||
no_strict,
|
no_strict,
|
||||||
python,
|
python,
|
||||||
|
@ -970,6 +972,7 @@ impl PipTreeSettings {
|
||||||
} = args;
|
} = args;
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
|
no_dedupe,
|
||||||
// Shared settings.
|
// Shared settings.
|
||||||
shared: PipSettings::combine(
|
shared: PipSettings::combine(
|
||||||
PipOptions {
|
PipOptions {
|
||||||
|
|
|
@ -328,8 +328,8 @@ fn cyclic_dependency() {
|
||||||
uv-cyclic-dependencies-c v0.1.0
|
uv-cyclic-dependencies-c v0.1.0
|
||||||
└── uv-cyclic-dependencies-a v0.1.0
|
└── uv-cyclic-dependencies-a v0.1.0
|
||||||
└── uv-cyclic-dependencies-b v0.1.0
|
└── uv-cyclic-dependencies-b v0.1.0
|
||||||
└── uv-cyclic-dependencies-a v0.1.0 (*)
|
└── uv-cyclic-dependencies-a v0.1.0 (#)
|
||||||
(*) Package tree already displayed
|
(#) Dependency cycle
|
||||||
|
|
||||||
----- stderr -----
|
----- 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]
|
#[test]
|
||||||
fn with_editable() {
|
fn with_editable() {
|
||||||
let context = TestContext::new("3.12");
|
let context = TestContext::new("3.12");
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue