From b460e51e197f46ef2d772be514de3d7b4a0a25dc Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Mon, 3 Mar 2025 18:18:48 -0800 Subject: [PATCH] Allow `--constraints` and `--overrides` in `uvx` (#10207) ## Summary Closes https://github.com/astral-sh/uv/issues/9813. --- crates/uv-cli/src/lib.rs | 22 ++++++ crates/uv/src/commands/tool/run.rs | 112 ++++++++++++++++++++++------- crates/uv/src/lib.rs | 48 ++++++++----- crates/uv/src/settings.rs | 35 +++++++-- crates/uv/tests/it/tool_run.rs | 64 +++++++++++++++++ docs/reference/cli.md | 14 ++++ 6 files changed, 245 insertions(+), 50 deletions(-) diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 1c15d79eb..f3fac3907 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -3956,6 +3956,28 @@ pub struct ToolRunArgs { #[arg(long, value_delimiter = ',', value_parser = parse_maybe_file_path)] pub with_requirements: Vec>, + /// Constrain versions using the given requirements files. + /// + /// Constraints files are `requirements.txt`-like files that only control the _version_ of a + /// requirement that's installed. However, including a package in a constraints file will _not_ + /// trigger the installation of that package. + /// + /// This is equivalent to pip's `--constraint` option. + #[arg(long, short, alias = "constraint", env = EnvVars::UV_CONSTRAINT, value_delimiter = ' ', value_parser = parse_maybe_file_path)] + pub constraints: Vec>, + + /// Override versions using the given requirements files. + /// + /// Overrides files are `requirements.txt`-like files that force a specific version of a + /// requirement to be installed, regardless of the requirements declared by any constituent + /// package, and regardless of whether this would be considered an invalid resolution. + /// + /// While constraints are _additive_, in that they're combined with the requirements of the + /// constituent packages, overrides are _absolute_, in that they completely replace the + /// requirements of the constituent packages. + #[arg(long, alias = "override", env = EnvVars::UV_OVERRIDE, value_delimiter = ' ', value_parser = parse_maybe_file_path)] + pub overrides: Vec>, + /// Run the tool in an isolated virtual environment, ignoring any already-installed tools. #[arg(long)] pub isolated: bool, diff --git a/crates/uv/src/commands/tool/run.rs b/crates/uv/src/commands/tool/run.rs index e14ac1a9f..e1af04dd3 100644 --- a/crates/uv/src/commands/tool/run.rs +++ b/crates/uv/src/commands/tool/run.rs @@ -15,7 +15,9 @@ use uv_cache_info::Timestamp; use uv_cli::ExternalCommand; use uv_client::BaseClientBuilder; use uv_configuration::{Concurrency, PreviewMode}; -use uv_distribution_types::{Name, UnresolvedRequirement, UnresolvedRequirementSpecification}; +use uv_distribution_types::{ + Name, NameRequirementSpecification, UnresolvedRequirement, UnresolvedRequirementSpecification, +}; use uv_installer::{SatisfiesResult, SitePackages}; use uv_normalize::PackageName; use uv_pep440::{VersionSpecifier, VersionSpecifiers}; @@ -27,7 +29,7 @@ use uv_python::{ PythonPreference, PythonRequest, }; use uv_requirements::{RequirementsSource, RequirementsSpecification}; -use uv_settings::PythonInstallMirrors; +use uv_settings::{PythonInstallMirrors, ResolverInstallerOptions, ToolOptions}; use uv_static::EnvVars; use uv_tool::{entrypoint_paths, InstalledTools}; use uv_warnings::warn_user; @@ -72,9 +74,12 @@ pub(crate) async fn run( command: Option, from: Option, with: &[RequirementsSource], + constraints: &[RequirementsSource], + overrides: &[RequirementsSource], show_resolution: bool, python: Option, install_mirrors: PythonInstallMirrors, + options: ResolverInstallerOptions, settings: ResolverInstallerSettings, network_settings: NetworkSettings, invocation_source: ToolRunCommand, @@ -116,9 +121,12 @@ pub(crate) async fn run( let result = get_or_create_environment( &request, with, + constraints, + overrides, show_resolution, python.as_deref(), install_mirrors, + options, &settings, &network_settings, isolated, @@ -446,9 +454,12 @@ impl std::fmt::Display for ToolRequirement { async fn get_or_create_environment( request: &ToolRequest<'_>, with: &[RequirementsSource], + constraints: &[RequirementsSource], + overrides: &[RequirementsSource], show_resolution: bool, python: Option<&str>, install_mirrors: PythonInstallMirrors, + options: ResolverInstallerOptions, settings: &ResolverInstallerSettings, network_settings: &NetworkSettings, isolated: bool, @@ -631,13 +642,9 @@ async fn get_or_create_environment( }; // Read the `--with` requirements. - let spec = { - let client_builder = BaseClientBuilder::new() - .connectivity(network_settings.connectivity) - .native_tls(network_settings.native_tls) - .allow_insecure_host(network_settings.allow_insecure_host.clone()); - RequirementsSpecification::from_simple_sources(with, &client_builder).await? - }; + let spec = + RequirementsSpecification::from_sources(with, constraints, overrides, &client_builder) + .await?; // Resolve the `--from` and `--with` requirements. let requirements = { @@ -663,6 +670,28 @@ async fn get_or_create_environment( requirements }; + // Resolve the constraints. + let constraints = spec + .constraints + .clone() + .into_iter() + .map(|constraint| constraint.requirement) + .collect::>(); + + // Resolve the overrides. + let overrides = resolve_names( + spec.overrides.clone(), + &interpreter, + settings, + network_settings, + &state, + concurrency, + cache, + printer, + preview, + ) + .await?; + // Check if the tool is already installed in a compatible environment. if !isolated && !request.is_latest() { let installed_tools = InstalledTools::from_settings()?.init()?; @@ -676,27 +705,48 @@ async fn get_or_create_environment( python_request.satisfied(environment.interpreter(), cache) }) }); + + // Check if the installed packages meet the requirements. if let Some(environment) = existing_environment { - // Check if the installed packages meet the requirements. - let site_packages = SitePackages::from_environment(&environment)?; + if let Some(tool_receipt) = installed_tools + .get_tool_receipt(&requirement.name) + .ok() + .flatten() + .filter(|receipt| ToolOptions::from(options) == *receipt.options()) + { + if overrides.is_empty() { + // Check if the installed packages meet the requirements. + let site_packages = SitePackages::from_environment(&environment)?; - let requirements = requirements - .iter() - .cloned() - .map(UnresolvedRequirementSpecification::from) - .collect::>(); - let constraints = []; + let requirements = requirements + .iter() + .cloned() + .map(UnresolvedRequirementSpecification::from) + .collect::>(); + let constraints = []; - if matches!( - site_packages.satisfies( - &requirements, - &constraints, - &interpreter.resolver_marker_environment() - ), - Ok(SatisfiesResult::Fresh { .. }) - ) { - debug!("Using existing tool `{}`", requirement.name); - return Ok((from, environment)); + if matches!( + site_packages.satisfies( + &requirements, + &constraints, + &interpreter.resolver_marker_environment() + ), + Ok(SatisfiesResult::Fresh { .. }) + ) { + debug!("Using existing tool `{}`", requirement.name); + return Ok((from, environment)); + } + } else { + // Check if the installed packages match the requirements. + // TODO(charlie): Support overrides in `SitePackages::satisfies`. + if requirements == tool_receipt.requirements() + && constraints == tool_receipt.constraints() + && overrides == tool_receipt.overrides() + { + debug!("Using existing tool `{}`", requirement.name); + return Ok((from, environment)); + } + } } } } @@ -708,6 +758,14 @@ async fn get_or_create_environment( .into_iter() .map(UnresolvedRequirementSpecification::from) .collect(), + constraints: constraints + .into_iter() + .map(NameRequirementSpecification::from) + .collect(), + overrides: overrides + .into_iter() + .map(UnresolvedRequirementSpecification::from) + .collect(), ..spec }); diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index fc55e2539..363136f51 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -968,30 +968,46 @@ async fn run(mut cli: Cli) -> Result { .combine(Refresh::from(args.settings.upgrade.clone())), ); - let mut requirements = Vec::with_capacity( - args.with.len() + args.with_editable.len() + args.with_requirements.len(), - ); - for package in args.with { - requirements.push(RequirementsSource::from_with_package(package)?); - } - requirements.extend( - args.with_editable - .into_iter() - .map(RequirementsSource::Editable), - ); - requirements.extend( - args.with_requirements - .into_iter() - .map(RequirementsSource::from_requirements_file), - ); + let requirements = { + let mut requirements = Vec::with_capacity( + args.with.len() + args.with_editable.len() + args.with_requirements.len(), + ); + for package in args.with { + requirements.push(RequirementsSource::from_with_package(package)?); + } + requirements.extend( + args.with_editable + .into_iter() + .map(RequirementsSource::Editable), + ); + requirements.extend( + args.with_requirements + .into_iter() + .map(RequirementsSource::from_requirements_file), + ); + requirements + }; + let constraints = args + .constraints + .into_iter() + .map(RequirementsSource::from_constraints_txt) + .collect::>(); + let overrides = args + .overrides + .into_iter() + .map(RequirementsSource::from_overrides_txt) + .collect::>(); Box::pin(commands::tool_run( args.command, args.from, &requirements, + &constraints, + &overrides, args.show_resolution || globals.verbose > 0, args.python, args.install_mirrors, + args.options, args.settings, globals.network_settings, invocation_source, diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index 09441ef83..ae9c0e5f9 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -441,13 +441,16 @@ pub(crate) struct ToolRunSettings { pub(crate) command: Option, pub(crate) from: Option, pub(crate) with: Vec, - pub(crate) with_editable: Vec, pub(crate) with_requirements: Vec, + pub(crate) with_editable: Vec, + pub(crate) constraints: Vec, + pub(crate) overrides: Vec, pub(crate) isolated: bool, pub(crate) show_resolution: bool, pub(crate) python: Option, pub(crate) install_mirrors: PythonInstallMirrors, pub(crate) refresh: Refresh, + pub(crate) options: ResolverInstallerOptions, pub(crate) settings: ResolverInstallerSettings, } @@ -465,6 +468,8 @@ impl ToolRunSettings { with, with_editable, with_requirements, + constraints, + overrides, isolated, show_resolution, installer, @@ -492,11 +497,21 @@ impl ToolRunSettings { } } + let options = resolver_installer_options(installer, build).combine( + filesystem + .clone() + .map(FilesystemOptions::into_options) + .map(|options| options.top_level) + .unwrap_or_default(), + ); + let install_mirrors = filesystem - .clone() - .map(|fs| fs.install_mirrors.clone()) + .map(FilesystemOptions::into_options) + .map(|options| options.install_mirrors) .unwrap_or_default(); + let settings = ResolverInstallerSettings::from(options.clone()); + Self { command, from, @@ -512,14 +527,20 @@ impl ToolRunSettings { .into_iter() .filter_map(Maybe::into_option) .collect(), + constraints: constraints + .into_iter() + .filter_map(Maybe::into_option) + .collect(), + overrides: overrides + .into_iter() + .filter_map(Maybe::into_option) + .collect(), isolated, show_resolution, python: python.and_then(Maybe::into_option), refresh: Refresh::from(refresh), - settings: ResolverInstallerSettings::combine( - resolver_installer_options(installer, build), - filesystem, - ), + settings, + options, install_mirrors, } } diff --git a/crates/uv/tests/it/tool_run.rs b/crates/uv/tests/it/tool_run.rs index 9ca9146e3..919393c39 100644 --- a/crates/uv/tests/it/tool_run.rs +++ b/crates/uv/tests/it/tool_run.rs @@ -189,6 +189,70 @@ fn tool_run_from_version() { "###); } +#[test] +fn tool_run_constraints() { + let context = TestContext::new("3.12"); + let tool_dir = context.temp_dir.child("tools"); + let bin_dir = context.temp_dir.child("bin"); + + let constraints_txt = context.temp_dir.child("constraints.txt"); + constraints_txt.write_str("pluggy<1.4.0").unwrap(); + + uv_snapshot!(context.filters(), context.tool_run() + .arg("--constraints") + .arg("constraints.txt") + .arg("pytest") + .arg("--version") + .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()) + .env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()), @r###" + success: true + exit_code: 0 + ----- stdout ----- + pytest 8.0.2 + + ----- stderr ----- + Resolved 4 packages in [TIME] + Prepared 4 packages in [TIME] + Installed 4 packages in [TIME] + + iniconfig==2.0.0 + + packaging==24.0 + + pluggy==1.3.0 + + pytest==8.0.2 + "###); +} + +#[test] +fn tool_run_overrides() { + let context = TestContext::new("3.12"); + let tool_dir = context.temp_dir.child("tools"); + let bin_dir = context.temp_dir.child("bin"); + + let overrides_txt = context.temp_dir.child("overrides.txt"); + overrides_txt.write_str("pluggy<1.4.0").unwrap(); + + uv_snapshot!(context.filters(), context.tool_run() + .arg("--overrides") + .arg("overrides.txt") + .arg("pytest") + .arg("--version") + .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()) + .env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()), @r###" + success: true + exit_code: 0 + ----- stdout ----- + pytest 8.1.1 + + ----- stderr ----- + Resolved 4 packages in [TIME] + Prepared 4 packages in [TIME] + Installed 4 packages in [TIME] + + iniconfig==2.0.0 + + packaging==24.0 + + pluggy==1.3.0 + + pytest==8.1.1 + "###); +} + #[test] fn tool_run_suggest_valid_commands() { let context = TestContext::new("3.12").with_filtered_exe_suffix(); diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 4441873f9..8b4206656 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -3150,6 +3150,13 @@ uv tool run [OPTIONS] [COMMAND]

May also be set with the UV_CONFIG_FILE environment variable.

--config-setting, -C config-setting

Settings to pass to the PEP 517 build backend, specified as KEY=VALUE pairs

+
--constraints, -c constraints

Constrain versions using the given requirements files.

+ +

Constraints files are requirements.txt-like files that only control the version of a requirement that’s installed. However, including a package in a constraints file will not trigger the installation of that package.

+ +

This is equivalent to pip’s --constraint option.

+ +

May also be set with the UV_CONSTRAINT environment variable.

--default-index default-index

The URL of the default package index (by default: <https://pypi.org/simple>).

Accepts either a repository compliant with PEP 503 (the simple repository API), or a local directory laid out in the same format.

@@ -3316,6 +3323,13 @@ uv tool run [OPTIONS] [COMMAND]

When disabled, uv will only use locally cached data and locally available files.

May also be set with the UV_OFFLINE environment variable.

+
--overrides overrides

Override versions using the given requirements files.

+ +

Overrides files are requirements.txt-like files that force a specific version of a requirement to be installed, regardless of the requirements declared by any constituent package, and regardless of whether this would be considered an invalid resolution.

+ +

While constraints are additive, in that they’re combined with the requirements of the constituent packages, overrides are absolute, in that they completely replace the requirements of the constituent packages.

+ +

May also be set with the UV_OVERRIDE environment variable.

--prerelease prerelease

The strategy to use when considering pre-release versions.

By default, uv will accept pre-releases for packages that only publish pre-releases, along with first-party requirements that contain an explicit pre-release marker in the declared specifiers (if-necessary-or-explicit).