Implement uv pip show (#2115)

## Summary
Implementation for https://github.com/astral-sh/uv/issues/1594
The output will contain only the name, version and location of the
packages for now but it should be extendable to include other
information in the future.

Quite inexperienced with Rust, so please forgive me if there are things
that obviously don't make sense 😭

## Test Plan
Added a bunch of unit tests. The exit code behavior matches `pip`'s
behavior:
- When the package is found -> exit code 0
- When the package isn't found -> exit code 1
- When one package is found but another isn't -> exit code 0
This commit is contained in:
Chan Kang 2024-03-05 22:08:13 -05:00 committed by GitHub
parent 30bc16a3c1
commit 395be442fc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 586 additions and 0 deletions

View file

@ -12,6 +12,7 @@ pub(crate) use pip_compile::{extra_name_with_clap_error, pip_compile, Upgrade};
pub(crate) use pip_freeze::pip_freeze;
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_uninstall::pip_uninstall;
use uv_cache::Cache;
@ -29,6 +30,7 @@ mod pip_compile;
mod pip_freeze;
mod pip_install;
mod pip_list;
mod pip_show;
mod pip_sync;
mod pip_uninstall;
mod reporters;

View file

@ -0,0 +1,143 @@
use std::fmt::Write;
use anyhow::Result;
use owo_colors::OwoColorize;
use tracing::debug;
use anstream::{eprintln, println};
use distribution_types::Name;
use platform_host::Platform;
use uv_cache::Cache;
use uv_fs::Simplified;
use uv_installer::SitePackages;
use uv_interpreter::PythonEnvironment;
use uv_normalize::PackageName;
use crate::commands::ExitStatus;
use crate::printer::Printer;
/// Show information about one or more installed packages.
pub(crate) fn pip_show(
mut packages: Vec<PackageName>,
strict: bool,
python: Option<&str>,
system: bool,
quiet: bool,
cache: &Cache,
mut printer: Printer,
) -> Result<ExitStatus> {
if packages.is_empty() {
#[allow(clippy::print_stderr)]
{
eprintln!(
"{}{} Please provide a package name or names.",
"warning".yellow().bold(),
":".bold(),
);
}
return Ok(ExitStatus::Failure);
}
// Detect the current Python interpreter.
let platform = Platform::current()?;
let venv = if let Some(python) = python {
PythonEnvironment::from_requested_python(python, &platform, cache)?
} else if system {
PythonEnvironment::from_default_python(&platform, cache)?
} else {
match PythonEnvironment::from_virtualenv(platform.clone(), cache) {
Ok(venv) => venv,
Err(uv_interpreter::Error::VenvNotFound) => {
PythonEnvironment::from_default_python(&platform, cache)?
}
Err(err) => return Err(err.into()),
}
};
debug!(
"Using Python {} environment at {}",
venv.interpreter().python_version(),
venv.python_executable().simplified_display().cyan()
);
// Build the installed index.
let site_packages = SitePackages::from_executable(&venv)?;
// Sort and deduplicate the packages, which are keyed by name.
packages.sort_unstable();
packages.dedup();
// Map to the local distributions.
let distributions = {
let mut distributions = Vec::with_capacity(packages.len());
// Identify all packages that are installed.
for package in &packages {
let installed = site_packages.get_packages(package);
if installed.is_empty() {
writeln!(
printer,
"{}{} Package(s) not found for: {}",
"warning".yellow().bold(),
":".bold(),
package.as_ref().bold()
)?;
} else {
distributions.extend(installed);
}
}
distributions
};
// Like `pip`, if no packages were found, return a failure.
if distributions.is_empty() {
return Ok(ExitStatus::Failure);
}
if !quiet {
// Print the information for each package.
let mut first = true;
for distribution in &distributions {
if first {
first = false;
} else {
// Print a separator between packages.
#[allow(clippy::print_stdout)]
{
println!("---");
}
}
// Print the name, version, and location (e.g., the `site-packages` directory).
#[allow(clippy::print_stdout)]
{
println!("Name: {}", distribution.name());
println!("Version: {}", distribution.version());
println!(
"Location: {}",
distribution
.path()
.parent()
.expect("package path is not root")
.simplified_display()
);
}
}
// Validate that the environment is consistent.
if strict {
for diagnostic in site_packages.diagnostics()? {
writeln!(
printer,
"{}{} {}",
"warning".yellow().bold(),
":".bold(),
diagnostic.message().bold()
)?;
}
}
}
Ok(ExitStatus::Success)
}

View file

@ -182,6 +182,8 @@ enum PipCommand {
Freeze(PipFreezeArgs),
/// Enumerate the installed packages in the current environment.
List(PipListArgs),
/// Show information about one or more installed packages.
Show(PipShowArgs),
}
/// Clap parser for the union of date and datetime
@ -947,6 +949,44 @@ struct PipListArgs {
system: bool,
}
#[derive(Args)]
#[allow(clippy::struct_excessive_bools)]
struct PipShowArgs {
/// The package(s) to display.
package: Vec<PackageName>,
/// Validate the virtual environment, to detect packages with missing dependencies or other
/// issues.
#[clap(long)]
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.
#[clap(long, short, verbatim_doc_comment, conflicts_with = "system")]
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.
#[clap(long, conflicts_with = "python")]
system: bool,
}
#[derive(Args)]
#[allow(clippy::struct_excessive_bools)]
struct VenvArgs {
@ -1105,6 +1145,12 @@ async fn run() -> Result<ExitStatus> {
ContextValue::String("uv pip list".to_string()),
);
}
"show" => {
err.insert(
ContextKind::SuggestedSubcommand,
ContextValue::String("uv pip show".to_string()),
);
}
_ => {}
}
}
@ -1433,6 +1479,17 @@ async fn run() -> Result<ExitStatus> {
&cache,
printer,
),
Commands::Pip(PipNamespace {
command: PipCommand::Show(args),
}) => commands::pip_show(
args.package,
args.strict,
args.python.as_deref(),
args.system,
cli.quiet,
&cache,
printer,
),
Commands::Cache(CacheNamespace {
command: CacheCommand::Clean(args),
})

384
crates/uv/tests/pip_show.rs Normal file
View file

@ -0,0 +1,384 @@
use std::process::Command;
use anyhow::Result;
use assert_cmd::prelude::*;
use assert_fs::fixture::PathChild;
use assert_fs::fixture::{FileTouch, FileWriteStr};
use indoc::indoc;
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 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())
.current_dir(&context.temp_dir);
command
}
#[test]
fn show_empty() {
let context = TestContext::new("3.12");
uv_snapshot!(Command::new(get_bin())
.arg("pip")
.arg("show")
.arg("--cache-dir")
.arg(context.cache_dir.path())
.env("VIRTUAL_ENV", context.venv.as_os_str())
.current_dir(&context.temp_dir), @r###"
success: false
exit_code: 1
----- stdout -----
----- stderr -----
warning: Please provide a package name or names.
"###
);
}
#[test]
fn show_found_single_package() -> Result<()> {
let context = TestContext::new("3.12");
let requirements_txt = context.temp_dir.child("requirements.txt");
requirements_txt.touch()?;
requirements_txt.write_str("MarkupSafe==2.1.3")?;
uv_snapshot!(command(&context)
.arg("-r")
.arg("requirements.txt")
.arg("--strict"), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 1 package in [TIME]
Downloaded 1 package in [TIME]
Installed 1 package in [TIME]
+ markupsafe==2.1.3
"###
);
context.assert_command("import markupsafe").success();
let filters = [(
r"Location:.*site-packages",
"Location: [WORKSPACE_DIR]/site-packages",
)]
.to_vec();
uv_snapshot!(filters, Command::new(get_bin())
.arg("pip")
.arg("show")
.arg("markupsafe")
.arg("--cache-dir")
.arg(context.cache_dir.path())
.env("VIRTUAL_ENV", context.venv.as_os_str())
.current_dir(&context.temp_dir), @r###"
success: true
exit_code: 0
----- stdout -----
Name: markupsafe
Version: 2.1.3
Location: [WORKSPACE_DIR]/site-packages
----- stderr -----
"###
);
Ok(())
}
#[test]
fn show_found_multiple_packages() -> Result<()> {
let context = TestContext::new("3.12");
let requirements_txt = context.temp_dir.child("requirements.txt");
requirements_txt.touch()?;
requirements_txt.write_str(indoc! {r"
MarkupSafe==2.1.3
pip==21.3.1
"
})?;
uv_snapshot!(command(&context)
.arg("-r")
.arg("requirements.txt")
.arg("--strict"), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 2 packages in [TIME]
Downloaded 2 packages in [TIME]
Installed 2 packages in [TIME]
+ markupsafe==2.1.3
+ pip==21.3.1
"###
);
context.assert_command("import markupsafe").success();
// In addition to the standard filters, remove the temporary directory from the snapshot.
let filters = [(
r"Location:.*site-packages",
"Location: [WORKSPACE_DIR]/site-packages",
)]
.to_vec();
uv_snapshot!(filters, Command::new(get_bin())
.arg("pip")
.arg("show")
.arg("markupsafe")
.arg("pip")
.arg("--cache-dir")
.arg(context.cache_dir.path())
.env("VIRTUAL_ENV", context.venv.as_os_str())
.current_dir(&context.temp_dir), @r###"
success: true
exit_code: 0
----- stdout -----
Name: markupsafe
Version: 2.1.3
Location: [WORKSPACE_DIR]/site-packages
---
Name: pip
Version: 21.3.1
Location: [WORKSPACE_DIR]/site-packages
----- stderr -----
"###
);
Ok(())
}
#[test]
fn show_found_one_out_of_two() -> Result<()> {
let context = TestContext::new("3.12");
let requirements_txt = context.temp_dir.child("requirements.txt");
requirements_txt.touch()?;
requirements_txt.write_str(indoc! {r"
MarkupSafe==2.1.3
pip==21.3.1
"
})?;
uv_snapshot!(command(&context)
.arg("-r")
.arg("requirements.txt")
.arg("--strict"), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 2 packages in [TIME]
Downloaded 2 packages in [TIME]
Installed 2 packages in [TIME]
+ markupsafe==2.1.3
+ pip==21.3.1
"###
);
context.assert_command("import markupsafe").success();
// In addition to the standard filters, remove the temporary directory from the snapshot.
let filters = [(
r"Location:.*site-packages",
"Location: [WORKSPACE_DIR]/site-packages",
)]
.to_vec();
uv_snapshot!(filters, Command::new(get_bin())
.arg("pip")
.arg("show")
.arg("markupsafe")
.arg("flask")
.arg("--cache-dir")
.arg(context.cache_dir.path())
.env("VIRTUAL_ENV", context.venv.as_os_str())
.current_dir(&context.temp_dir), @r###"
success: true
exit_code: 0
----- stdout -----
Name: markupsafe
Version: 2.1.3
Location: [WORKSPACE_DIR]/site-packages
----- stderr -----
warning: Package(s) not found for: flask
"###
);
Ok(())
}
#[test]
fn show_found_one_out_of_two_quiet() -> Result<()> {
let context = TestContext::new("3.12");
let requirements_txt = context.temp_dir.child("requirements.txt");
requirements_txt.touch()?;
requirements_txt.write_str(indoc! {r"
MarkupSafe==2.1.3
pip==21.3.1
"
})?;
uv_snapshot!(command(&context)
.arg("-r")
.arg("requirements.txt")
.arg("--strict"), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 2 packages in [TIME]
Downloaded 2 packages in [TIME]
Installed 2 packages in [TIME]
+ markupsafe==2.1.3
+ pip==21.3.1
"###
);
context.assert_command("import markupsafe").success();
// Flask isn't installed, but markupsafe is, so the command should succeed.
uv_snapshot!(Command::new(get_bin())
.arg("pip")
.arg("show")
.arg("markupsafe")
.arg("flask")
.arg("--quiet")
.arg("--cache-dir")
.arg(context.cache_dir.path())
.env("VIRTUAL_ENV", context.venv.as_os_str())
.current_dir(&context.temp_dir), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
"###
);
Ok(())
}
#[test]
fn show_empty_quiet() -> Result<()> {
let context = TestContext::new("3.12");
let requirements_txt = context.temp_dir.child("requirements.txt");
requirements_txt.touch()?;
requirements_txt.write_str(indoc! {r"
MarkupSafe==2.1.3
pip==21.3.1
"
})?;
uv_snapshot!(command(&context)
.arg("-r")
.arg("requirements.txt")
.arg("--strict"), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 2 packages in [TIME]
Downloaded 2 packages in [TIME]
Installed 2 packages in [TIME]
+ markupsafe==2.1.3
+ pip==21.3.1
"###
);
context.assert_command("import markupsafe").success();
// Flask isn't installed, so the command should fail.
uv_snapshot!(Command::new(get_bin())
.arg("pip")
.arg("show")
.arg("flask")
.arg("--quiet")
.arg("--cache-dir")
.arg(context.cache_dir.path())
.env("VIRTUAL_ENV", context.venv.as_os_str())
.current_dir(&context.temp_dir), @r###"
success: false
exit_code: 1
----- stdout -----
----- stderr -----
"###
);
Ok(())
}
#[test]
fn show_editable() {
let context = TestContext::new("3.12");
// Install the editable package.
Command::new(get_bin())
.arg("pip")
.arg("install")
.arg("-e")
.arg("../../scripts/editable-installs/poetry_editable")
.arg("--strict")
.arg("--cache-dir")
.arg(context.cache_dir.path())
.arg("--exclude-newer")
.arg(EXCLUDE_NEWER)
.env("VIRTUAL_ENV", context.venv.as_os_str())
.env(
"CARGO_TARGET_DIR",
"../../../target/target_install_editable",
)
.assert()
.success();
// In addition to the standard filters, remove the temporary directory from the snapshot.
let filters = [(
r"Location:.*site-packages",
"Location: [WORKSPACE_DIR]/site-packages",
)]
.to_vec();
uv_snapshot!(filters, Command::new(get_bin())
.arg("pip")
.arg("show")
.arg("poetry-editable")
.arg("--cache-dir")
.arg(context.cache_dir.path())
.env("VIRTUAL_ENV", context.venv.as_os_str())
.current_dir(&context.temp_dir), @r###"
success: true
exit_code: 0
----- stdout -----
Name: poetry-editable
Version: 0.1.0
Location: [WORKSPACE_DIR]/site-packages
----- stderr -----
"###
);
}