Support for `--format=freeze` and `--format=json` in `uv pip list` (#1998)

Implements `pip list --format=freeze` and `pip list --format=json`

Closes https://github.com/astral-sh/uv/issues/1970

## Test Plan

Extended existing `pip list` tests to match output.
Need to look at escaping in the Windows test 🪟
This commit is contained in:
Simon Brugman 2024-03-06 02:46:13 +01:00 committed by GitHub
parent df06069922
commit 190a161cc5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 406 additions and 44 deletions

View File

@ -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.
///

View File

@ -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<String>,
}
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.

View File

@ -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<PackageName>,
/// 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<ExitStatus> {
args.editable,
args.exclude_editable,
&args.exclude,
&args.format,
args.python.as_deref(),
args.system,
&cache,

View File

@ -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::<Vec<_>>();
// 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::<Vec<_>>();
// 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::<Vec<_>>();
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(())
}