uv pip list (#1662)

Hi, love your work on `uv` 👋! 

Opening a Draft PR early to check if there are any existing rust table
formatting libs that I am unaware of (either already in `uv`/`ruff`, or
the rust ecosystem) before spending much time on inventing the wheel
myself and cleaning it up. Any other pointers are also welcome (e.g. on
the editable filtering).

Editable project locations in `uv pip list` include the file scheme
(`file://`), where they are omitted in `pip list`. Is this desired, or
should it replicate pip?

## Summary

Implementation for #1401 
`--editable` flag is implemented.

`--outdated` and `--uptodate` out of scope for this PR (requires latest
version information, and type wheel/sdist)

## Test Plan

Not yet implemented as I couldn't locate the tests for `uv pip freeze`.
We can compare to `pip` in
`scripts/compare_with_pip/compare_with_pip.py`?
This commit is contained in:
Simon Brugman 2024-02-25 20:42:27 +01:00 committed by GitHub
parent 8d721830db
commit 0f1377bb08
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 483 additions and 0 deletions

1
Cargo.lock generated
View file

@ -4183,6 +4183,7 @@ dependencies = [
"tracing-durations-export",
"tracing-subscriber",
"tracing-tree",
"unicode-width",
"url",
"uv-build",
"uv-cache",

View file

@ -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 {

View file

@ -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"

View file

@ -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;

View file

@ -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<ExitStatus> {
// 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<String>,
}
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<Item = String> + '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<T>(Vec<T>);
impl<T> Iterator for Multizip<T>
where
T: Iterator,
{
type Item = Vec<T::Item>;
fn next(&mut self) -> Option<Self::Item> {
self.0.iter_mut().map(Iterator::next).collect()
}
}

View file

@ -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<ExitStatus> {
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<ExitStatus> {
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),
})

291
crates/uv/tests/pip_list.rs Normal file
View file

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