diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index e7ad0f07c53b..6595f7d556f9 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -2208,10 +2208,14 @@ pub struct ToolRunArgs { #[arg(long)] pub from: Option, - /// Include the following extra requirements. + /// Run with the given packages installed. #[arg(long)] pub with: Vec, + /// Run with all packages listed in the given `requirements.txt` files. + #[arg(long, value_parser = parse_maybe_file_path)] + pub with_requirements: Vec>, + #[command(flatten)] pub installer: ResolverInstallerArgs, @@ -2252,6 +2256,10 @@ pub struct ToolInstallArgs { #[arg(long)] pub with: Vec, + /// Run all requirements listed in the given `requirements.txt` files. + #[arg(long, value_parser = parse_maybe_file_path)] + pub with_requirements: Vec>, + #[command(flatten)] pub installer: ResolverInstallerArgs, diff --git a/crates/uv/src/commands/pip/uninstall.rs b/crates/uv/src/commands/pip/uninstall.rs index d0180cda2bf8..46f4170039c6 100644 --- a/crates/uv/src/commands/pip/uninstall.rs +++ b/crates/uv/src/commands/pip/uninstall.rs @@ -37,6 +37,7 @@ pub(crate) async fn pip_uninstall( printer: Printer, ) -> Result { let start = std::time::Instant::now(); + let client_builder = BaseClientBuilder::new() .connectivity(connectivity) .native_tls(native_tls) diff --git a/crates/uv/src/commands/tool/common.rs b/crates/uv/src/commands/tool/common.rs index e91d32df94ed..8b4ca39f0b45 100644 --- a/crates/uv/src/commands/tool/common.rs +++ b/crates/uv/src/commands/tool/common.rs @@ -1,11 +1,11 @@ use distribution_types::{InstalledDist, Name}; use pypi_types::Requirement; use uv_cache::Cache; -use uv_client::Connectivity; +use uv_client::{BaseClientBuilder, Connectivity}; use uv_configuration::{Concurrency, PreviewMode}; use uv_installer::SitePackages; use uv_python::{Interpreter, PythonEnvironment}; -use uv_requirements::RequirementsSpecification; +use uv_requirements::{RequirementsSource, RequirementsSpecification}; use uv_tool::entrypoint_paths; use crate::commands::{project, SharedState}; @@ -14,7 +14,7 @@ use crate::settings::ResolverInstallerSettings; /// Resolve any [`UnnamedRequirements`]. pub(super) async fn resolve_requirements( - requirements: impl Iterator, + requirements: &[RequirementsSource], interpreter: &Interpreter, settings: &ResolverInstallerSettings, state: &SharedState, @@ -25,18 +25,17 @@ pub(super) async fn resolve_requirements( cache: &Cache, printer: Printer, ) -> anyhow::Result> { + let client_builder = BaseClientBuilder::new() + .connectivity(connectivity) + .native_tls(native_tls); + // Parse the requirements. - let requirements = { - let mut parsed = vec![]; - for requirement in requirements { - parsed.push(RequirementsSpecification::parse_package(requirement)?); - } - parsed - }; + let spec = + RequirementsSpecification::from_simple_sources(requirements, &client_builder).await?; // Resolve the parsed requirements. project::resolve_names( - requirements, + spec.requirements, interpreter, settings, state, diff --git a/crates/uv/src/commands/tool/install.rs b/crates/uv/src/commands/tool/install.rs index 41c3c2081f45..44f4c3fba418 100644 --- a/crates/uv/src/commands/tool/install.rs +++ b/crates/uv/src/commands/tool/install.rs @@ -22,7 +22,7 @@ use uv_python::{ EnvironmentPreference, PythonEnvironment, PythonFetch, PythonInstallation, PythonPreference, PythonRequest, }; -use uv_requirements::RequirementsSpecification; +use uv_requirements::{RequirementsSource, RequirementsSpecification}; use uv_shell::Shell; use uv_tool::{entrypoint_paths, find_executable_directory, InstalledTools, Tool, ToolEntrypoint}; use uv_warnings::{warn_user, warn_user_once}; @@ -30,7 +30,7 @@ use uv_warnings::{warn_user, warn_user_once}; use crate::commands::reporters::PythonDownloadReporter; use crate::commands::tool::common::resolve_requirements; use crate::commands::{ - project::{resolve_environment, sync_environment, update_environment}, + project::{resolve_environment, resolve_names, sync_environment, update_environment}, tool::common::matching_packages, }; use crate::commands::{ExitStatus, SharedState}; @@ -41,8 +41,8 @@ use crate::settings::ResolverInstallerSettings; pub(crate) async fn install( package: String, from: Option, + with: &[RequirementsSource], python: Option, - with: Vec, force: bool, settings: ResolverInstallerSettings, preview: PreviewMode, @@ -91,21 +91,23 @@ pub(crate) async fn install( bail!("Package requirement (`{from}`) provided with `--from` conflicts with install request (`{package}`)", from = from.cyan(), package = package.cyan()) }; - let from_requirement = resolve_requirements( - std::iter::once(from.as_str()), - &interpreter, - &settings, - &state, - preview, - connectivity, - concurrency, - native_tls, - cache, - printer, - ) - .await? - .pop() - .unwrap(); + let from_requirement = { + resolve_names( + vec![RequirementsSpecification::parse_package(&from)?], + &interpreter, + &settings, + &state, + preview, + connectivity, + concurrency, + native_tls, + cache, + printer, + ) + .await? + .pop() + .unwrap() + }; // Check if the positional name conflicts with `--from`. if from_requirement.name != package { @@ -119,8 +121,8 @@ pub(crate) async fn install( from_requirement } else { - resolve_requirements( - std::iter::once(package.as_str()), + resolve_names( + vec![RequirementsSpecification::parse_package(&package)?], &interpreter, &settings, &state, @@ -142,7 +144,7 @@ pub(crate) async fn install( requirements.push(from.clone()); requirements.extend( resolve_requirements( - with.iter().map(String::as_str), + with, &interpreter, &settings, &state, diff --git a/crates/uv/src/commands/tool/run.rs b/crates/uv/src/commands/tool/run.rs index 35872b15af5d..609d4e108983 100644 --- a/crates/uv/src/commands/tool/run.rs +++ b/crates/uv/src/commands/tool/run.rs @@ -23,12 +23,15 @@ use uv_python::{ EnvironmentPreference, PythonEnvironment, PythonFetch, PythonInstallation, PythonPreference, PythonRequest, }; +use uv_requirements::{RequirementsSource, RequirementsSpecification}; use uv_tool::{entrypoint_paths, InstalledTools}; use uv_warnings::{warn_user, warn_user_once}; use crate::commands::reporters::PythonDownloadReporter; use crate::commands::tool::common::resolve_requirements; -use crate::commands::{project::environment::CachedEnvironment, tool::common::matching_packages}; +use crate::commands::{ + project, project::environment::CachedEnvironment, tool::common::matching_packages, +}; use crate::commands::{ExitStatus, SharedState}; use crate::printer::Printer; use crate::settings::ResolverInstallerSettings; @@ -54,7 +57,7 @@ impl Display for ToolRunCommand { pub(crate) async fn run( command: ExternalCommand, from: Option, - with: Vec, + with: &[RequirementsSource], python: Option, settings: ResolverInstallerSettings, invocation_source: ToolRunCommand, @@ -86,7 +89,7 @@ pub(crate) async fn run( // Get or create a compatible environment in which to execute the tool. let (from, environment) = get_or_create_environment( &from, - &with, + with, python.as_deref(), &settings, isolated, @@ -273,7 +276,7 @@ fn warn_executable_not_provided_by_package( /// [`PythonEnvironment`]. Otherwise, gets or creates a [`CachedEnvironment`]. async fn get_or_create_environment( from: &str, - with: &[String], + with: &[RequirementsSource], python: Option<&str>, settings: &ResolverInstallerSettings, isolated: bool, @@ -312,8 +315,8 @@ async fn get_or_create_environment( // Resolve the `from` requirement. let from = { - resolve_requirements( - std::iter::once(from), + project::resolve_names( + vec![RequirementsSpecification::parse_package(from)?], &interpreter, settings, &state, @@ -335,7 +338,7 @@ async fn get_or_create_environment( requirements.push(from.clone()); requirements.extend( resolve_requirements( - with.iter().map(String::as_str), + with, &interpreter, settings, &state, diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index ef0564d314a6..f35ab03f949b 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -618,10 +618,22 @@ async fn run(cli: Cli) -> Result { // Initialize the cache. let cache = cache.init()?.with_refresh(args.refresh); + + let requirements = args + .with + .into_iter() + .map(RequirementsSource::from_package) + .chain( + args.with_requirements + .into_iter() + .map(RequirementsSource::from_requirements_file), + ) + .collect::>(); + commands::tool_run( args.command, args.from, - args.with, + &requirements, args.python, args.settings, invocation_source, @@ -647,11 +659,22 @@ async fn run(cli: Cli) -> Result { // Initialize the cache. let cache = cache.init()?.with_refresh(args.refresh); + let requirements = args + .with + .into_iter() + .map(RequirementsSource::from_package) + .chain( + args.with_requirements + .into_iter() + .map(RequirementsSource::from_requirements_file), + ) + .collect::>(); + commands::tool_install( args.package, args.from, + &requirements, args.python, - args.with, args.force, args.settings, globals.preview, diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index db70e980ee23..61b9c375fc0f 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -248,6 +248,7 @@ pub(crate) struct ToolRunSettings { pub(crate) command: ExternalCommand, pub(crate) from: Option, pub(crate) with: Vec, + pub(crate) with_requirements: Vec, pub(crate) python: Option, pub(crate) refresh: Refresh, pub(crate) settings: ResolverInstallerSettings, @@ -261,6 +262,7 @@ impl ToolRunSettings { command, from, with, + with_requirements, installer, build, refresh, @@ -271,6 +273,10 @@ impl ToolRunSettings { command, from, with, + with_requirements: with_requirements + .into_iter() + .filter_map(Maybe::into_option) + .collect(), python, refresh: Refresh::from(refresh), settings: ResolverInstallerSettings::combine( @@ -288,6 +294,7 @@ pub(crate) struct ToolInstallSettings { pub(crate) package: String, pub(crate) from: Option, pub(crate) with: Vec, + pub(crate) with_requirements: Vec, pub(crate) python: Option, pub(crate) refresh: Refresh, pub(crate) settings: ResolverInstallerSettings, @@ -302,6 +309,7 @@ impl ToolInstallSettings { package, from, with, + with_requirements, installer, force, build, @@ -313,6 +321,10 @@ impl ToolInstallSettings { package, from, with, + with_requirements: with_requirements + .into_iter() + .filter_map(Maybe::into_option) + .collect(), python, force, refresh: Refresh::from(refresh), diff --git a/crates/uv/tests/tool_install.rs b/crates/uv/tests/tool_install.rs index 961890960429..654e7e5cec1a 100644 --- a/crates/uv/tests/tool_install.rs +++ b/crates/uv/tests/tool_install.rs @@ -1273,6 +1273,106 @@ fn tool_install_unnamed_with() { "###); } +/// Test installing a tool with extra requirements from a `requirements.txt` file. +#[test] +fn tool_install_requirements_txt() { + let context = TestContext::new("3.12") + .with_filtered_counts() + .with_filtered_exe_suffix(); + let tool_dir = context.temp_dir.child("tools"); + let bin_dir = context.temp_dir.child("bin"); + + let requirements_txt = context.temp_dir.child("requirements.txt"); + requirements_txt.write_str("iniconfig").unwrap(); + + // Install `black` + uv_snapshot!(context.filters(), context.tool_install() + .arg("black") + .arg("--with-requirements") + .arg("requirements.txt") + .env("UV_TOOL_DIR", tool_dir.as_os_str()) + .env("XDG_BIN_HOME", bin_dir.as_os_str()) + .env("PATH", bin_dir.as_os_str()), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv tool install` is experimental and may change without warning + Resolved [N] packages in [TIME] + Prepared [N] packages in [TIME] + Installed [N] packages in [TIME] + + black==24.3.0 + + click==8.1.7 + + iniconfig==2.0.0 + + mypy-extensions==1.0.0 + + packaging==24.0 + + pathspec==0.12.1 + + platformdirs==4.2.0 + Installed 2 executables: black, blackd + "###); + + insta::with_settings!({ + filters => context.filters(), + }, { + // We should have a tool receipt + assert_snapshot!(fs_err::read_to_string(tool_dir.join("black").join("uv-receipt.toml")).unwrap(), @r###" + [tool] + requirements = [ + "black", + "iniconfig", + ] + entrypoints = [ + { name = "black", install-path = "[TEMP_DIR]/bin/black" }, + { name = "blackd", install-path = "[TEMP_DIR]/bin/blackd" }, + ] + "###); + }); + + // Update the `requirements.txt` file. + requirements_txt.write_str("idna").unwrap(); + + // Install `black` + uv_snapshot!(context.filters(), context.tool_install() + .arg("black") + .arg("--with-requirements") + .arg("requirements.txt") + .env("UV_TOOL_DIR", tool_dir.as_os_str()) + .env("XDG_BIN_HOME", bin_dir.as_os_str()) + .env("PATH", bin_dir.as_os_str()), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv tool install` is experimental and may change without warning + Resolved [N] packages in [TIME] + Prepared [N] packages in [TIME] + Uninstalled [N] packages in [TIME] + Installed [N] packages in [TIME] + + idna==3.6 + - iniconfig==2.0.0 + Installed 2 executables: black, blackd + "###); + + insta::with_settings!({ + filters => context.filters(), + }, { + // We should have a tool receipt + assert_snapshot!(fs_err::read_to_string(tool_dir.join("black").join("uv-receipt.toml")).unwrap(), @r###" + [tool] + requirements = [ + "black", + "idna", + ] + entrypoints = [ + { name = "black", install-path = "[TEMP_DIR]/bin/black" }, + { name = "blackd", install-path = "[TEMP_DIR]/bin/blackd" }, + ] + "###); + }); +} + /// Test upgrading an already installed tool. #[test] fn tool_install_upgrade() { diff --git a/crates/uv/tests/tool_run.rs b/crates/uv/tests/tool_run.rs index 5cee5f8ae9c7..1de2eb9ce28d 100644 --- a/crates/uv/tests/tool_run.rs +++ b/crates/uv/tests/tool_run.rs @@ -619,3 +619,47 @@ fn tool_run_url() { + werkzeug==3.0.1 "###); } + +/// Read requirements from a `requirements.txt` file. +#[test] +fn tool_run_requirements_txt() { + let context = TestContext::new("3.12").with_filtered_counts(); + let tool_dir = context.temp_dir.child("tools"); + let bin_dir = context.temp_dir.child("bin"); + + let requirements_txt = context.temp_dir.child("requirements.txt"); + requirements_txt.write_str("iniconfig").unwrap(); + + // We treat arguments before the command as uv arguments + uv_snapshot!(context.filters(), context.tool_run() + .arg("--with-requirements") + .arg("requirements.txt") + .arg("--with") + .arg("typing-extensions") + .arg("flask") + .arg("--version") + .env("UV_TOOL_DIR", tool_dir.as_os_str()) + .env("XDG_BIN_HOME", bin_dir.as_os_str()), @r###" + success: true + exit_code: 0 + ----- stdout ----- + Python 3.12.[X] + Flask 3.0.2 + Werkzeug 3.0.1 + + ----- stderr ----- + warning: `uv tool run` is experimental and may change without warning + Resolved [N] packages in [TIME] + Prepared [N] packages in [TIME] + Installed [N] packages in [TIME] + + blinker==1.7.0 + + click==8.1.7 + + flask==3.0.2 + + iniconfig==2.0.0 + + itsdangerous==2.1.2 + + jinja2==3.1.3 + + markupsafe==2.1.5 + + typing-extensions==4.10.0 + + werkzeug==3.0.1 + "###); +}