diff --git a/crates/uv/src/commands/mod.rs b/crates/uv/src/commands/mod.rs index 42ad8774c..d06788fde 100644 --- a/crates/uv/src/commands/mod.rs +++ b/crates/uv/src/commands/mod.rs @@ -93,6 +93,18 @@ pub(crate) enum VersionFormat { Json, } +#[derive(Debug, Default, Clone, clap::ValueEnum)] +pub(crate) enum ListFormat { + /// Display the list of packages in a human-readable table. + #[default] + Columns, + /// Display the list of packages in a `pip freeze`-like format, with one package per line + /// alongside its version. + Freeze, + /// Display the list of packages in a machine-readable JSON format. + Json, +} + /// Compile all Python source files in site-packages to bytecode, to speed up the /// initial run of any subsequent executions. /// diff --git a/crates/uv/src/commands/pip_list.rs b/crates/uv/src/commands/pip_list.rs index ccb12f62c..5d5b96807 100644 --- a/crates/uv/src/commands/pip_list.rs +++ b/crates/uv/src/commands/pip_list.rs @@ -5,10 +5,11 @@ use anstream::println; use anyhow::Result; use itertools::Itertools; use owo_colors::OwoColorize; +use serde::Serialize; use tracing::debug; use unicode_width::UnicodeWidthStr; -use distribution_types::Name; +use distribution_types::{InstalledDist, Name}; use platform_host::Platform; use uv_cache::Cache; use uv_fs::Simplified; @@ -19,6 +20,8 @@ use uv_normalize::PackageName; use crate::commands::ExitStatus; use crate::printer::Printer; +use super::ListFormat; + /// Enumerate the installed packages in the current environment. #[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)] pub(crate) fn pip_list( @@ -26,6 +29,7 @@ pub(crate) fn pip_list( editable: bool, exclude_editable: bool, exclude: &[PackageName], + format: &ListFormat, python: Option<&str>, system: bool, cache: &Cache, @@ -67,45 +71,63 @@ pub(crate) fn pip_list( 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(), - }, - ]; + match format { + ListFormat::Columns => { + // The package name and version are always present. + let mut columns = vec![ + Column { + header: String::from("Package"), + rows: results + .iter() + .copied() + .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 results.iter().any(|f| f.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(), - }); - } + // Editable column is only displayed if at least one editable package is found. + if results.iter().copied().any(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(" ")); + for elems in Multizip(columns.iter().map(Column::fmt_padded).collect_vec()) { + println!("{}", elems.join(" ")); + } + } + ListFormat::Json => { + let rows = results.iter().copied().map(Row::from).collect_vec(); + let output = serde_json::to_string(&rows)?; + println!("{output}"); + } + ListFormat::Freeze => { + for dist in &results { + println!("{}=={}", dist.name().bold(), dist.version()); + } + } } // Validate that the environment is consistent. @@ -124,6 +146,26 @@ pub(crate) fn pip_list( Ok(ExitStatus::Success) } +#[derive(Debug, Serialize)] +struct Row { + name: String, + version: String, + #[serde(skip_serializing_if = "Option::is_none")] + editable_project_location: Option, +} + +impl From<&InstalledDist> for Row { + fn from(dist: &InstalledDist) -> Self { + Self { + name: dist.name().to_string(), + version: dist.version().to_string(), + editable_project_location: dist + .as_editable() + .map(|url| url.to_file_path().unwrap().simplified_display().to_string()), + } + } +} + #[derive(Debug)] struct Column { /// The header of the column. diff --git a/crates/uv/src/main.rs b/crates/uv/src/main.rs index e5c2cc6ee..85304a424 100644 --- a/crates/uv/src/main.rs +++ b/crates/uv/src/main.rs @@ -24,7 +24,7 @@ use uv_traits::{ ConfigSettingEntry, ConfigSettings, NoBuild, PackageNameSpecifier, SetupPyStrategy, }; -use crate::commands::{extra_name_with_clap_error, ExitStatus, Upgrade, VersionFormat}; +use crate::commands::{extra_name_with_clap_error, ExitStatus, ListFormat, Upgrade, VersionFormat}; use crate::compat::CompatArgs; use crate::requirements::RequirementsSource; @@ -916,6 +916,10 @@ struct PipListArgs { #[clap(long)] r#exclude: Vec, + /// Select the output format between: `columns` (default), `freeze`, or `json`. + #[clap(long, value_enum, default_value_t = ListFormat::default())] + format: ListFormat, + /// The Python interpreter for which packages should be listed. /// /// By default, `uv` lists packages in the currently activated virtual environment, or a virtual @@ -1423,6 +1427,7 @@ async fn run() -> Result { args.editable, args.exclude_editable, &args.exclude, + &args.format, args.python.as_deref(), args.system, &cache, diff --git a/crates/uv/tests/pip_list.rs b/crates/uv/tests/pip_list.rs index 71bdf3440..c40f478a4 100644 --- a/crates/uv/tests/pip_list.rs +++ b/crates/uv/tests/pip_list.rs @@ -27,7 +27,7 @@ fn command(context: &TestContext) -> Command { } #[test] -fn empty() { +fn list_empty() { let context = TestContext::new("3.12"); uv_snapshot!(Command::new(get_bin()) @@ -47,7 +47,7 @@ fn empty() { } #[test] -fn single_no_editable() -> Result<()> { +fn list_single_no_editable() -> Result<()> { let context = TestContext::new("3.12"); let requirements_txt = context.temp_dir.child("requirements.txt"); @@ -94,7 +94,7 @@ fn single_no_editable() -> Result<()> { } #[test] -fn editable() -> Result<()> { +fn list_editable() -> Result<()> { let context = TestContext::new("3.12"); let current_dir = std::env::current_dir()?; @@ -196,7 +196,7 @@ fn editable() -> Result<()> { } #[test] -fn editable_only() -> Result<()> { +fn list_editable_only() -> Result<()> { let context = TestContext::new("3.12"); let current_dir = std::env::current_dir()?; @@ -327,7 +327,7 @@ fn editable_only() -> Result<()> { } #[test] -fn exclude() -> Result<()> { +fn list_exclude() -> Result<()> { let context = TestContext::new("3.12"); let current_dir = std::env::current_dir()?; @@ -460,3 +460,306 @@ fn exclude() -> Result<()> { Ok(()) } + +#[test] +#[cfg(not(windows))] +fn list_format_json() -> 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) + "### + ); + + let workspace_dir = regex::escape( + current_dir + .join("..") + .join("..") + .canonicalize()? + .to_str() + .unwrap(), + ); + + let workspace_len_difference = workspace_dir.as_str().len() + 32 - 16; + 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(); + let search_workspace_escaped = search_workspace.replace('/', "\\\\"); + let replace_workspace = "[WORKSPACE_DIR]"; + + let filters: Vec<_> = [ + (search_workspace, replace_workspace), + (search_workspace_escaped.as_str(), 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()), + ] + .into_iter() + .chain(INSTA_FILTERS.to_vec()) + .collect(); + + uv_snapshot!(filters, Command::new(get_bin()) + .arg("pip") + .arg("list") + .arg("--format=json") + .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":"numpy","version":"1.26.2"},{"name":"poetry-editable","version":"0.1.0","editable_project_location":"[WORKSPACE_DIR]/scripts/editable-installs/poetry_editable"}] + + ----- stderr ----- + "### + ); + + uv_snapshot!(filters, Command::new(get_bin()) + .arg("pip") + .arg("list") + .arg("--format=json") + .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 ----- + [{"name":"poetry-editable","version":"0.1.0","editable_project_location":"[WORKSPACE_DIR]/scripts/editable-installs/poetry_editable"}] + + ----- stderr ----- + "### + ); + + uv_snapshot!(filters, Command::new(get_bin()) + .arg("pip") + .arg("list") + .arg("--format=json") + .arg("--exclude-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":"numpy","version":"1.26.2"}] + + ----- stderr ----- + "### + ); + + uv_snapshot!(filters, Command::new(get_bin()) + .arg("pip") + .arg("list") + .arg("--format=json") + .arg("--editable") + .arg("--exclude-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 ----- + + ----- stderr ----- + "### + ); + + Ok(()) +} + +#[test] +fn list_format_freeze() -> 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("--format=freeze") + .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 ----- + numpy==1.26.2 + poetry-editable==0.1.0 + + ----- stderr ----- + "### + ); + + uv_snapshot!(filters, Command::new(get_bin()) + .arg("pip") + .arg("list") + .arg("--format=freeze") + .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 ----- + poetry-editable==0.1.0 + + ----- stderr ----- + "### + ); + + uv_snapshot!(filters, Command::new(get_bin()) + .arg("pip") + .arg("list") + .arg("--format=freeze") + .arg("--exclude-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 ----- + numpy==1.26.2 + + ----- stderr ----- + "### + ); + + uv_snapshot!(filters, Command::new(get_bin()) + .arg("pip") + .arg("list") + .arg("--format=freeze") + .arg("--editable") + .arg("--exclude-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 ----- + + ----- stderr ----- + "### + ); + + Ok(()) +}