Skip to content

Commit

Permalink
Implement pip freeze --path (#10488)
Browse files Browse the repository at this point in the history
## 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>
  • Loading branch information
ericmarkmartin and charliermarsh authored Jan 13, 2025
1 parent 97c1877 commit f261c65
Show file tree
Hide file tree
Showing 6 changed files with 151 additions and 21 deletions.
4 changes: 4 additions & 0 deletions crates/uv-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
63 changes: 42 additions & 21 deletions crates/uv/src/commands/pip/freeze.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use std::fmt::Write;
use std::path::PathBuf;

use anyhow::Result;
use itertools::Itertools;
Expand All @@ -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> {
Expand All @@ -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()
)?;
}
}
}

Expand Down
1 change: 1 addition & 0 deletions crates/uv/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down
3 changes: 3 additions & 0 deletions crates/uv/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}

Expand All @@ -1894,13 +1895,15 @@ impl PipFreezeSettings {
strict,
no_strict,
python,
paths,
system,
no_system,
compat_args: _,
} = args;

Self {
exclude_editable,
paths,
settings: PipSettings::combine(
PipOptions {
python: python.and_then(Maybe::into_option),
Expand Down
99 changes: 99 additions & 0 deletions crates/uv/tests/it/pip_freeze.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 -----
");
}
2 changes: 2 additions & 0 deletions docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down

0 comments on commit f261c65

Please sign in to comment.