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)]
pub from: Option<String>,
/// Include the following extra requirements.
/// Run with the given packages installed.
#[arg(long)]
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)]
pub installer: ResolverInstallerArgs,
@ -2252,6 +2256,10 @@ pub struct ToolInstallArgs {
#[arg(long)]
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)]
pub installer: ResolverInstallerArgs,

View file

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

View file

@ -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<Item = &str>,
requirements: &[RequirementsSource],
interpreter: &Interpreter,
settings: &ResolverInstallerSettings,
state: &SharedState,
@ -25,18 +25,17 @@ pub(super) async fn resolve_requirements(
cache: &Cache,
printer: Printer,
) -> anyhow::Result<Vec<Requirement>> {
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,

View file

@ -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<String>,
with: &[RequirementsSource],
python: Option<String>,
with: Vec<String>,
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,

View file

@ -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<String>,
with: Vec<String>,
with: &[RequirementsSource],
python: Option<String>,
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,

View file

@ -618,10 +618,22 @@ async fn run(cli: Cli) -> Result<ExitStatus> {
// 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::<Vec<_>>();
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<ExitStatus> {
// 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::<Vec<_>>();
commands::tool_install(
args.package,
args.from,
&requirements,
args.python,
args.with,
args.force,
args.settings,
globals.preview,

View file

@ -248,6 +248,7 @@ pub(crate) struct ToolRunSettings {
pub(crate) command: ExternalCommand,
pub(crate) from: Option<String>,
pub(crate) with: Vec<String>,
pub(crate) with_requirements: Vec<PathBuf>,
pub(crate) python: Option<String>,
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<String>,
pub(crate) with: Vec<String>,
pub(crate) with_requirements: Vec<PathBuf>,
pub(crate) python: Option<String>,
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),

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]
fn tool_install_upgrade() {

View file

@ -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
"###);
}