From 395be442fc170af4f22c08ead59911078bc02f8c Mon Sep 17 00:00:00 2001 From: Chan Kang Date: Tue, 5 Mar 2024 22:08:13 -0500 Subject: [PATCH] Implement `uv pip show` (#2115) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- crates/uv/src/commands/mod.rs | 2 + crates/uv/src/commands/pip_show.rs | 143 +++++++++++ crates/uv/src/main.rs | 57 +++++ crates/uv/tests/pip_show.rs | 384 +++++++++++++++++++++++++++++ 4 files changed, 586 insertions(+) create mode 100644 crates/uv/src/commands/pip_show.rs create mode 100644 crates/uv/tests/pip_show.rs diff --git a/crates/uv/src/commands/mod.rs b/crates/uv/src/commands/mod.rs index d06788fde..9ade51a78 100644 --- a/crates/uv/src/commands/mod.rs +++ b/crates/uv/src/commands/mod.rs @@ -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; diff --git a/crates/uv/src/commands/pip_show.rs b/crates/uv/src/commands/pip_show.rs new file mode 100644 index 000000000..efeda1254 --- /dev/null +++ b/crates/uv/src/commands/pip_show.rs @@ -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, + strict: bool, + python: Option<&str>, + system: bool, + quiet: bool, + cache: &Cache, + mut printer: Printer, +) -> Result { + 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) +} diff --git a/crates/uv/src/main.rs b/crates/uv/src/main.rs index 85304a424..c1e184eb9 100644 --- a/crates/uv/src/main.rs +++ b/crates/uv/src/main.rs @@ -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, + + /// 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, + + /// 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 { 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 { &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), }) diff --git a/crates/uv/tests/pip_show.rs b/crates/uv/tests/pip_show.rs new file mode 100644 index 000000000..b22450df7 --- /dev/null +++ b/crates/uv/tests/pip_show.rs @@ -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 ----- + "### + ); +}