Support requirements.txt files in uv tool install and uv tool run (#5362)

## Summary

Closes https://github.com/astral-sh/uv/issues/5347.
Closes https://github.com/astral-sh/uv/issues/5348.
This commit is contained in:
Charlie Marsh 2024-07-23 16:06:17 -04:00 committed by GitHub
parent 7ddf67a72b
commit 2cdcc61da9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 234 additions and 42 deletions

View file

@ -2208,10 +2208,14 @@ pub struct ToolRunArgs {
#[arg(long)] #[arg(long)]
pub from: Option<String>, pub from: Option<String>,
/// Include the following extra requirements. /// Run with the given packages installed.
#[arg(long)] #[arg(long)]
pub with: Vec<String>, pub with: Vec<String>,
/// Run with all packages listed in the given `requirements.txt` files.
#[arg(long, value_parser = parse_maybe_file_path)]
pub with_requirements: Vec<Maybe<PathBuf>>,
#[command(flatten)] #[command(flatten)]
pub installer: ResolverInstallerArgs, pub installer: ResolverInstallerArgs,
@ -2252,6 +2256,10 @@ pub struct ToolInstallArgs {
#[arg(long)] #[arg(long)]
pub with: Vec<String>, pub with: Vec<String>,
/// Run all requirements listed in the given `requirements.txt` files.
#[arg(long, value_parser = parse_maybe_file_path)]
pub with_requirements: Vec<Maybe<PathBuf>>,
#[command(flatten)] #[command(flatten)]
pub installer: ResolverInstallerArgs, pub installer: ResolverInstallerArgs,

View file

@ -37,6 +37,7 @@ pub(crate) async fn pip_uninstall(
printer: Printer, printer: Printer,
) -> Result<ExitStatus> { ) -> Result<ExitStatus> {
let start = std::time::Instant::now(); let start = std::time::Instant::now();
let client_builder = BaseClientBuilder::new() let client_builder = BaseClientBuilder::new()
.connectivity(connectivity) .connectivity(connectivity)
.native_tls(native_tls) .native_tls(native_tls)

View file

@ -1,11 +1,11 @@
use distribution_types::{InstalledDist, Name}; use distribution_types::{InstalledDist, Name};
use pypi_types::Requirement; use pypi_types::Requirement;
use uv_cache::Cache; use uv_cache::Cache;
use uv_client::Connectivity; use uv_client::{BaseClientBuilder, Connectivity};
use uv_configuration::{Concurrency, PreviewMode}; use uv_configuration::{Concurrency, PreviewMode};
use uv_installer::SitePackages; use uv_installer::SitePackages;
use uv_python::{Interpreter, PythonEnvironment}; use uv_python::{Interpreter, PythonEnvironment};
use uv_requirements::RequirementsSpecification; use uv_requirements::{RequirementsSource, RequirementsSpecification};
use uv_tool::entrypoint_paths; use uv_tool::entrypoint_paths;
use crate::commands::{project, SharedState}; use crate::commands::{project, SharedState};
@ -14,7 +14,7 @@ use crate::settings::ResolverInstallerSettings;
/// Resolve any [`UnnamedRequirements`]. /// Resolve any [`UnnamedRequirements`].
pub(super) async fn resolve_requirements( pub(super) async fn resolve_requirements(
requirements: impl Iterator<Item = &str>, requirements: &[RequirementsSource],
interpreter: &Interpreter, interpreter: &Interpreter,
settings: &ResolverInstallerSettings, settings: &ResolverInstallerSettings,
state: &SharedState, state: &SharedState,
@ -25,18 +25,17 @@ pub(super) async fn resolve_requirements(
cache: &Cache, cache: &Cache,
printer: Printer, printer: Printer,
) -> anyhow::Result<Vec<Requirement>> { ) -> anyhow::Result<Vec<Requirement>> {
let client_builder = BaseClientBuilder::new()
.connectivity(connectivity)
.native_tls(native_tls);
// Parse the requirements. // Parse the requirements.
let requirements = { let spec =
let mut parsed = vec![]; RequirementsSpecification::from_simple_sources(requirements, &client_builder).await?;
for requirement in requirements {
parsed.push(RequirementsSpecification::parse_package(requirement)?);
}
parsed
};
// Resolve the parsed requirements. // Resolve the parsed requirements.
project::resolve_names( project::resolve_names(
requirements, spec.requirements,
interpreter, interpreter,
settings, settings,
state, state,

View file

@ -22,7 +22,7 @@ use uv_python::{
EnvironmentPreference, PythonEnvironment, PythonFetch, PythonInstallation, PythonPreference, EnvironmentPreference, PythonEnvironment, PythonFetch, PythonInstallation, PythonPreference,
PythonRequest, PythonRequest,
}; };
use uv_requirements::RequirementsSpecification; use uv_requirements::{RequirementsSource, RequirementsSpecification};
use uv_shell::Shell; use uv_shell::Shell;
use uv_tool::{entrypoint_paths, find_executable_directory, InstalledTools, Tool, ToolEntrypoint}; use uv_tool::{entrypoint_paths, find_executable_directory, InstalledTools, Tool, ToolEntrypoint};
use uv_warnings::{warn_user, warn_user_once}; 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::reporters::PythonDownloadReporter;
use crate::commands::tool::common::resolve_requirements; use crate::commands::tool::common::resolve_requirements;
use crate::commands::{ use crate::commands::{
project::{resolve_environment, sync_environment, update_environment}, project::{resolve_environment, resolve_names, sync_environment, update_environment},
tool::common::matching_packages, tool::common::matching_packages,
}; };
use crate::commands::{ExitStatus, SharedState}; use crate::commands::{ExitStatus, SharedState};
@ -41,8 +41,8 @@ use crate::settings::ResolverInstallerSettings;
pub(crate) async fn install( pub(crate) async fn install(
package: String, package: String,
from: Option<String>, from: Option<String>,
with: &[RequirementsSource],
python: Option<String>, python: Option<String>,
with: Vec<String>,
force: bool, force: bool,
settings: ResolverInstallerSettings, settings: ResolverInstallerSettings,
preview: PreviewMode, 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()) bail!("Package requirement (`{from}`) provided with `--from` conflicts with install request (`{package}`)", from = from.cyan(), package = package.cyan())
}; };
let from_requirement = resolve_requirements( let from_requirement = {
std::iter::once(from.as_str()), resolve_names(
&interpreter, vec![RequirementsSpecification::parse_package(&from)?],
&settings, &interpreter,
&state, &settings,
preview, &state,
connectivity, preview,
concurrency, connectivity,
native_tls, concurrency,
cache, native_tls,
printer, cache,
) printer,
.await? )
.pop() .await?
.unwrap(); .pop()
.unwrap()
};
// Check if the positional name conflicts with `--from`. // Check if the positional name conflicts with `--from`.
if from_requirement.name != package { if from_requirement.name != package {
@ -119,8 +121,8 @@ pub(crate) async fn install(
from_requirement from_requirement
} else { } else {
resolve_requirements( resolve_names(
std::iter::once(package.as_str()), vec![RequirementsSpecification::parse_package(&package)?],
&interpreter, &interpreter,
&settings, &settings,
&state, &state,
@ -142,7 +144,7 @@ pub(crate) async fn install(
requirements.push(from.clone()); requirements.push(from.clone());
requirements.extend( requirements.extend(
resolve_requirements( resolve_requirements(
with.iter().map(String::as_str), with,
&interpreter, &interpreter,
&settings, &settings,
&state, &state,

View file

@ -23,12 +23,15 @@ use uv_python::{
EnvironmentPreference, PythonEnvironment, PythonFetch, PythonInstallation, PythonPreference, EnvironmentPreference, PythonEnvironment, PythonFetch, PythonInstallation, PythonPreference,
PythonRequest, PythonRequest,
}; };
use uv_requirements::{RequirementsSource, RequirementsSpecification};
use uv_tool::{entrypoint_paths, InstalledTools}; use uv_tool::{entrypoint_paths, InstalledTools};
use uv_warnings::{warn_user, warn_user_once}; use uv_warnings::{warn_user, warn_user_once};
use crate::commands::reporters::PythonDownloadReporter; use crate::commands::reporters::PythonDownloadReporter;
use crate::commands::tool::common::resolve_requirements; 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::commands::{ExitStatus, SharedState};
use crate::printer::Printer; use crate::printer::Printer;
use crate::settings::ResolverInstallerSettings; use crate::settings::ResolverInstallerSettings;
@ -54,7 +57,7 @@ impl Display for ToolRunCommand {
pub(crate) async fn run( pub(crate) async fn run(
command: ExternalCommand, command: ExternalCommand,
from: Option<String>, from: Option<String>,
with: Vec<String>, with: &[RequirementsSource],
python: Option<String>, python: Option<String>,
settings: ResolverInstallerSettings, settings: ResolverInstallerSettings,
invocation_source: ToolRunCommand, invocation_source: ToolRunCommand,
@ -86,7 +89,7 @@ pub(crate) async fn run(
// Get or create a compatible environment in which to execute the tool. // Get or create a compatible environment in which to execute the tool.
let (from, environment) = get_or_create_environment( let (from, environment) = get_or_create_environment(
&from, &from,
&with, with,
python.as_deref(), python.as_deref(),
&settings, &settings,
isolated, isolated,
@ -273,7 +276,7 @@ fn warn_executable_not_provided_by_package(
/// [`PythonEnvironment`]. Otherwise, gets or creates a [`CachedEnvironment`]. /// [`PythonEnvironment`]. Otherwise, gets or creates a [`CachedEnvironment`].
async fn get_or_create_environment( async fn get_or_create_environment(
from: &str, from: &str,
with: &[String], with: &[RequirementsSource],
python: Option<&str>, python: Option<&str>,
settings: &ResolverInstallerSettings, settings: &ResolverInstallerSettings,
isolated: bool, isolated: bool,
@ -312,8 +315,8 @@ async fn get_or_create_environment(
// Resolve the `from` requirement. // Resolve the `from` requirement.
let from = { let from = {
resolve_requirements( project::resolve_names(
std::iter::once(from), vec![RequirementsSpecification::parse_package(from)?],
&interpreter, &interpreter,
settings, settings,
&state, &state,
@ -335,7 +338,7 @@ async fn get_or_create_environment(
requirements.push(from.clone()); requirements.push(from.clone());
requirements.extend( requirements.extend(
resolve_requirements( resolve_requirements(
with.iter().map(String::as_str), with,
&interpreter, &interpreter,
settings, settings,
&state, &state,

View file

@ -618,10 +618,22 @@ async fn run(cli: Cli) -> Result<ExitStatus> {
// Initialize the cache. // Initialize the cache.
let cache = cache.init()?.with_refresh(args.refresh); 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::<Vec<_>>();
commands::tool_run( commands::tool_run(
args.command, args.command,
args.from, args.from,
args.with, &requirements,
args.python, args.python,
args.settings, args.settings,
invocation_source, invocation_source,
@ -647,11 +659,22 @@ async fn run(cli: Cli) -> Result<ExitStatus> {
// Initialize the cache. // Initialize the cache.
let cache = cache.init()?.with_refresh(args.refresh); 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::<Vec<_>>();
commands::tool_install( commands::tool_install(
args.package, args.package,
args.from, args.from,
&requirements,
args.python, args.python,
args.with,
args.force, args.force,
args.settings, args.settings,
globals.preview, globals.preview,

View file

@ -248,6 +248,7 @@ pub(crate) struct ToolRunSettings {
pub(crate) command: ExternalCommand, pub(crate) command: ExternalCommand,
pub(crate) from: Option<String>, pub(crate) from: Option<String>,
pub(crate) with: Vec<String>, pub(crate) with: Vec<String>,
pub(crate) with_requirements: Vec<PathBuf>,
pub(crate) python: Option<String>, pub(crate) python: Option<String>,
pub(crate) refresh: Refresh, pub(crate) refresh: Refresh,
pub(crate) settings: ResolverInstallerSettings, pub(crate) settings: ResolverInstallerSettings,
@ -261,6 +262,7 @@ impl ToolRunSettings {
command, command,
from, from,
with, with,
with_requirements,
installer, installer,
build, build,
refresh, refresh,
@ -271,6 +273,10 @@ impl ToolRunSettings {
command, command,
from, from,
with, with,
with_requirements: with_requirements
.into_iter()
.filter_map(Maybe::into_option)
.collect(),
python, python,
refresh: Refresh::from(refresh), refresh: Refresh::from(refresh),
settings: ResolverInstallerSettings::combine( settings: ResolverInstallerSettings::combine(
@ -288,6 +294,7 @@ pub(crate) struct ToolInstallSettings {
pub(crate) package: String, pub(crate) package: String,
pub(crate) from: Option<String>, pub(crate) from: Option<String>,
pub(crate) with: Vec<String>, pub(crate) with: Vec<String>,
pub(crate) with_requirements: Vec<PathBuf>,
pub(crate) python: Option<String>, pub(crate) python: Option<String>,
pub(crate) refresh: Refresh, pub(crate) refresh: Refresh,
pub(crate) settings: ResolverInstallerSettings, pub(crate) settings: ResolverInstallerSettings,
@ -302,6 +309,7 @@ impl ToolInstallSettings {
package, package,
from, from,
with, with,
with_requirements,
installer, installer,
force, force,
build, build,
@ -313,6 +321,10 @@ impl ToolInstallSettings {
package, package,
from, from,
with, with,
with_requirements: with_requirements
.into_iter()
.filter_map(Maybe::into_option)
.collect(),
python, python,
force, force,
refresh: Refresh::from(refresh), refresh: Refresh::from(refresh),

View file

@ -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 upgrading an already installed tool.
#[test] #[test]
fn tool_install_upgrade() { fn tool_install_upgrade() {

View file

@ -619,3 +619,47 @@ fn tool_run_url() {
+ werkzeug==3.0.1 + 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
"###);
}