implement uv pip tree (#3859)

## Summary

resolves https://github.com/astral-sh/uv/issues/3272

added it as a new subcommand rather than a flag on an existing
command since that seems more consistent with `cargo tree` + cleaner
code organization, but can make changes if it's preferred the other way.
This commit is contained in:
Chan Kang
2024-06-21 15:48:30 -04:00
committed by GitHub
parent 7e574f5e20
commit dd45fce2d4
7 changed files with 942 additions and 3 deletions
+48
View File
@@ -227,6 +227,8 @@ pub(crate) enum PipCommand {
List(PipListArgs),
/// Show information about one or more installed packages.
Show(PipShowArgs),
/// Display the dependency tree.
Tree(PipTreeArgs),
/// Verify installed packages have compatible dependencies.
Check(PipCheckArgs),
}
@@ -1344,6 +1346,52 @@ pub(crate) struct PipShowArgs {
pub(crate) no_system: bool,
}
#[derive(Args)]
#[allow(clippy::struct_excessive_bools)]
pub(crate) struct PipTreeArgs {
/// Validate the virtual environment, to detect packages with missing dependencies or other
/// issues.
#[arg(long, overrides_with("no_strict"))]
pub(crate) strict: bool,
#[arg(long, overrides_with("strict"), hide = true)]
pub(crate) no_strict: bool,
/// The Python interpreter for which packages should be listed.
///
/// By default, `uv` lists packages in the currently activated virtual environment, or a virtual
/// environment (`.venv`) located in the current working directory or any parent directory,
/// falling back to the system Python if no virtual environment is found.
///
/// Supported formats:
/// - `3.10` looks for an installed Python 3.10 using `py --list-paths` on Windows, or
/// `python3.10` on Linux and macOS.
/// - `python3.10` or `python.exe` looks for a binary with the given name in `PATH`.
/// - `/home/ferris/.local/bin/python3.10` uses the exact Python at the given path.
#[arg(long, short, env = "UV_PYTHON", verbatim_doc_comment)]
pub(crate) python: Option<String>,
/// List packages for the system Python.
///
/// By default, `uv` lists packages in the currently activated virtual environment, or a virtual
/// environment (`.venv`) located in the current working directory or any parent directory,
/// falling back to the system Python if no virtual environment is found. The `--system` option
/// instructs `uv` to use the first Python found in the system `PATH`.
///
/// WARNING: `--system` is intended for use in continuous integration (CI) environments and
/// should be used with caution.
#[arg(
long,
env = "UV_SYSTEM_PYTHON",
value_parser = clap::builder::BoolishValueParser::new(),
overrides_with("no_system")
)]
pub(crate) system: bool,
#[arg(long, overrides_with("system"))]
pub(crate) no_system: bool,
}
#[derive(Args)]
#[allow(clippy::struct_excessive_bools)]
pub(crate) struct VenvArgs {
+1
View File
@@ -15,6 +15,7 @@ pub(crate) use pip::install::pip_install;
pub(crate) use pip::list::pip_list;
pub(crate) use pip::show::pip_show;
pub(crate) use pip::sync::pip_sync;
pub(crate) use pip::tree::pip_tree;
pub(crate) use pip::uninstall::pip_uninstall;
pub(crate) use project::add::add;
pub(crate) use project::lock::lock;
+1
View File
@@ -13,6 +13,7 @@ pub(crate) mod list;
pub(crate) mod operations;
pub(crate) mod show;
pub(crate) mod sync;
pub(crate) mod tree;
pub(crate) mod uninstall;
// Determine the tags, markers, and interpreter to use for resolution.
+235
View File
@@ -0,0 +1,235 @@
use std::fmt::Write;
use distribution_types::{Diagnostic, InstalledDist, Name};
use owo_colors::OwoColorize;
use tracing::debug;
use uv_cache::Cache;
use uv_configuration::PreviewMode;
use uv_fs::Simplified;
use uv_installer::SitePackages;
use uv_normalize::PackageName;
use uv_toolchain::EnvironmentPreference;
use uv_toolchain::PythonEnvironment;
use uv_toolchain::ToolchainRequest;
use crate::commands::ExitStatus;
use crate::printer::Printer;
use std::collections::{HashMap, HashSet};
use pypi_types::VerbatimParsedUrl;
/// Display the installed packages in the current environment as a dependency tree.
pub(crate) fn pip_tree(
strict: bool,
python: Option<&str>,
system: bool,
_preview: PreviewMode,
cache: &Cache,
printer: Printer,
) -> anyhow::Result<ExitStatus> {
// Detect the current Python interpreter.
let environment = PythonEnvironment::find(
&python.map(ToolchainRequest::parse).unwrap_or_default(),
EnvironmentPreference::from_system_flag(system, false),
cache,
)?;
debug!(
"Using Python {} environment at {}",
environment.interpreter().python_version(),
environment.python_executable().user_display().cyan()
);
// Build the installed index.
let site_packages = SitePackages::from_executable(&environment)?;
let rendered_tree = DisplayDependencyGraph::new(&site_packages)
.render()
.join("\n");
writeln!(printer.stdout(), "{rendered_tree}").unwrap();
if rendered_tree.contains('*') {
writeln!(
printer.stdout(),
r#"{}: (*) indicates the package has been `de-duplicated`.
The dependencies for the package have already been shown elsewhere in the graph, and so are not repeated."#,
"Note".yellow().bold()
)?;
}
// Validate that the environment is consistent.
if strict {
for diagnostic in site_packages.diagnostics()? {
writeln!(
printer.stderr(),
"{}{} {}",
"warning".yellow().bold(),
":".bold(),
diagnostic.message().bold()
)?;
}
}
Ok(ExitStatus::Success)
}
// Filter out all required packages of the given distribution if they
// are required by an extra.
// For example, `requests==2.32.3` requires `charset-normalizer`, `idna`, `urllib`, and `certifi` at
// all times, `PySocks` on `socks` extra and `chardet` on `use_chardet_on_py3` extra.
// This function will return `["charset-normalizer", "idna", "urllib", "certifi"]` for `requests`.
fn required_with_no_extra(dist: &InstalledDist) -> Vec<pep508_rs::Requirement<VerbatimParsedUrl>> {
let metadata = dist.metadata().unwrap();
return metadata
.requires_dist
.into_iter()
.filter(|r| {
r.marker.is_none()
|| !r
.marker
.as_ref()
.unwrap()
.evaluate_optional_environment(None, &metadata.provides_extras[..])
})
.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,
// Map from package name to the installed distribution.
dist_by_package_name: HashMap<&'a PackageName, &'a InstalledDist>,
// Set of package names that are required by at least one installed distribution.
// It is used to determine the starting nodes when recursing the
// dependency graph.
required_packages: HashSet<PackageName>,
}
impl<'a> DisplayDependencyGraph<'a> {
/// Create a new [`DisplayDependencyGraph`] for the set of installed distributions.
fn new(site_packages: &'a SitePackages) -> DisplayDependencyGraph<'a> {
let mut dist_by_package_name = HashMap::new();
let mut required_packages = HashSet::new();
for site_package in site_packages.iter() {
dist_by_package_name.insert(site_package.name(), site_package);
}
for site_package in site_packages.iter() {
for required in required_with_no_extra(site_package) {
required_packages.insert(required.name.clone());
}
}
Self {
site_packages,
dist_by_package_name,
required_packages,
}
}
// Depth-first traversal of the given distribution and its dependencies.
fn visit(
&self,
installed_dist: &InstalledDist,
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;
}
path.push(package_name.clone());
visited.insert(package_name.clone());
let required_packages = required_with_no_extra(installed_dist);
for (index, required_package) in required_packages.iter().enumerate() {
// Skip if the current package is not one of the installed distributions.
if !self
.dist_by_package_name
.contains_key(&required_package.name)
{
continue;
}
// For sub-visited packages, add the prefix to make the tree display user-friendly.
// The key observation here is you can group the tree as follows when you're at the
// root of the tree:
// root_package
// ├── level_1_0 // Group 1
// │ ├── level_2_0 ...
// │ │ ├── level_3_0 ...
// │ │ └── level_3_1 ...
// │ └── level_2_1 ...
// ├── level_1_1 // Group 2
// │ ├── level_2_2 ...
// │ └── level_2_3 ...
// └── level_1_2 // Group 3
// └── level_2_4 ...
//
// The lines in Group 1 and 2 have `├── ` at the top and `| ` at the rest while
// those in Group 3 have `└── ` at the top and ` ` at the rest.
// This observation is true recursively even when looking at the subtree rooted
// at `level_1_0`.
let (prefix_top, prefix_rest) = if required_packages.len() - 1 == index {
("└── ", " ")
} else {
("├── ", "")
};
let mut prefixed_lines = Vec::new();
for (visited_index, visited_line) in self
.visit(
self.dist_by_package_name[&required_package.name],
visited,
path,
)
.iter()
.enumerate()
{
prefixed_lines.push(format!(
"{}{}",
if visited_index == 0 {
prefix_top
} else {
prefix_rest
},
visited_line
));
}
lines.extend(prefixed_lines);
}
path.pop();
lines
}
// Depth-first traverse the nodes to render the tree.
// The starting nodes are the ones without incoming edges.
fn render(&self) -> Vec<String> {
let mut visited: HashSet<String> = HashSet::new();
let mut lines: Vec<String> = Vec::new();
for site_package in self.site_packages.iter() {
// If the current package is not required by any other package, start the traversal
// with the current package as the root.
if !self.required_packages.contains(site_package.name()) {
lines.extend(self.visit(site_package, &mut visited, &mut Vec::new()));
}
}
lines
}
}
+25
View File
@@ -9,6 +9,7 @@ use anyhow::Result;
use clap::error::{ContextKind, ContextValue};
use clap::{CommandFactory, Parser};
use owo_colors::OwoColorize;
use settings::PipTreeSettings;
use tracing::{debug, instrument};
use cli::{ToolCommand, ToolNamespace, ToolchainCommand, ToolchainNamespace};
@@ -105,6 +106,12 @@ async fn run() -> Result<ExitStatus> {
ContextValue::String("uv pip show".to_string()),
);
}
"tree" => {
err.insert(
ContextKind::SuggestedSubcommand,
ContextValue::String("uv pip tree".to_string()),
);
}
_ => {}
}
}
@@ -530,6 +537,24 @@ async fn run() -> Result<ExitStatus> {
printer,
)
}
Commands::Pip(PipNamespace {
command: PipCommand::Tree(args),
}) => {
// Resolve the settings from the command-line arguments and workspace configuration.
let args = PipTreeSettings::resolve(args, filesystem);
// Initialize the cache.
let cache = cache.init()?;
commands::pip_tree(
args.shared.strict,
args.shared.python.as_deref(),
args.shared.system,
globals.preview,
&cache,
printer,
)
}
Commands::Pip(PipNamespace {
command: PipCommand::Check(args),
}) => {
+37 -3
View File
@@ -27,9 +27,9 @@ use uv_toolchain::{Prefix, PythonVersion, Target, ToolchainPreference};
use crate::cli::{
AddArgs, BuildArgs, ColorChoice, Commands, ExternalCommand, GlobalArgs, IndexArgs,
InstallerArgs, LockArgs, Maybe, PipCheckArgs, PipCompileArgs, PipFreezeArgs, PipInstallArgs,
PipListArgs, PipShowArgs, PipSyncArgs, PipUninstallArgs, RefreshArgs, RemoveArgs, ResolverArgs,
ResolverInstallerArgs, RunArgs, SyncArgs, ToolRunArgs, ToolchainFindArgs, ToolchainInstallArgs,
ToolchainListArgs, VenvArgs,
PipListArgs, PipShowArgs, PipSyncArgs, PipTreeArgs, PipUninstallArgs, RefreshArgs, RemoveArgs,
ResolverArgs, ResolverInstallerArgs, RunArgs, SyncArgs, ToolRunArgs, ToolchainFindArgs,
ToolchainInstallArgs, ToolchainListArgs, VenvArgs,
};
use crate::commands::pip::operations::Modifications;
use crate::commands::ListFormat;
@@ -948,6 +948,40 @@ impl PipShowSettings {
}
}
/// The resolved settings to use for a `pip show` invocation.
#[allow(clippy::struct_excessive_bools)]
#[derive(Debug, Clone)]
pub(crate) struct PipTreeSettings {
// CLI-only settings.
pub(crate) shared: PipSettings,
}
impl PipTreeSettings {
/// Resolve the [`PipTreeSettings`] from the CLI and workspace configuration.
pub(crate) fn resolve(args: PipTreeArgs, filesystem: Option<FilesystemOptions>) -> Self {
let PipTreeArgs {
strict,
no_strict,
python,
system,
no_system,
} = args;
Self {
// Shared settings.
shared: PipSettings::combine(
PipOptions {
python,
system: flag(system, no_system),
strict: flag(strict, no_strict),
..PipOptions::default()
},
filesystem,
),
}
}
}
/// The resolved settings to use for a `pip check` invocation.
#[allow(clippy::struct_excessive_bools)]
#[derive(Debug, Clone)]
+595
View File
@@ -0,0 +1,595 @@
use std::process::Command;
use assert_fs::fixture::FileWriteStr;
use assert_fs::fixture::PathChild;
use common::uv_snapshot;
use crate::common::{get_bin, TestContext, EXCLUDE_NEWER};
mod common;
/// Create a `pip install` command with options shared across scenarios.
fn install_command(context: &TestContext) -> Command {
let mut command = Command::new(get_bin());
command
.arg("pip")
.arg("install")
.arg("--cache-dir")
.arg(context.cache_dir.path())
.arg("--exclude-newer")
.arg(EXCLUDE_NEWER)
.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());
}
command
}
#[test]
fn no_package() {
let context = TestContext::new("3.12");
uv_snapshot!(context.filters(), Command::new(get_bin())
.arg("pip")
.arg("tree")
.arg("--cache-dir")
.arg(context.cache_dir.path())
.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 -----
----- stderr -----
"###
);
}
#[test]
fn single_package() {
let context = TestContext::new("3.12");
let requirements_txt = context.temp_dir.child("requirements.txt");
requirements_txt.write_str("requests==2.31.0").unwrap();
uv_snapshot!(install_command(&context)
.arg("-r")
.arg("requirements.txt")
.arg("--strict"), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 5 packages in [TIME]
Prepared 5 packages in [TIME]
Installed 5 packages in [TIME]
+ certifi==2024.2.2
+ charset-normalizer==3.3.2
+ idna==3.6
+ requests==2.31.0
+ urllib3==2.2.1
"###
);
context.assert_command("import requests").success();
uv_snapshot!(context.filters(), Command::new(get_bin())
.arg("pip")
.arg("tree")
.arg("--cache-dir")
.arg(context.cache_dir.path())
.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 -----
requests v2.31.0
├── charset-normalizer v3.3.2
├── idna v3.6
├── urllib3 v2.2.1
└── certifi v2024.2.2
----- stderr -----
"###
);
}
#[test]
fn nested_dependencies() {
let context = TestContext::new("3.12");
let requirements_txt = context.temp_dir.child("requirements.txt");
requirements_txt
.write_str("scikit-learn==1.4.1.post1")
.unwrap();
uv_snapshot!(install_command(&context)
.arg("-r")
.arg("requirements.txt")
.arg("--strict"), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 5 packages in [TIME]
Prepared 5 packages in [TIME]
Installed 5 packages in [TIME]
+ joblib==1.3.2
+ numpy==1.26.4
+ scikit-learn==1.4.1.post1
+ scipy==1.12.0
+ threadpoolctl==3.4.0
"###
);
uv_snapshot!(context.filters(), Command::new(get_bin())
.arg("pip")
.arg("tree")
.arg("--cache-dir")
.arg(context.cache_dir.path())
.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 -----
scikit-learn v1.4.1.post1
├── numpy v1.26.4
├── scipy v1.12.0
│ └── numpy v1.26.4 (*)
├── joblib v1.3.2
└── threadpoolctl v3.4.0
Note: (*) indicates the package has been `de-duplicated`.
The dependencies for the package have already been shown elsewhere in the graph, and so are not repeated.
----- stderr -----
"###
);
}
#[test]
#[cfg(target_os = "macos")]
fn nested_dependencies_more_complex() {
let context = TestContext::new("3.12");
let requirements_txt = context.temp_dir.child("requirements.txt");
requirements_txt.write_str("packse").unwrap();
uv_snapshot!(install_command(&context)
.arg("-r")
.arg("requirements.txt")
.arg("--strict"), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 32 packages in [TIME]
Prepared 32 packages in [TIME]
Installed 32 packages in [TIME]
+ certifi==2024.2.2
+ charset-normalizer==3.3.2
+ chevron-blue==0.2.1
+ docutils==0.20.1
+ hatchling==1.22.4
+ idna==3.6
+ importlib-metadata==7.1.0
+ jaraco-classes==3.3.1
+ jaraco-context==4.3.0
+ jaraco-functools==4.0.0
+ keyring==25.0.0
+ markdown-it-py==3.0.0
+ mdurl==0.1.2
+ more-itertools==10.2.0
+ msgspec==0.18.6
+ nh3==0.2.15
+ packaging==24.0
+ packse==0.3.12
+ pathspec==0.12.1
+ pkginfo==1.10.0
+ pluggy==1.4.0
+ pygments==2.17.2
+ readme-renderer==43.0
+ requests==2.31.0
+ requests-toolbelt==1.0.0
+ rfc3986==2.0.0
+ rich==13.7.1
+ setuptools==69.2.0
+ trove-classifiers==2024.3.3
+ twine==4.0.2
+ urllib3==2.2.1
+ zipp==3.18.1
"###
);
uv_snapshot!(context.filters(), Command::new(get_bin())
.arg("pip")
.arg("tree")
.arg("--cache-dir")
.arg(context.cache_dir.path())
.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 -----
packse v0.3.12
├── chevron-blue v0.2.1
├── hatchling v1.22.4
│ ├── packaging v24.0
│ ├── pathspec v0.12.1
│ ├── pluggy v1.4.0
│ └── trove-classifiers v2024.3.3
├── msgspec v0.18.6
├── setuptools v69.2.0
└── twine v4.0.2
├── pkginfo v1.10.0
├── readme-renderer v43.0
│ ├── nh3 v0.2.15
│ ├── docutils v0.20.1
│ └── pygments v2.17.2
├── requests v2.31.0
│ ├── charset-normalizer v3.3.2
│ ├── idna v3.6
│ ├── urllib3 v2.2.1
│ └── certifi v2024.2.2
├── requests-toolbelt v1.0.0
│ └── requests v2.31.0 (*)
├── urllib3 v2.2.1 (*)
├── importlib-metadata v7.1.0
│ └── zipp v3.18.1
├── keyring v25.0.0
│ ├── jaraco-classes v3.3.1
│ │ └── more-itertools v10.2.0
│ ├── jaraco-functools v4.0.0
│ │ └── more-itertools v10.2.0 (*)
│ └── jaraco-context v4.3.0
├── rfc3986 v2.0.0
└── rich v13.7.1
├── markdown-it-py v3.0.0
│ └── mdurl v0.1.2
└── pygments v2.17.2 (*)
Note: (*) indicates the package has been `de-duplicated`.
The dependencies for the package have already been shown elsewhere in the graph, and so are not repeated.
----- stderr -----
"###
);
}
// Ensure `pip tree` behaves correctly with a package that has a cyclic dependency.
// package `uv-cyclic-dependencies-a` and `uv-cyclic-dependencies-b` depend on each other,
// which creates a dependency cycle.
// Additionally, package `uv-cyclic-dependencies-c` is included (depends on `uv-cyclic-dependencies-a`)
// to make this test case more realistic and meaningful.
#[test]
fn cyclic_dependency() {
let context = TestContext::new("3.12");
let requirements_txt = context.temp_dir.child("requirements.txt");
requirements_txt
.write_str("uv-cyclic-dependencies-c")
.unwrap();
let mut command = Command::new(get_bin());
command
.arg("pip")
.arg("install")
.arg("-r")
.arg("requirements.txt")
.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())
.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 -----
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 (*)
Note: (*) indicates the package has been `de-duplicated`.
The dependencies for the package have already been shown elsewhere in the graph, and so are not repeated.
----- stderr -----
"###
);
}
// Ensure `pip tree` behaves correctly after a package has been removed.
#[test]
fn removed_dependency() {
let context = TestContext::new("3.12");
let requirements_txt = context.temp_dir.child("requirements.txt");
requirements_txt.write_str("requests==2.31.0").unwrap();
uv_snapshot!(install_command(&context)
.arg("-r")
.arg("requirements.txt")
.arg("--strict"), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 5 packages in [TIME]
Prepared 5 packages in [TIME]
Installed 5 packages in [TIME]
+ certifi==2024.2.2
+ charset-normalizer==3.3.2
+ idna==3.6
+ requests==2.31.0
+ urllib3==2.2.1
"###
);
uv_snapshot!(context.filters(), Command::new(get_bin())
.arg("pip")
.arg("uninstall")
.arg("requests")
.arg("--cache-dir")
.arg(context.cache_dir.path())
.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 -----
----- stderr -----
Uninstalled 1 package in [TIME]
- requests==2.31.0
"###
);
uv_snapshot!(context.filters(), Command::new(get_bin())
.arg("pip")
.arg("tree")
.arg("--cache-dir")
.arg(context.cache_dir.path())
.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 -----
certifi v2024.2.2
charset-normalizer v3.3.2
idna v3.6
urllib3 v2.2.1
----- stderr -----
"###
);
}
#[test]
fn multiple_packages() {
let context = TestContext::new("3.12");
let requirements_txt = context.temp_dir.child("requirements.txt");
requirements_txt
.write_str(
r"
requests==2.31.0
click==8.1.7
",
)
.unwrap();
uv_snapshot!(install_command(&context)
.arg("-r")
.arg("requirements.txt")
.arg("--strict"), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 6 packages in [TIME]
Prepared 6 packages in [TIME]
Installed 6 packages in [TIME]
+ certifi==2024.2.2
+ charset-normalizer==3.3.2
+ click==8.1.7
+ idna==3.6
+ requests==2.31.0
+ urllib3==2.2.1
"###
);
let mut filters = context.filters();
if cfg!(windows) {
filters.push(("colorama v0.4.6\n", ""));
}
context.assert_command("import requests").success();
uv_snapshot!(filters, Command::new(get_bin())
.arg("pip")
.arg("tree")
.arg("--cache-dir")
.arg(context.cache_dir.path())
.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 -----
click v8.1.7
requests v2.31.0
├── charset-normalizer v3.3.2
├── idna v3.6
├── urllib3 v2.2.1
└── certifi v2024.2.2
----- stderr -----
"###
);
}
// Both `pendulum` and `boto3` depend on `python-dateutil`.
#[test]
#[cfg(not(windows))]
fn multiple_packages_shared_descendant() {
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())
.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 (*)
pendulum v3.0.0
├── python-dateutil v2.9.0.post0 (*)
└── tzdata v2024.1
time-machine v2.14.1
└── python-dateutil v2.9.0.post0 (*)
urllib3 v2.2.1
Note: (*) indicates the package has been `de-duplicated`.
The dependencies for the package have already been shown elsewhere in the graph, and so are not repeated.
----- stderr -----
"###
);
}
#[test]
fn with_editable() {
let context = TestContext::new("3.12");
// Install the editable package.
uv_snapshot!(context.filters(), install_command(&context)
.arg("-e")
.arg(context.workspace_root.join("scripts/packages/hatchling_editable")), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 2 packages in [TIME]
Prepared 2 packages in [TIME]
Installed 2 packages in [TIME]
+ hatchling-editable==0.1.0 (from file://[WORKSPACE]/scripts/packages/hatchling_editable)
+ iniconfig==2.0.1.dev6+g9cae431 (from git+https://github.com/pytest-dev/iniconfig@9cae43103df70bac6fde7b9f35ad11a9f1be0cb4)
"###
);
let filters = context
.filters()
.into_iter()
.chain(vec![(r"\-\-\-\-\-\-+.*", "[UNDERLINE]"), (" +", " ")])
.collect::<Vec<_>>();
uv_snapshot!(filters, Command::new(get_bin())
.arg("pip")
.arg("tree")
.arg("--cache-dir")
.arg(context.cache_dir.path())
.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 -----
hatchling-editable v0.1.0
└── iniconfig v2.0.1.dev6+g9cae431
----- stderr -----
"###
);
}