diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 34c7dccba..ddcad467a 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -3778,6 +3778,28 @@ pub struct ToolInstallArgs { #[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>, + #[command(flatten)] pub installer: ResolverInstallerArgs, diff --git a/crates/uv-tool/src/tool.rs b/crates/uv-tool/src/tool.rs index a557366dd..1b12910d0 100644 --- a/crates/uv-tool/src/tool.rs +++ b/crates/uv-tool/src/tool.rs @@ -16,6 +16,10 @@ use uv_settings::ToolOptions; pub struct Tool { /// The requirements requested by the user during installation. requirements: Vec, + /// The constraints requested by the user during installation. + constraints: Vec, + /// The overrides requested by the user during installation. + overrides: Vec, /// The Python requested by the user during installation. python: Option, /// A mapping of entry point names to their metadata. @@ -26,7 +30,12 @@ pub struct Tool { #[derive(Debug, Clone, Deserialize)] struct ToolWire { + #[serde(default)] requirements: Vec, + #[serde(default)] + constraints: Vec, + #[serde(default)] + overrides: Vec, python: Option, entrypoints: Vec, #[serde(default)] @@ -51,6 +60,8 @@ impl From for ToolWire { .into_iter() .map(RequirementWire::Requirement) .collect(), + constraints: tool.constraints, + overrides: tool.overrides, python: tool.python, entrypoints: tool.entrypoints, options: tool.options, @@ -71,6 +82,8 @@ impl TryFrom for Tool { RequirementWire::Deprecated(requirement) => Requirement::from(requirement), }) .collect(), + constraints: tool.constraints, + overrides: tool.overrides, python: tool.python, entrypoints: tool.entrypoints, options: tool.options, @@ -116,6 +129,8 @@ impl Tool { /// Create a new `Tool`. pub fn new( requirements: Vec, + constraints: Vec, + overrides: Vec, python: Option, entrypoints: impl Iterator, options: ToolOptions, @@ -124,6 +139,8 @@ impl Tool { entrypoints.sort(); Self { requirements, + constraints, + overrides, python, entrypoints, options, @@ -140,25 +157,71 @@ impl Tool { pub(crate) fn to_toml(&self) -> Result { let mut table = Table::new(); - table.insert("requirements", { - let requirements = self - .requirements - .iter() - .map(|requirement| { - serde::Serialize::serialize( - &requirement, - toml_edit::ser::ValueSerializer::new(), - ) - }) - .collect::, _>>()?; + if !self.requirements.is_empty() { + table.insert("requirements", { + let requirements = self + .requirements + .iter() + .map(|requirement| { + serde::Serialize::serialize( + &requirement, + toml_edit::ser::ValueSerializer::new(), + ) + }) + .collect::, _>>()?; - let requirements = match requirements.as_slice() { - [] => Array::new(), - [requirement] => Array::from_iter([requirement]), - requirements => each_element_on_its_line_array(requirements.iter()), - }; - value(requirements) - }); + let requirements = match requirements.as_slice() { + [] => Array::new(), + [requirement] => Array::from_iter([requirement]), + requirements => each_element_on_its_line_array(requirements.iter()), + }; + value(requirements) + }); + } + + if !self.constraints.is_empty() { + table.insert("constraints", { + let constraints = self + .constraints + .iter() + .map(|constraint| { + serde::Serialize::serialize( + &constraint, + toml_edit::ser::ValueSerializer::new(), + ) + }) + .collect::, _>>()?; + + let constraints = match constraints.as_slice() { + [] => Array::new(), + [constraint] => Array::from_iter([constraint]), + constraints => each_element_on_its_line_array(constraints.iter()), + }; + value(constraints) + }); + } + + if !self.overrides.is_empty() { + table.insert("overrides", { + let overrides = self + .overrides + .iter() + .map(|r#override| { + serde::Serialize::serialize( + &r#override, + toml_edit::ser::ValueSerializer::new(), + ) + }) + .collect::, _>>()?; + + let overrides = match overrides.as_slice() { + [] => Array::new(), + [r#override] => Array::from_iter([r#override]), + overrides => each_element_on_its_line_array(overrides.iter()), + }; + value(overrides) + }); + } if let Some(ref python) = self.python { table.insert("python", value(python)); @@ -196,6 +259,14 @@ impl Tool { &self.requirements } + pub fn constraints(&self) -> &[Requirement] { + &self.constraints + } + + pub fn overrides(&self) -> &[Requirement] { + &self.overrides + } + pub fn python(&self) -> &Option { &self.python } diff --git a/crates/uv/src/commands/tool/common.rs b/crates/uv/src/commands/tool/common.rs index 08fae7fa6..3dcadb7bb 100644 --- a/crates/uv/src/commands/tool/common.rs +++ b/crates/uv/src/commands/tool/common.rs @@ -70,6 +70,8 @@ pub(crate) fn install_executables( force: bool, python: Option, requirements: Vec, + constraints: Vec, + overrides: Vec, printer: Printer, ) -> anyhow::Result { let site_packages = SitePackages::from_environment(environment)?; @@ -183,7 +185,9 @@ pub(crate) fn install_executables( debug!("Adding receipt for tool `{name}`"); let tool = Tool::new( - requirements.into_iter().collect(), + requirements, + constraints, + overrides, python, target_entry_points .into_iter() diff --git a/crates/uv/src/commands/tool/install.rs b/crates/uv/src/commands/tool/install.rs index 55eead89d..3d62f5c91 100644 --- a/crates/uv/src/commands/tool/install.rs +++ b/crates/uv/src/commands/tool/install.rs @@ -4,12 +4,13 @@ use std::str::FromStr; use anyhow::{bail, Result}; use owo_colors::OwoColorize; use tracing::{debug, trace}; + use uv_cache::{Cache, Refresh}; use uv_cache_info::Timestamp; use uv_client::{BaseClientBuilder, Connectivity}; use uv_configuration::{Concurrency, Reinstall, TrustedHost, Upgrade}; use uv_dispatch::SharedState; -use uv_distribution_types::UnresolvedRequirementSpecification; +use uv_distribution_types::{NameRequirementSpecification, UnresolvedRequirementSpecification}; use uv_normalize::PackageName; use uv_pep440::{VersionSpecifier, VersionSpecifiers}; use uv_pep508::MarkerTree; @@ -43,6 +44,8 @@ pub(crate) async fn install( editable: bool, from: Option, with: &[RequirementsSource], + constraints: &[RequirementsSource], + overrides: &[RequirementsSource], python: Option, install_mirrors: PythonInstallMirrors, force: bool, @@ -239,7 +242,9 @@ pub(crate) async fn install( }; // Read the `--with` requirements. - let spec = 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 = { @@ -263,6 +268,28 @@ pub(crate) async fn install( requirements }; + // Resolve the constraints. + let constraints = spec + .constraints + .into_iter() + .map(|constraint| constraint.requirement) + .collect::>(); + + // Resolve the overrides. + let overrides = resolve_names( + spec.overrides, + &interpreter, + &settings, + &state, + connectivity, + concurrency, + native_tls, + allow_insecure_host, + &cache, + printer, + ) + .await?; + // Convert to tool options. let options = ToolOptions::from(options); @@ -330,8 +357,10 @@ pub(crate) async fn install( .is_some() { if let Some(tool_receipt) = existing_tool_receipt.as_ref() { - let receipt = tool_receipt.requirements().to_vec(); - if requirements == receipt { + if requirements == tool_receipt.requirements() + && constraints == tool_receipt.constraints() + && overrides == tool_receipt.overrides() + { if *tool_receipt.options() != options { // ...but the options differ, we need to update the receipt. installed_tools @@ -357,6 +386,16 @@ pub(crate) async fn install( .cloned() .map(UnresolvedRequirementSpecification::from) .collect(), + constraints: constraints + .iter() + .cloned() + .map(NameRequirementSpecification::from) + .collect(), + overrides: overrides + .iter() + .cloned() + .map(UnresolvedRequirementSpecification::from) + .collect(), ..spec }; @@ -470,6 +509,8 @@ pub(crate) async fn install( force || invalid_tool_receipt, python, requirements, + constraints, + overrides, printer, ) } diff --git a/crates/uv/src/commands/tool/upgrade.rs b/crates/uv/src/commands/tool/upgrade.rs index 929cb01d9..c6d964ff5 100644 --- a/crates/uv/src/commands/tool/upgrade.rs +++ b/crates/uv/src/commands/tool/upgrade.rs @@ -266,9 +266,16 @@ async fn upgrade_tool( let settings = ResolverInstallerSettings::from(options.clone()); // Resolve the requirements. - let requirements = existing_tool_receipt.requirements(); - let spec = - RequirementsSpecification::from_constraints(requirements.to_vec(), constraints.to_vec()); + let spec = RequirementsSpecification::from_overrides( + existing_tool_receipt.requirements().to_vec(), + existing_tool_receipt + .constraints() + .iter() + .chain(constraints) + .cloned() + .collect(), + existing_tool_receipt.overrides().to_vec(), + ); // Initialize any shared state. let state = SharedState::default(); @@ -362,7 +369,9 @@ async fn upgrade_tool( ToolOptions::from(options), true, existing_tool_receipt.python().to_owned(), - requirements.to_vec(), + existing_tool_receipt.requirements().to_vec(), + existing_tool_receipt.constraints().to_vec(), + existing_tool_receipt.overrides().to_vec(), printer, )?; } diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index 5bff3c4c8..63c485225 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -954,12 +954,24 @@ async fn run(mut cli: Cli) -> Result { .map(RequirementsSource::from_requirements_file), ) .collect::>(); + 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_install( args.package, args.editable, args.from, &requirements, + &constraints, + &overrides, args.python, args.install_mirrors, args.force, diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index 0a9554492..5f0c947e9 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -462,6 +462,8 @@ pub(crate) struct ToolInstallSettings { pub(crate) with: Vec, pub(crate) with_requirements: Vec, pub(crate) with_editable: Vec, + pub(crate) constraints: Vec, + pub(crate) overrides: Vec, pub(crate) python: Option, pub(crate) refresh: Refresh, pub(crate) options: ResolverInstallerOptions, @@ -482,6 +484,8 @@ impl ToolInstallSettings { with, with_editable, with_requirements, + constraints, + overrides, installer, force, build, @@ -519,6 +523,14 @@ impl ToolInstallSettings { .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(), python: python.and_then(Maybe::into_option), force, editable, diff --git a/crates/uv/tests/it/show_settings.rs b/crates/uv/tests/it/show_settings.rs index 82f3b0ad3..a4229e741 100644 --- a/crates/uv/tests/it/show_settings.rs +++ b/crates/uv/tests/it/show_settings.rs @@ -2704,6 +2704,8 @@ fn resolve_tool() -> anyhow::Result<()> { with: [], with_requirements: [], with_editable: [], + constraints: [], + overrides: [], python: None, refresh: None( Timestamp( diff --git a/crates/uv/tests/it/tool_install.rs b/crates/uv/tests/it/tool_install.rs index cae3d2fc0..3323d8641 100644 --- a/crates/uv/tests/it/tool_install.rs +++ b/crates/uv/tests/it/tool_install.rs @@ -172,7 +172,7 @@ fn tool_install() { } #[test] -fn tool_install_with_editable() -> anyhow::Result<()> { +fn tool_install_with_editable() -> Result<()> { let context = TestContext::new("3.12") .with_filtered_counts() .with_filtered_exe_suffix(); @@ -3114,3 +3114,174 @@ fn tool_install_at_latest_upgrade() { "###); }); } + +/// Install a tool with `--constraints`. +#[test] +fn tool_install_constraints() -> Result<()> { + 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 constraints_txt = context.temp_dir.child("constraints.txt"); + constraints_txt.write_str(indoc::indoc! {r" + mypy-extensions<1 + anyio>=3 + "})?; + + // Install `black`. + uv_snapshot!(context.filters(), context.tool_install() + .arg("black") + .arg("--constraints") + .arg(constraints_txt.as_os_str()) + .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()) + .env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()) + .env(EnvVars::PATH, bin_dir.as_os_str()), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved [N] packages in [TIME] + Prepared [N] packages in [TIME] + Installed [N] packages in [TIME] + + black==24.3.0 + + click==8.1.7 + + mypy-extensions==0.4.4 + + 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 = [{ name = "black" }] + constraints = [ + { name = "mypy-extensions", specifier = "<1" }, + { name = "anyio", specifier = ">=3" }, + ] + entrypoints = [ + { name = "black", install-path = "[TEMP_DIR]/bin/black" }, + { name = "blackd", install-path = "[TEMP_DIR]/bin/blackd" }, + ] + + [tool.options] + exclude-newer = "2024-03-25T00:00:00Z" + "###); + }); + + // Installing with the same constraints should be a no-op. + uv_snapshot!(context.filters(), context.tool_install() + .arg("black") + .arg("--constraints") + .arg(constraints_txt.as_os_str()) + .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()) + .env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()) + .env(EnvVars::PATH, bin_dir.as_os_str()), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + `black` is already installed + "###); + + let constraints_txt = context.temp_dir.child("constraints.txt"); + constraints_txt.write_str(indoc::indoc! {r" + platformdirs<4 + "})?; + + // Installing with revised constraints should reinstall the tool. + uv_snapshot!(context.filters(), context.tool_install() + .arg("black") + .arg("--constraints") + .arg(constraints_txt.as_os_str()) + .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()) + .env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()) + .env(EnvVars::PATH, bin_dir.as_os_str()), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved [N] packages in [TIME] + Prepared [N] packages in [TIME] + Uninstalled [N] packages in [TIME] + Installed [N] packages in [TIME] + - platformdirs==4.2.0 + + platformdirs==3.11.0 + Installed 2 executables: black, blackd + "###); + + Ok(()) +} + +/// Install a tool with `--overrides`. +#[test] +fn tool_install_overrides() -> Result<()> { + 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 overrides_txt = context.temp_dir.child("overrides.txt"); + overrides_txt.write_str(indoc::indoc! {r" + click<8 + anyio>=3 + "})?; + + // Install `black`. + uv_snapshot!(context.filters(), context.tool_install() + .arg("black") + .arg("--overrides") + .arg(overrides_txt.as_os_str()) + .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()) + .env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()) + .env(EnvVars::PATH, bin_dir.as_os_str()), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved [N] packages in [TIME] + Prepared [N] packages in [TIME] + Installed [N] packages in [TIME] + + black==24.3.0 + + click==7.1.2 + + 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 = [{ name = "black" }] + overrides = [ + { name = "click", specifier = "<8" }, + { name = "anyio", specifier = ">=3" }, + ] + entrypoints = [ + { name = "black", install-path = "[TEMP_DIR]/bin/black" }, + { name = "blackd", install-path = "[TEMP_DIR]/bin/blackd" }, + ] + + [tool.options] + exclude-newer = "2024-03-25T00:00:00Z" + "###); + }); + + Ok(()) +} diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 04e7a3fb6..1c54fd068 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -3241,6 +3241,13 @@ uv tool install [OPTIONS]

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.

@@ -3388,6 +3395,13 @@ uv tool install [OPTIONS]

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

+
--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).