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 ----- + "### + ); +}