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>>, 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. /// List packages in the system Python environment.
/// ///
/// Disables discovery of virtual environments. /// Disables discovery of virtual environments.

View file

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

View file

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

View file

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

View file

@ -354,3 +354,102 @@ Version: 0.22.0
Ok(()) 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>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> <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> </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> <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>