Implement pip freeze --path (#10488)

## Summary

Resolves #5952

Add a `--path` option to `uv pip freeze` to be compatible with `pip
freeze`

## Test Plan

New snapshot tests

---------

Co-authored-by: Charlie Marsh <charlie.r.marsh@gmail.com>
This commit is contained in:
Eric Mark Martin 2025-01-13 17:50:04 -05:00 committed by GitHub
parent 97c1877f6f
commit f261c65bdd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 151 additions and 21 deletions

View file

@ -1904,6 +1904,10 @@ pub struct PipFreezeArgs {
)]
pub python: Option<Maybe<String>>,
/// Restrict to the specified installation path for listing packages (can be used multiple times).
#[arg(long("path"), value_parser = parse_file_path)]
pub paths: Option<Vec<PathBuf>>,
/// List packages in the system Python environment.
///
/// Disables discovery of virtual environments.

View file

@ -1,4 +1,5 @@
use std::fmt::Write;
use std::path::PathBuf;
use anyhow::Result;
use itertools::Itertools;
@ -19,6 +20,7 @@ pub(crate) fn pip_freeze(
strict: bool,
python: Option<&str>,
system: bool,
paths: Option<Vec<PathBuf>>,
cache: &Cache,
printer: Printer,
) -> Result<ExitStatus> {
@ -31,49 +33,68 @@ pub(crate) fn pip_freeze(
report_target_environment(&environment, cache, printer)?;
// Build the installed index.
let site_packages = SitePackages::from_environment(&environment)?;
for dist in site_packages
// Collect all the `site-packages` directories.
let site_packages = match paths {
Some(paths) => {
paths
.into_iter()
.filter_map(|path| {
environment
.clone()
.with_target(uv_python::Target::from(path))
// Drop invalid paths as per `pip freeze`.
.ok()
})
.map(|environment| SitePackages::from_environment(&environment))
.collect::<Result<Vec<_>>>()?
}
None => vec![SitePackages::from_environment(&environment)?],
};
site_packages
.iter()
.flat_map(uv_installer::SitePackages::iter)
.filter(|dist| !(exclude_editable && dist.is_editable()))
.sorted_unstable_by(|a, b| a.name().cmp(b.name()).then(a.version().cmp(b.version())))
{
match dist {
.map(|dist| match dist {
InstalledDist::Registry(dist) => {
writeln!(printer.stdout(), "{}=={}", dist.name().bold(), dist.version)?;
format!("{}=={}", dist.name().bold(), dist.version)
}
InstalledDist::Url(dist) => {
if dist.editable {
writeln!(printer.stdout(), "-e {}", dist.url)?;
format!("-e {}", dist.url)
} else {
writeln!(printer.stdout(), "{} @ {}", dist.name().bold(), dist.url)?;
format!("{} @ {}", dist.name().bold(), dist.url)
}
}
InstalledDist::EggInfoFile(dist) => {
writeln!(printer.stdout(), "{}=={}", dist.name().bold(), dist.version)?;
format!("{}=={}", dist.name().bold(), dist.version)
}
InstalledDist::EggInfoDirectory(dist) => {
writeln!(printer.stdout(), "{}=={}", dist.name().bold(), dist.version)?;
format!("{}=={}", dist.name().bold(), dist.version)
}
InstalledDist::LegacyEditable(dist) => {
writeln!(printer.stdout(), "-e {}", dist.target.display())?;
format!("-e {}", dist.target.display())
}
}
}
})
.dedup()
.try_for_each(|dist| writeln!(printer.stdout(), "{dist}"))?;
// Validate that the environment is consistent.
if strict {
// Determine the markers to use for resolution.
let markers = environment.interpreter().resolver_marker_environment();
for diagnostic in site_packages.diagnostics(&markers)? {
writeln!(
printer.stderr(),
"{}{} {}",
"warning".yellow().bold(),
":".bold(),
diagnostic.message().bold()
)?;
for entry in site_packages {
for diagnostic in entry.diagnostics(&markers)? {
writeln!(
printer.stderr(),
"{}{} {}",
"warning".yellow().bold(),
":".bold(),
diagnostic.message().bold()
)?;
}
}
}

View file

@ -627,6 +627,7 @@ async fn run(mut cli: Cli) -> Result<ExitStatus> {
args.settings.strict,
args.settings.python.as_deref(),
args.settings.system,
args.paths,
&cache,
printer,
)

View file

@ -1883,6 +1883,7 @@ impl PipUninstallSettings {
#[derive(Debug, Clone)]
pub(crate) struct PipFreezeSettings {
pub(crate) exclude_editable: bool,
pub(crate) paths: Option<Vec<PathBuf>>,
pub(crate) settings: PipSettings,
}
@ -1894,6 +1895,7 @@ impl PipFreezeSettings {
strict,
no_strict,
python,
paths,
system,
no_system,
compat_args: _,
@ -1901,6 +1903,7 @@ impl PipFreezeSettings {
Self {
exclude_editable,
paths,
settings: PipSettings::combine(
PipOptions {
python: python.and_then(Maybe::into_option),

View file

@ -354,3 +354,102 @@ Version: 0.22.0
Ok(())
}
#[test]
fn freeze_path() -> Result<()> {
let context = TestContext::new("3.12");
let requirements_txt = context.temp_dir.child("requirements.txt");
requirements_txt.write_str("MarkupSafe==2.1.3\ntomli==2.0.1")?;
let target = context.temp_dir.child("install-path");
// Run `pip sync`.
context
.pip_sync()
.arg(requirements_txt.path())
.arg("--target")
.arg(target.path())
.assert()
.success();
// Run `pip freeze`.
uv_snapshot!(context.filters(), context.pip_freeze()
.arg("--path")
.arg(target.path()), @r"
success: true
exit_code: 0
----- stdout -----
markupsafe==2.1.3
tomli==2.0.1
----- stderr -----
");
Ok(())
}
#[test]
fn freeze_multiple_paths() -> Result<()> {
let context = TestContext::new("3.12");
let requirements_txt1 = context.temp_dir.child("requirements1.txt");
requirements_txt1.write_str("MarkupSafe==2.1.3\ntomli==2.0.1")?;
let requirements_txt2 = context.temp_dir.child("requirements2.txt");
requirements_txt2.write_str("MarkupSafe==2.1.3\nrequests==2.31.0")?;
let target1 = context.temp_dir.child("install-path1");
let target2 = context.temp_dir.child("install-path2");
// Run `pip sync`.
for (target, requirements_txt) in [
(target1.path(), requirements_txt1),
(target2.path(), requirements_txt2),
] {
context
.pip_sync()
.arg(requirements_txt.path())
.arg("--target")
.arg(target)
.assert()
.success();
}
// Run `pip freeze`.
uv_snapshot!(context.filters(), context.pip_freeze().arg("--path").arg(target1.path()).arg("--path").arg(target2.path()), @r"
success: true
exit_code: 0
----- stdout -----
markupsafe==2.1.3
requests==2.31.0
tomli==2.0.1
----- stderr -----
");
Ok(())
}
// We follow pip in just ignoring nonexistent paths
#[test]
fn freeze_nonexistent_path() {
let context = TestContext::new("3.12");
let nonexistent_dir = {
let dir = context.temp_dir.child("blahblah");
assert!(!dir.exists());
dir
};
// Run `pip freeze`.
uv_snapshot!(context.filters(), context.pip_freeze()
.arg("--path")
.arg(nonexistent_dir.path()), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
");
}

View file

@ -7045,6 +7045,8 @@ uv pip freeze [OPTIONS]
<p>When disabled, uv will only use locally cached data and locally available files.</p>
<p>May also be set with the <code>UV_OFFLINE</code> environment variable.</p>
</dd><dt><code>--path</code> <i>paths</i></dt><dd><p>Restrict to the specified installation path for listing packages (can be used multiple times)</p>
</dd><dt><code>--project</code> <i>project</i></dt><dd><p>Run the command within the given project directory.</p>
<p>All <code>pyproject.toml</code>, <code>uv.toml</code>, and <code>.python-version</code> files will be discovered by walking up the directory tree from the project root, as will the project&#8217;s virtual environment (<code>.venv</code>).</p>