Skip to content

Commit

Permalink
Add uv pip check (astral-sh#2397)
Browse files Browse the repository at this point in the history
<!--
Thank you for contributing to uv! To help us out with reviewing, please
consider the following:

- Does this pull request include a summary of the change? (See below.)
- Does this pull request include a descriptive title?
- Does this pull request include references to any relevant issues?
-->

## Summary
Resolves astral-sh#2391
<!-- What's the purpose of the change? What does it do, and why? -->

## Test Plan
Added a few tests to make sure that the exit code returned is 0 when
there's no conflict; 1 when there's any conflict.
<!-- How was it tested? -->
  • Loading branch information
ChannyClaus authored Mar 15, 2024
1 parent 9c27f92 commit 5b2293d
Show file tree
Hide file tree
Showing 4 changed files with 337 additions and 0 deletions.
2 changes: 2 additions & 0 deletions crates/uv/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use owo_colors::OwoColorize;
pub(crate) use cache_clean::cache_clean;
pub(crate) use cache_dir::cache_dir;
use distribution_types::InstalledMetadata;
pub(crate) use pip_check::pip_check;
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;
Expand All @@ -26,6 +27,7 @@ use crate::printer::Printer;

mod cache_clean;
mod cache_dir;
mod pip_check;
mod pip_compile;
mod pip_freeze;
mod pip_install;
Expand Down
59 changes: 59 additions & 0 deletions crates/uv/src/commands/pip_check.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
use std::fmt::Write;

use anyhow::Result;
use owo_colors::OwoColorize;
use tracing::debug;

use uv_cache::Cache;
use uv_fs::Simplified;
use uv_installer::SitePackages;
use uv_interpreter::PythonEnvironment;

use crate::commands::ExitStatus;
use crate::printer::Printer;

/// Show information about one or more installed packages.
pub(crate) fn pip_check(
python: Option<&str>,
system: bool,
cache: &Cache,
printer: Printer,
) -> Result<ExitStatus> {
// Detect the current Python interpreter.
let venv = if let Some(python) = python {
PythonEnvironment::from_requested_python(python, cache)?
} else if system {
PythonEnvironment::from_default_python(cache)?
} else {
match PythonEnvironment::from_virtualenv(cache) {
Ok(venv) => venv,
Err(uv_interpreter::Error::VenvNotFound) => {
PythonEnvironment::from_default_python(cache)?
}
Err(err) => return Err(err.into()),
}
};

debug!(
"Using Python {} environment at {}",
venv.interpreter().python_version(),
venv.python_executable().simplified_display().cyan()
);

// Build the installed index.
let site_packages = SitePackages::from_executable(&venv)?;

let mut is_compatible = true;
// This loop is entered if and only if there is at least one conflict.
for diagnostic in site_packages.diagnostics()? {
is_compatible = false;
writeln!(printer.stdout(), "{}", diagnostic.message())?;
}

if !is_compatible {
return Ok(ExitStatus::Failure);
}

writeln!(printer.stdout(), "Installed packages pass the check.").unwrap();
Ok(ExitStatus::Success)
}
46 changes: 46 additions & 0 deletions crates/uv/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,8 @@ enum PipCommand {
List(PipListArgs),
/// Show information about one or more installed packages.
Show(PipShowArgs),
/// Verify installed packages have compatible dependencies.
Check(PipCheckArgs),
}

/// Clap parser for the union of date and datetime
Expand Down Expand Up @@ -1095,6 +1097,47 @@ struct PipListArgs {
system: bool,
}

#[derive(Args)]
#[allow(clippy::struct_excessive_bools)]
struct PipCheckArgs {
/// The Python interpreter for which packages should be listed.
///
/// By default, `uv` lists packages in the currently activated virtual environment, or a virtual
/// environment (`.venv`) located in the current working directory or any parent directory,
/// falling back to the system Python if no virtual environment is found.
///
/// Supported formats:
/// - `3.10` looks for an installed Python 3.10 using `py --list-paths` on Windows, or
/// `python3.10` on Linux and macOS.
/// - `python3.10` or `python.exe` looks for a binary with the given name in `PATH`.
/// - `/home/ferris/.local/bin/python3.10` uses the exact Python at the given path.
#[clap(
long,
short,
verbatim_doc_comment,
conflicts_with = "system",
group = "discovery"
)]
python: Option<String>,

/// List packages for the system Python.
///
/// By default, `uv` lists packages in the currently activated virtual environment, or a virtual
/// environment (`.venv`) located in the current working directory or any parent directory,
/// falling back to the system Python if no virtual environment is found. The `--system` option
/// instructs `uv` to use the first Python found in the system `PATH`.
///
/// WARNING: `--system` is intended for use in continuous integration (CI) environments and
/// should be used with caution.
#[clap(
long,
conflicts_with = "python",
env = "UV_SYSTEM_PYTHON",
group = "discovery"
)]
system: bool,
}

#[derive(Args)]
#[allow(clippy::struct_excessive_bools)]
struct PipShowArgs {
Expand Down Expand Up @@ -1689,6 +1732,9 @@ async fn run() -> Result<ExitStatus> {
&cache,
printer,
),
Commands::Pip(PipNamespace {
command: PipCommand::Check(args),
}) => commands::pip_check(args.python.as_deref(), args.system, &cache, printer),
Commands::Cache(CacheNamespace {
command: CacheCommand::Clean(args),
})
Expand Down
230 changes: 230 additions & 0 deletions crates/uv/tests/pip_check.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
use std::process::Command;

use anyhow::Result;
use assert_fs::fixture::PathChild;
use assert_fs::fixture::{FileTouch, FileWriteStr};

use common::uv_snapshot;

use crate::common::{get_bin, TestContext, EXCLUDE_NEWER};

mod common;

/// Create a `pip install` command with options shared across scenarios.
fn install_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);

if cfg!(all(windows, debug_assertions)) {
// TODO(konstin): Reduce stack usage in debug mode enough that the tests pass with the
// default windows stack of 1MB
command.env("UV_STACK_SIZE", (2 * 1024 * 1024).to_string());
}

command
}

#[test]
fn check_compatible_packages() -> Result<()> {
let context = TestContext::new("3.12");

let requirements_txt = context.temp_dir.child("requirements.txt");
requirements_txt.touch()?;
requirements_txt.write_str("requests==2.31.0")?;

uv_snapshot!(install_command(&context)
.arg("-r")
.arg("requirements.txt")
.arg("--strict"), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 5 packages in [TIME]
Downloaded 5 packages in [TIME]
Installed 5 packages in [TIME]
+ certifi==2023.11.17
+ charset-normalizer==3.3.2
+ idna==3.4
+ requests==2.31.0
+ urllib3==2.1.0
"###
);

// Guards against the package names being sorted.
uv_snapshot!([], Command::new(get_bin())
.arg("pip")
.arg("check")
.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 -----
Installed packages pass the check.
----- stderr -----
"###
);

Ok(())
}

// requests 2.31.0 requires idna (<4,>=2.5)
// this test force-installs idna 2.4 to trigger a failure.
#[test]
fn check_incompatible_packages() -> Result<()> {
let context = TestContext::new("3.12");

let requirements_txt = context.temp_dir.child("requirements.txt");
requirements_txt.touch()?;
requirements_txt.write_str("requests==2.31.0")?;

uv_snapshot!(install_command(&context)
.arg("-r")
.arg("requirements.txt")
.arg("--strict"), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 5 packages in [TIME]
Downloaded 5 packages in [TIME]
Installed 5 packages in [TIME]
+ certifi==2023.11.17
+ charset-normalizer==3.3.2
+ idna==3.4
+ requests==2.31.0
+ urllib3==2.1.0
"###
);

let requirements_txt_idna = context.temp_dir.child("requirements_idna.txt");
requirements_txt_idna.touch()?;
requirements_txt_idna.write_str("idna==2.4")?;

uv_snapshot!(install_command(&context)
.arg("-r")
.arg("requirements_idna.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]
- idna==3.4
+ idna==2.4
warning: The package `requests` requires `idna <4, >=2.5`, but `2.4` is installed.
"###
);

// Guards against the package names being sorted.
uv_snapshot!([], Command::new(get_bin())
.arg("pip")
.arg("check")
.arg("--cache-dir")
.arg(context.cache_dir.path())
.env("VIRTUAL_ENV", context.venv.as_os_str())
.current_dir(&context.temp_dir), @r###"
success: false
exit_code: 1
----- stdout -----
The package `requests` requires `idna <4, >=2.5`, but `2.4` is installed.
----- stderr -----
"###
);

Ok(())
}

// requests 2.31.0 requires idna (<4,>=2.5) and urllib3<3,>=1.21.1
// this test force-installs idna 2.4 and urllib3 1.20 to trigger a failure
// with multiple incompatible packages.
#[test]
fn check_multiple_incompatible_packages() -> Result<()> {
let context = TestContext::new("3.12");

let requirements_txt = context.temp_dir.child("requirements.txt");
requirements_txt.touch()?;
requirements_txt.write_str("requests==2.31.0")?;

uv_snapshot!(install_command(&context)
.arg("-r")
.arg("requirements.txt")
.arg("--strict"), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 5 packages in [TIME]
Downloaded 5 packages in [TIME]
Installed 5 packages in [TIME]
+ certifi==2023.11.17
+ charset-normalizer==3.3.2
+ idna==3.4
+ requests==2.31.0
+ urllib3==2.1.0
"###
);

let requirements_txt_two = context.temp_dir.child("requirements_two.txt");
requirements_txt_two.touch()?;
requirements_txt_two.write_str("idna==2.4\nurllib3==1.20")?;

uv_snapshot!(install_command(&context)
.arg("-r")
.arg("requirements_two.txt")
.arg("--strict"), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 2 packages in [TIME]
Downloaded 2 packages in [TIME]
Installed 2 packages in [TIME]
- idna==3.4
+ idna==2.4
- urllib3==2.1.0
+ urllib3==1.20
warning: The package `requests` requires `idna <4, >=2.5`, but `2.4` is installed.
warning: The package `requests` requires `urllib3 <3, >=1.21.1`, but `1.20` is installed.
"###
);

// Guards against the package names being sorted.
uv_snapshot!([], Command::new(get_bin())
.arg("pip")
.arg("check")
.arg("--cache-dir")
.arg(context.cache_dir.path())
.env("VIRTUAL_ENV", context.venv.as_os_str())
.current_dir(&context.temp_dir), @r###"
success: false
exit_code: 1
----- stdout -----
The package `requests` requires `idna <4, >=2.5`, but `2.4` is installed.
The package `requests` requires `urllib3 <3, >=1.21.1`, but `1.20` is installed.
----- stderr -----
"###
);

Ok(())
}

0 comments on commit 5b2293d

Please sign in to comment.