diff --git a/crates/uv-settings/src/lib.rs b/crates/uv-settings/src/lib.rs index 3fe41766a..a8937f0da 100644 --- a/crates/uv-settings/src/lib.rs +++ b/crates/uv-settings/src/lib.rs @@ -1,3 +1,4 @@ +use std::num::NonZeroUsize; use std::ops::Deref; use std::path::{Path, PathBuf}; use std::time::Duration; @@ -565,6 +566,13 @@ pub enum Error { }, } +#[derive(Copy, Clone, Debug)] +pub struct Concurrency { + pub downloads: Option, + pub builds: Option, + pub installs: Option, +} + /// Options loaded from environment variables. /// /// This is currently a subset of all respected environment variables, most are parsed via Clap at @@ -578,6 +586,7 @@ pub struct EnvironmentOptions { pub log_context: Option, pub http_timeout: Duration, pub upload_http_timeout: Duration, + pub concurrency: Concurrency, #[cfg(feature = "tracing-durations-export")] pub tracing_durations_file: Option, } @@ -587,11 +596,9 @@ impl EnvironmentOptions { pub fn new() -> Result { // Timeout options, matching https://doc.rust-lang.org/nightly/cargo/reference/config.html#httptimeout // `UV_REQUEST_TIMEOUT` is provided for backwards compatibility with v0.1.6 - let http_timeout = parse_integer_environment_variable(EnvVars::UV_HTTP_TIMEOUT)? - .or(parse_integer_environment_variable( - EnvVars::UV_REQUEST_TIMEOUT, - )?) - .or(parse_integer_environment_variable(EnvVars::HTTP_TIMEOUT)?) + let http_timeout = parse_u64_environment_variable(EnvVars::UV_HTTP_TIMEOUT)? + .or(parse_u64_environment_variable(EnvVars::UV_REQUEST_TIMEOUT)?) + .or(parse_u64_environment_variable(EnvVars::HTTP_TIMEOUT)?) .map(Duration::from_secs); Ok(Self { @@ -602,6 +609,15 @@ impl EnvironmentOptions { python_install_registry: parse_boolish_environment_variable( EnvVars::UV_PYTHON_INSTALL_REGISTRY, )?, + concurrency: Concurrency { + downloads: parse_non_zero_usize_environment_variable( + EnvVars::UV_CONCURRENT_DOWNLOADS, + )?, + builds: parse_non_zero_usize_environment_variable(EnvVars::UV_CONCURRENT_BUILDS)?, + installs: parse_non_zero_usize_environment_variable( + EnvVars::UV_CONCURRENT_INSTALLS, + )?, + }, install_mirrors: PythonInstallMirrors { python_install_mirror: parse_string_environment_variable( EnvVars::UV_PYTHON_INSTALL_MIRROR, @@ -614,12 +630,10 @@ impl EnvironmentOptions { )?, }, log_context: parse_boolish_environment_variable(EnvVars::UV_LOG_CONTEXT)?, - upload_http_timeout: parse_integer_environment_variable( - EnvVars::UV_UPLOAD_HTTP_TIMEOUT, - )? - .map(Duration::from_secs) - .or(http_timeout) - .unwrap_or(Duration::from_secs(15 * 60)), + upload_http_timeout: parse_u64_environment_variable(EnvVars::UV_UPLOAD_HTTP_TIMEOUT)? + .map(Duration::from_secs) + .or(http_timeout) + .unwrap_or(Duration::from_secs(15 * 60)), http_timeout: http_timeout.unwrap_or(Duration::from_secs(30)), #[cfg(feature = "tracing-durations-export")] tracing_durations_file: parse_path_environment_variable( @@ -702,8 +716,14 @@ fn parse_string_environment_variable(name: &'static str) -> Result Result, Error> { +fn parse_integer_environment_variable( + name: &'static str, + err_msg: &'static str, +) -> Result, Error> +where + T: std::str::FromStr + Copy, + ::Err: std::fmt::Display, +{ let value = match std::env::var(name) { Ok(v) => v, Err(e) => { @@ -712,7 +732,7 @@ fn parse_integer_environment_variable(name: &'static str) -> Result, std::env::VarError::NotUnicode(err) => Err(Error::InvalidEnvironmentVariable { name: name.to_string(), value: err.to_string_lossy().to_string(), - err: "expected an integer".to_string(), + err: err_msg.to_string(), }), }; } @@ -720,16 +740,29 @@ fn parse_integer_environment_variable(name: &'static str) -> Result, if value.is_empty() { return Ok(None); } - match value.parse::() { + + match value.parse::() { Ok(v) => Ok(Some(v)), Err(_) => Err(Error::InvalidEnvironmentVariable { name: name.to_string(), value, - err: "expected an integer".to_string(), + err: err_msg.to_string(), }), } } +/// Parse a integer environment variable. +fn parse_u64_environment_variable(name: &'static str) -> Result, Error> { + parse_integer_environment_variable(name, "expected an integer") +} + +/// Parse a non-zero usize environment variable. +fn parse_non_zero_usize_environment_variable( + name: &'static str, +) -> Result, Error> { + parse_integer_environment_variable(name, "expected a non-zero positive integer") +} + #[cfg(feature = "tracing-durations-export")] /// Parse a path environment variable. fn parse_path_environment_variable(name: &'static str) -> Option { diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index e3f255e3b..e8d3470fa 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -117,15 +117,21 @@ impl GlobalSettings { }, network_settings, concurrency: Concurrency { - downloads: env(env::CONCURRENT_DOWNLOADS) + downloads: environment + .concurrency + .downloads .combine(workspace.and_then(|workspace| workspace.globals.concurrent_downloads)) .map(NonZeroUsize::get) .unwrap_or(Concurrency::DEFAULT_DOWNLOADS), - builds: env(env::CONCURRENT_BUILDS) + builds: environment + .concurrency + .builds .combine(workspace.and_then(|workspace| workspace.globals.concurrent_builds)) .map(NonZeroUsize::get) .unwrap_or_else(Concurrency::threads), - installs: env(env::CONCURRENT_INSTALLS) + installs: environment + .concurrency + .installs .combine(workspace.and_then(|workspace| workspace.globals.concurrent_installs)) .map(NonZeroUsize::get) .unwrap_or_else(Concurrency::threads), @@ -3747,16 +3753,6 @@ impl AuthLoginSettings { // Environment variables that are not exposed as CLI arguments. mod env { use uv_static::EnvVars; - - pub(super) const CONCURRENT_DOWNLOADS: (&str, &str) = - (EnvVars::UV_CONCURRENT_DOWNLOADS, "a non-zero integer"); - - pub(super) const CONCURRENT_BUILDS: (&str, &str) = - (EnvVars::UV_CONCURRENT_BUILDS, "a non-zero integer"); - - pub(super) const CONCURRENT_INSTALLS: (&str, &str) = - (EnvVars::UV_CONCURRENT_INSTALLS, "a non-zero integer"); - pub(super) const UV_PYTHON_DOWNLOADS: (&str, &str) = ( EnvVars::UV_PYTHON_DOWNLOADS, "one of 'auto', 'true', 'manual', 'never', or 'false'", diff --git a/crates/uv/tests/it/common/mod.rs b/crates/uv/tests/it/common/mod.rs index 85e89448b..01b805e5b 100644 --- a/crates/uv/tests/it/common/mod.rs +++ b/crates/uv/tests/it/common/mod.rs @@ -135,6 +135,15 @@ impl TestContext { self } + /// Set the "concurrent installs" for all commands in this context. + pub fn with_concurrent_installs(mut self, concurrent_installs: &str) -> Self { + self.extra_env.push(( + EnvVars::UV_CONCURRENT_INSTALLS.into(), + concurrent_installs.into(), + )); + self + } + /// Add extra standard filtering for messages like "Resolved 10 packages" which /// can differ between platforms. /// diff --git a/crates/uv/tests/it/venv.rs b/crates/uv/tests/it/venv.rs index 8244f3a45..55c5b4752 100644 --- a/crates/uv/tests/it/venv.rs +++ b/crates/uv/tests/it/venv.rs @@ -900,6 +900,24 @@ fn create_venv_with_invalid_http_timeout() { "###); } +#[test] +fn create_venv_with_invalid_concurrent_installs() { + let context = TestContext::new_with_versions(&["3.12"]).with_concurrent_installs("0"); + + // Create a virtual environment at `.venv`. + uv_snapshot!(context.filters(), context.venv() + .arg(context.venv.as_os_str()) + .arg("--python") + .arg("3.12"), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Failed to parse environment variable `UV_CONCURRENT_INSTALLS` with invalid value `0`: expected a non-zero positive integer + "###); +} + #[test] fn create_venv_unknown_python_minor() { let context = TestContext::new_with_versions(&["3.12"]).with_filtered_python_sources();