From 5b2293dbd0a853c5106b7cd671faae6be6530987 Mon Sep 17 00:00:00 2001 From: Chan Kang Date: Fri, 15 Mar 2024 13:39:55 -0400 Subject: [PATCH] Add `uv pip check` (#2397) ## Summary Resolves https://github.com/astral-sh/uv/issues/2391 ## 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. --- crates/uv/src/commands/mod.rs | 2 + crates/uv/src/commands/pip_check.rs | 59 +++++++ crates/uv/src/main.rs | 46 ++++++ crates/uv/tests/pip_check.rs | 230 ++++++++++++++++++++++++++++ 4 files changed, 337 insertions(+) create mode 100644 crates/uv/src/commands/pip_check.rs create mode 100644 crates/uv/tests/pip_check.rs diff --git a/crates/uv/src/commands/mod.rs b/crates/uv/src/commands/mod.rs index 97a2a3853491..ba71c3dcbda5 100644 --- a/crates/uv/src/commands/mod.rs +++ b/crates/uv/src/commands/mod.rs @@ -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; @@ -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; diff --git a/crates/uv/src/commands/pip_check.rs b/crates/uv/src/commands/pip_check.rs new file mode 100644 index 000000000000..9cc0056b88d3 --- /dev/null +++ b/crates/uv/src/commands/pip_check.rs @@ -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 { + // 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) +} diff --git a/crates/uv/src/main.rs b/crates/uv/src/main.rs index 4b57365b1fc3..f581a2c035a9 100644 --- a/crates/uv/src/main.rs +++ b/crates/uv/src/main.rs @@ -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 @@ -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, + + /// 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 { @@ -1689,6 +1732,9 @@ async fn run() -> Result { &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), }) diff --git a/crates/uv/tests/pip_check.rs b/crates/uv/tests/pip_check.rs new file mode 100644 index 000000000000..52895e99f4ce --- /dev/null +++ b/crates/uv/tests/pip_check.rs @@ -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(()) +}