diff --git a/Cargo.lock b/Cargo.lock index 76c7f205d..230765615 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4183,6 +4183,7 @@ dependencies = [ "tracing-durations-export", "tracing-subscriber", "tracing-tree", + "unicode-width", "url", "uv-build", "uv-cache", diff --git a/crates/distribution-types/src/installed.rs b/crates/distribution-types/src/installed.rs index 271e26d97..6a31ed0ed 100644 --- a/crates/distribution-types/src/installed.rs +++ b/crates/distribution-types/src/installed.rs @@ -132,6 +132,14 @@ impl InstalledDist { } } + /// Return true if the distribution is editable. + pub fn is_editable(&self) -> bool { + match self { + Self::Registry(_) => false, + Self::Url(dist) => dist.editable, + } + } + /// Return the [`Url`] of the distribution, if it is editable. pub fn as_editable(&self) -> Option<&Url> { match self { diff --git a/crates/uv/Cargo.toml b/crates/uv/Cargo.toml index b4b569611..b9324eeb9 100644 --- a/crates/uv/Cargo.toml +++ b/crates/uv/Cargo.toml @@ -69,6 +69,7 @@ tracing-subscriber = { workspace = true } tracing-tree = { workspace = true } url = { workspace = true } which = { workspace = true } +unicode-width = { workspace = true } [target.'cfg(target_os = "windows")'.dependencies] mimalloc = "0.1.39" diff --git a/crates/uv/src/commands/mod.rs b/crates/uv/src/commands/mod.rs index a83b1a1e4..d5ee9b358 100644 --- a/crates/uv/src/commands/mod.rs +++ b/crates/uv/src/commands/mod.rs @@ -7,6 +7,7 @@ use distribution_types::InstalledMetadata; 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_sync::pip_sync; pub(crate) use pip_uninstall::pip_uninstall; pub(crate) use venv::venv; @@ -17,6 +18,7 @@ mod cache_dir; mod pip_compile; mod pip_freeze; mod pip_install; +mod pip_list; mod pip_sync; mod pip_uninstall; mod reporters; diff --git a/crates/uv/src/commands/pip_list.rs b/crates/uv/src/commands/pip_list.rs new file mode 100644 index 000000000..0b3c14592 --- /dev/null +++ b/crates/uv/src/commands/pip_list.rs @@ -0,0 +1,156 @@ +use std::cmp::max; +use std::fmt::Write; + +use anstream::println; +use anyhow::Result; +use itertools::Itertools; +use owo_colors::OwoColorize; +use tracing::debug; +use unicode_width::UnicodeWidthStr; + +use distribution_types::Name; +use platform_host::Platform; +use uv_cache::Cache; +use uv_fs::Normalized; +use uv_installer::SitePackages; +use uv_interpreter::Virtualenv; + +use crate::commands::ExitStatus; +use crate::printer::Printer; + +/// Enumerate the installed packages in the current environment. +pub(crate) fn pip_list( + cache: &Cache, + strict: bool, + editable: bool, + mut printer: Printer, +) -> Result { + // Detect the current Python interpreter. + let platform = Platform::current()?; + let venv = Virtualenv::from_env(platform, cache)?; + + debug!( + "Using Python {} environment at {}", + venv.interpreter().python_version(), + venv.python_executable().normalized_display().cyan() + ); + + // Build the installed index. + let site_packages = SitePackages::from_executable(&venv)?; + + // Filter if `--editable` is specified; always sort by name. + let results = site_packages + .iter() + .filter(|f| !editable || f.is_editable()) + .sorted_unstable_by(|a, b| a.name().cmp(b.name()).then(a.version().cmp(b.version()))) + .collect_vec(); + if results.is_empty() { + return Ok(ExitStatus::Success); + } + + // The package name and version are always present. + let mut columns = vec![ + Column { + header: String::from("Package"), + rows: results.iter().map(|f| f.name().to_string()).collect_vec(), + }, + Column { + header: String::from("Version"), + rows: results + .iter() + .map(|f| f.version().to_string()) + .collect_vec(), + }, + ]; + + // Editable column is only displayed if at least one editable package is found. + if site_packages + .iter() + .any(distribution_types::InstalledDist::is_editable) + { + columns.push(Column { + header: String::from("Editable project location"), + rows: results + .iter() + .map(|f| f.as_editable()) + .map(|e| { + if let Some(url) = e { + url.to_file_path() + .unwrap() + .into_os_string() + .into_string() + .unwrap() + } else { + String::new() + } + }) + .collect_vec(), + }); + } + + for elems in Multizip(columns.iter().map(Column::fmt_padded).collect_vec()) { + println!("{0}", elems.join(" ")); + } + + // 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) +} + +#[derive(Debug)] +struct Column { + /// The header of the column. + header: String, + /// The rows of the column. + rows: Vec, +} + +impl<'a> Column { + /// Return the width of the column. + fn max_width(&self) -> usize { + max( + self.header.width(), + self.rows.iter().map(|f| f.width()).max().unwrap_or(0), + ) + } + + /// Return an iterator of the column, with the header and rows formatted to the maximum width. + fn fmt_padded(&'a self) -> impl Iterator + 'a { + let max_width = self.max_width(); + let header = vec![ + format!("{0:width$}", self.header, width = max_width), + format!("{:-^width$}", "", width = max_width), + ]; + + header + .into_iter() + .chain(self.rows.iter().map(move |f| format!("{f:max_width$}"))) + } +} + +/// Zip an unknown number of iterators. +/// Combination of [`itertools::multizip`] and [`itertools::izip`]. +#[derive(Debug)] +struct Multizip(Vec); + +impl Iterator for Multizip +where + T: Iterator, +{ + type Item = Vec; + + fn next(&mut self) -> Option { + self.0.iter_mut().map(Iterator::next).collect() + } +} diff --git a/crates/uv/src/main.rs b/crates/uv/src/main.rs index e6ab42065..97baa6026 100644 --- a/crates/uv/src/main.rs +++ b/crates/uv/src/main.rs @@ -179,6 +179,8 @@ enum PipCommand { Uninstall(PipUninstallArgs), /// Enumerate the installed packages in the current environment. Freeze(PipFreezeArgs), + /// Enumerate the installed packages in the current environment. + List(PipListArgs), } /// Clap parser for the union of date and datetime @@ -685,6 +687,19 @@ struct PipFreezeArgs { strict: bool, } +#[derive(Args)] +#[allow(clippy::struct_excessive_bools)] +struct PipListArgs { + /// Validate the virtual environment, to detect packages with missing dependencies or other + /// issues. + #[clap(long)] + strict: bool, + + /// List editable projects. + #[clap(short, long)] + editable: bool, +} + #[derive(Args)] #[allow(clippy::struct_excessive_bools)] struct VenvArgs { @@ -802,6 +817,12 @@ async fn run() -> Result { ContextValue::String("uv pip freeze".to_string()), ); } + "list" => { + err.insert( + ContextKind::SuggestedSubcommand, + ContextValue::String("uv pip list".to_string()), + ); + } _ => {} } } @@ -1086,6 +1107,9 @@ async fn run() -> Result { Commands::Pip(PipNamespace { command: PipCommand::Freeze(args), }) => commands::pip_freeze(&cache, args.strict, printer), + Commands::Pip(PipNamespace { + command: PipCommand::List(args), + }) => commands::pip_list(&cache, args.strict, args.editable, printer), Commands::Cache(CacheNamespace { command: CacheCommand::Clean(args), }) diff --git a/crates/uv/tests/pip_list.rs b/crates/uv/tests/pip_list.rs new file mode 100644 index 000000000..201939939 --- /dev/null +++ b/crates/uv/tests/pip_list.rs @@ -0,0 +1,291 @@ +use std::process::Command; + +use anyhow::Result; +use assert_fs::fixture::PathChild; +use assert_fs::fixture::{FileTouch, FileWriteStr}; +use url::Url; + +use common::uv_snapshot; + +use crate::common::{get_bin, TestContext, EXCLUDE_NEWER, INSTA_FILTERS}; + +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 empty() { + let context = TestContext::new("3.12"); + + uv_snapshot!(Command::new(get_bin()) + .arg("pip") + .arg("list") + .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 ----- + "### + ); +} + +#[test] +fn single_no_editable() -> 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(); + + uv_snapshot!(Command::new(get_bin()) + .arg("pip") + .arg("list") + .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 ----- + Package Version + ---------- ------- + markupsafe 2.1.3 + + ----- stderr ----- + "### + ); + + Ok(()) +} + +#[test] +fn editable() -> Result<()> { + let context = TestContext::new("3.12"); + + let current_dir = std::env::current_dir()?; + let workspace_dir = regex::escape( + Url::from_directory_path(current_dir.join("..").join("..").canonicalize()?) + .unwrap() + .as_str(), + ); + + let filters = [(workspace_dir.as_str(), "file://[WORKSPACE_DIR]/")] + .into_iter() + .chain(INSTA_FILTERS.to_vec()) + .collect::>(); + + // Install the editable package. + uv_snapshot!(filters, 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"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Built 1 editable in [TIME] + Resolved 2 packages in [TIME] + Downloaded 1 package in [TIME] + Installed 2 packages in [TIME] + + numpy==1.26.2 + + poetry-editable==0.1.0 (from file://[WORKSPACE_DIR]/scripts/editable-installs/poetry_editable) + "### + ); + + // Account for difference length workspace dir + let prefix = if cfg!(windows) { "file:///" } else { "file://" }; + + // Origin of lengths used below: + // - |Editable project location| = 25 + // - expected length = 57 + // - expected length - |Editable project location| = 32 + // - |`[WORKSPACE_DIR]/`| = 16 + // - |`file://`| = 7, |`file:///`| = 8 (windows) + + let workspace_len_difference = workspace_dir.as_str().len() + 32 - 16 - prefix.len(); + let find_divider = "-".repeat(25 + workspace_len_difference); + let replace_divider = "-".repeat(57); + + let find_header = format!( + "Editable project location{0}", + " ".repeat(workspace_len_difference) + ); + let replace_header = format!("Editable project location{0}", " ".repeat(32)); + + let find_whitespace = " ".repeat(25 + workspace_len_difference); + let replace_whitespace = " ".repeat(57); + + let search_workspace = workspace_dir.as_str().strip_prefix(prefix).unwrap(); + let replace_workspace = "[WORKSPACE_DIR]/"; + + let filters = INSTA_FILTERS + .iter() + .copied() + .chain(vec![ + (search_workspace, replace_workspace), + (find_divider.as_str(), replace_divider.as_str()), + (find_header.as_str(), replace_header.as_str()), + (find_whitespace.as_str(), replace_whitespace.as_str()), + ]) + .collect::>(); + + uv_snapshot!(filters, Command::new(get_bin()) + .arg("pip") + .arg("list") + .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 ----- + Package Version Editable project location + --------------- ------- --------------------------------------------------------- + numpy 1.26.2 + poetry-editable 0.1.0 [WORKSPACE_DIR]/scripts/editable-installs/poetry_editable + + ----- stderr ----- + "### + ); + + Ok(()) +} + +#[test] +fn editable_only() -> Result<()> { + let context = TestContext::new("3.12"); + + let current_dir = std::env::current_dir()?; + let workspace_dir = regex::escape( + Url::from_directory_path(current_dir.join("..").join("..").canonicalize()?) + .unwrap() + .as_str(), + ); + + let filters = [(workspace_dir.as_str(), "file://[WORKSPACE_DIR]/")] + .into_iter() + .chain(INSTA_FILTERS.to_vec()) + .collect::>(); + + // Install the editable package. + uv_snapshot!(filters, 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"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Built 1 editable in [TIME] + Resolved 2 packages in [TIME] + Downloaded 1 package in [TIME] + Installed 2 packages in [TIME] + + numpy==1.26.2 + + poetry-editable==0.1.0 (from file://[WORKSPACE_DIR]/scripts/editable-installs/poetry_editable) + "### + ); + + // Account for difference length workspace dir + let prefix = if cfg!(windows) { "file:///" } else { "file://" }; + + let workspace_len_difference = workspace_dir.as_str().len() + 32 - 16 - prefix.len(); + let find_divider = "-".repeat(25 + workspace_len_difference); + let replace_divider = "-".repeat(57); + + let find_header = format!( + "Editable project location{0}", + " ".repeat(workspace_len_difference) + ); + let replace_header = format!("Editable project location{0}", " ".repeat(32)); + + let find_whitespace = " ".repeat(25 + workspace_len_difference); + let replace_whitespace = " ".repeat(57); + + let search_workspace = workspace_dir.as_str().strip_prefix(prefix).unwrap(); + let replace_workspace = "[WORKSPACE_DIR]/"; + + let filters = INSTA_FILTERS + .iter() + .copied() + .chain(vec![ + (search_workspace, replace_workspace), + (find_divider.as_str(), replace_divider.as_str()), + (find_header.as_str(), replace_header.as_str()), + (find_whitespace.as_str(), replace_whitespace.as_str()), + ]) + .collect::>(); + + uv_snapshot!(filters, Command::new(get_bin()) + .arg("pip") + .arg("list") + .arg("--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 ----- + Package Version Editable project location + --------------- ------- --------------------------------------------------------- + poetry-editable 0.1.0 [WORKSPACE_DIR]/scripts/editable-installs/poetry_editable + + ----- stderr ----- + "### + ); + + Ok(()) +}