diff --git a/Cargo.lock b/Cargo.lock index 5cf3520ec..19f118e65 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5845,12 +5845,15 @@ dependencies = [ "clap", "either", "fs-err", + "insta", "rayon", + "reqwest", "rustc-hash", "same-file", "schemars", "serde", "serde-untagged", + "serde_json", "thiserror 2.0.17", "tracing", "url", diff --git a/crates/uv-client/src/base_client.rs b/crates/uv-client/src/base_client.rs index 00ab7d30e..029e99ccc 100644 --- a/crates/uv-client/src/base_client.rs +++ b/crates/uv-client/src/base_client.rs @@ -16,7 +16,7 @@ use http::{ }, }; use itertools::Itertools; -use reqwest::{Client, ClientBuilder, IntoUrl, Proxy, Request, Response, multipart}; +use reqwest::{Client, ClientBuilder, IntoUrl, NoProxy, Proxy, Request, Response, multipart}; use reqwest_middleware::{ClientWithMiddleware, Middleware}; use reqwest_retry::policies::ExponentialBackoff; use reqwest_retry::{ @@ -29,7 +29,8 @@ use url::ParseError; use url::Url; use uv_auth::{AuthMiddleware, Credentials, CredentialsCache, Indexes, PyxTokenStore}; -use uv_configuration::{KeyringProviderType, TrustedHost}; +use uv_configuration::ProxyUrlKind; +use uv_configuration::{KeyringProviderType, ProxyUrl, TrustedHost}; use uv_fs::Simplified; use uv_pep508::MarkerEnvironment; use uv_platform_tags::Platform; @@ -84,6 +85,9 @@ pub struct BaseClientBuilder<'a> { timeout: Duration, extra_middleware: Option, proxies: Vec, + http_proxy: Option, + https_proxy: Option, + no_proxy: Option>, redirect_policy: RedirectPolicy, /// Whether credentials should be propagated during cross-origin redirects. /// @@ -148,6 +152,9 @@ impl Default for BaseClientBuilder<'_> { timeout: Duration::from_secs(30), extra_middleware: None, proxies: vec![], + http_proxy: None, + https_proxy: None, + no_proxy: None, redirect_policy: RedirectPolicy::default(), cross_origin_credential_policy: CrossOriginCredentialsPolicy::Secure, custom_client: None, @@ -265,6 +272,24 @@ impl<'a> BaseClientBuilder<'a> { self } + #[must_use] + pub fn http_proxy(mut self, http_proxy: Option) -> Self { + self.http_proxy = http_proxy; + self + } + + #[must_use] + pub fn https_proxy(mut self, https_proxy: Option) -> Self { + self.https_proxy = https_proxy; + self + } + + #[must_use] + pub fn no_proxy(mut self, no_proxy: Option>) -> Self { + self.no_proxy = no_proxy; + self + } + #[must_use] pub fn redirect(mut self, policy: RedirectPolicy) -> Self { self.redirect_policy = policy; @@ -526,6 +551,24 @@ impl<'a> BaseClientBuilder<'a> { for p in &self.proxies { client_builder = client_builder.proxy(p.clone()); } + + let no_proxy = self + .no_proxy + .as_ref() + .and_then(|no_proxy| NoProxy::from_string(&no_proxy.join(","))); + + if let Some(http_proxy) = &self.http_proxy { + let proxy = http_proxy + .as_proxy(ProxyUrlKind::Http) + .no_proxy(no_proxy.clone()); + client_builder = client_builder.proxy(proxy); + } + + if let Some(https_proxy) = &self.https_proxy { + let proxy = https_proxy.as_proxy(ProxyUrlKind::Https).no_proxy(no_proxy); + client_builder = client_builder.proxy(proxy); + } + let client_builder = client_builder; client_builder diff --git a/crates/uv-client/tests/it/main.rs b/crates/uv-client/tests/it/main.rs index 13674f930..18b5319bb 100644 --- a/crates/uv-client/tests/it/main.rs +++ b/crates/uv-client/tests/it/main.rs @@ -1,4 +1,5 @@ mod http_util; +mod proxy; mod remote_metadata; mod ssl_certs; mod user_agent_version; diff --git a/crates/uv-client/tests/it/proxy.rs b/crates/uv-client/tests/it/proxy.rs new file mode 100644 index 000000000..95890e4e9 --- /dev/null +++ b/crates/uv-client/tests/it/proxy.rs @@ -0,0 +1,100 @@ +//! An integration test for proxy support in `uv-client`. + +use anyhow::Result; +use wiremock::matchers::{any, method}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +use uv_client::BaseClientBuilder; +use uv_configuration::ProxyUrl; + +#[tokio::test] +async fn http_proxy() -> Result<()> { + // Start a mock server to act as the target. + let target_server = MockServer::start().await; + Mock::given(method("GET")) + .respond_with(ResponseTemplate::new(200)) + .mount(&target_server) + .await; + + // Start a mock server to act as the proxy. + let proxy_server = MockServer::start().await; + Mock::given(any()) + .respond_with(ResponseTemplate::new(200)) + .mount(&proxy_server) + .await; + + // Create a client with the proxy. + let client = BaseClientBuilder::new( + uv_client::Connectivity::Online, + false, + vec![], + uv_preview::Preview::default(), + std::time::Duration::from_secs(30), + 3, + ) + .http_proxy(Some(proxy_server.uri().parse::()?)) + .build(); + + // Make a request to the target. + let response = client + .for_host(&target_server.uri().parse()?) + .get(target_server.uri()) + .send() + .await?; + + assert_eq!(response.status(), 200); + + // Assert that the proxy was called. + let received_requests = proxy_server.received_requests().await.unwrap(); + assert_eq!(received_requests.len(), 1); + + Ok(()) +} + +#[tokio::test] +async fn no_proxy() -> Result<()> { + // Start a mock server to act as the target. + let target_server = MockServer::start().await; + Mock::given(method("GET")) + .respond_with(ResponseTemplate::new(200)) + .mount(&target_server) + .await; + + // Start a mock server to act as the proxy. + let proxy_server = MockServer::start().await; + Mock::given(any()) + .respond_with(ResponseTemplate::new(200)) + .mount(&proxy_server) + .await; + + // The host of the target server should be excluded from proxying. + let target_host = target_server.address().ip().to_string(); + + // Create a client with the proxy. + let client = BaseClientBuilder::new( + uv_client::Connectivity::Online, + false, + vec![], + uv_preview::Preview::default(), + std::time::Duration::from_secs(30), + 3, + ) + .http_proxy(Some(proxy_server.uri().parse::()?)) + .no_proxy(Some(vec![target_host])) + .build(); + + // Make a request to the target. + let response = client + .for_host(&target_server.uri().parse()?) + .get(target_server.uri()) + .send() + .await?; + + assert_eq!(response.status(), 200); + + // Assert that the proxy was NOT called. + let received_requests = proxy_server.received_requests().await.unwrap(); + assert_eq!(received_requests.len(), 0); + + Ok(()) +} diff --git a/crates/uv-configuration/Cargo.toml b/crates/uv-configuration/Cargo.toml index b3d562a67..a378a70e2 100644 --- a/crates/uv-configuration/Cargo.toml +++ b/crates/uv-configuration/Cargo.toml @@ -30,6 +30,7 @@ clap = { workspace = true, features = ["derive"], optional = true } either = { workspace = true } fs-err = { workspace = true } rayon = { workspace = true } +reqwest = { workspace = true } rustc-hash = { workspace = true } same-file = { workspace = true } schemars = { workspace = true, optional = true } @@ -41,6 +42,8 @@ url = { workspace = true } [dev-dependencies] anyhow = { workspace = true } +insta = { workspace = true } +serde_json = { workspace = true } [features] default = [] diff --git a/crates/uv-configuration/src/lib.rs b/crates/uv-configuration/src/lib.rs index 931634812..c7952da23 100644 --- a/crates/uv-configuration/src/lib.rs +++ b/crates/uv-configuration/src/lib.rs @@ -15,6 +15,7 @@ pub use name_specifiers::*; pub use overrides::*; pub use package_options::*; pub use project_build_backend::*; +pub use proxy_url::*; pub use required_version::*; pub use sources::*; pub use target_triple::*; @@ -40,6 +41,7 @@ mod name_specifiers; mod overrides; mod package_options; mod project_build_backend; +mod proxy_url; mod required_version; mod sources; mod target_triple; diff --git a/crates/uv-configuration/src/proxy_url.rs b/crates/uv-configuration/src/proxy_url.rs new file mode 100644 index 000000000..93a4ea610 --- /dev/null +++ b/crates/uv-configuration/src/proxy_url.rs @@ -0,0 +1,237 @@ +#[cfg(feature = "schemars")] +use std::borrow::Cow; +use std::fmt::{self, Display, Formatter}; +use std::str::FromStr; + +use reqwest::Proxy; +use serde::{Deserialize, Deserializer, Serialize}; +use url::Url; + +/// A validated proxy URL. +/// +/// This type validates that the [`Url`] is valid for a [`reqwest::Proxy`] on construction. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct ProxyUrl(Url); + +/// Mapping to [`reqwest::proxy::Intercept`] kinds which are not public API. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ProxyUrlKind { + Http, + Https, +} + +impl ProxyUrl { + /// Returns a reference to the underlying [`Url`]. + fn as_url(&self) -> &Url { + &self.0 + } + + /// Constructs a [`reqwest::Proxy`] from this [`ProxyUrl`] for the given [`ProxyUrlKind`]. + pub fn as_proxy(&self, kind: ProxyUrlKind) -> Proxy { + // SAFETY: Constructing a [`Proxy`] from a [`Url`] is infallible. + match kind { + ProxyUrlKind::Http => Proxy::http(self.0.as_str()) + .expect("Constructing a proxy from a url should never fail"), + ProxyUrlKind::Https => Proxy::https(self.0.as_str()) + .expect("Constructing a proxy from a url should never fail"), + } + } +} + +#[derive(Debug, thiserror::Error)] +pub enum ProxyUrlError { + #[error("invalid proxy URL: {0}")] + InvalidUrl(#[from] url::ParseError), + #[error( + "invalid proxy URL scheme `{scheme}` in `{url}`: expected http, https, socks5, or socks5h" + )] + InvalidScheme { scheme: String, url: Url }, +} + +/// Returns true if the input likely has no scheme (no "://" present). +fn lacks_scheme(s: &str) -> bool { + !s.contains("://") +} + +impl FromStr for ProxyUrl { + type Err = ProxyUrlError; + + /// Parses a proxy URL from a string, assuming `http://` if no scheme is present. + /// + /// This matches reqwest's and curl's behavior. + fn from_str(s: &str) -> Result { + fn try_with_http_scheme(s: &str) -> Result { + let with_scheme = format!("http://{s}"); + let url = Url::parse(&with_scheme)?; + ProxyUrl::try_from(url) + } + + match Url::parse(s) { + Ok(url) => match Self::try_from(url) { + Ok(proxy) => Ok(proxy), + Err(ProxyUrlError::InvalidScheme { .. }) if lacks_scheme(s) => { + try_with_http_scheme(s) + } + Err(e) => Err(e), + }, + Err(url::ParseError::RelativeUrlWithoutBase) => try_with_http_scheme(s), + Err(err) => Err(ProxyUrlError::InvalidUrl(err)), + } + } +} + +impl TryFrom for ProxyUrl { + type Error = ProxyUrlError; + + fn try_from(url: Url) -> Result { + match url.scheme() { + "http" | "https" | "socks5" | "socks5h" => Ok(Self(url)), + scheme => Err(ProxyUrlError::InvalidScheme { + scheme: scheme.to_string(), + url, + }), + } + } +} + +impl Display for ProxyUrl { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + Display::fmt(&self.0, f) + } +} + +impl<'de> Deserialize<'de> for ProxyUrl { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + Self::from_str(&s).map_err(serde::de::Error::custom) + } +} + +impl Serialize for ProxyUrl { + fn serialize(&self, serializer: S) -> Result + where + S: serde::ser::Serializer, + { + serializer.serialize_str(self.as_url().as_str()) + } +} + +#[cfg(feature = "schemars")] +impl schemars::JsonSchema for ProxyUrl { + fn schema_name() -> Cow<'static, str> { + Cow::Borrowed("ProxyUrl") + } + + fn json_schema(_generator: &mut schemars::generate::SchemaGenerator) -> schemars::Schema { + schemars::json_schema!({ + "type": "string", + "format": "uri", + "description": "A proxy URL (e.g., `http://proxy.example.com:8080`)." + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_valid_proxy_urls() { + // HTTP proxy + let url = "http://proxy.example.com:8080".parse::().unwrap(); + assert_eq!(url.to_string(), "http://proxy.example.com:8080/"); + + // HTTPS proxy + let url = "https://proxy.example.com:8080" + .parse::() + .unwrap(); + assert_eq!(url.to_string(), "https://proxy.example.com:8080/"); + + // SOCKS5 proxy (no trailing slash for socks URLs) + let url = "socks5://proxy.example.com:1080" + .parse::() + .unwrap(); + assert_eq!(url.to_string(), "socks5://proxy.example.com:1080"); + + // SOCKS5H proxy + let url = "socks5h://proxy.example.com:1080" + .parse::() + .unwrap(); + assert_eq!(url.to_string(), "socks5h://proxy.example.com:1080"); + + // Proxy with auth + let url = "http://user:pass@proxy.example.com:8080" + .parse::() + .unwrap(); + assert_eq!(url.to_string(), "http://user:pass@proxy.example.com:8080/"); + } + + #[test] + fn parse_proxy_url_without_scheme() { + // URL without a scheme (no "://") should default to http:// + // This matches curl and reqwest behavior + let url = "proxy.example.com:8080".parse::().unwrap(); + assert_eq!(url.to_string(), "http://proxy.example.com:8080/"); + + // With auth but no scheme + let url = "user:pass@proxy.example.com:8080" + .parse::() + .unwrap(); + assert_eq!(url.to_string(), "http://user:pass@proxy.example.com:8080/"); + + // Just hostname + let url = "proxy.example.com".parse::().unwrap(); + assert_eq!(url.to_string(), "http://proxy.example.com/"); + } + + #[test] + fn parse_invalid_proxy_urls() { + let result = "ftp://proxy.example.com:8080".parse::(); + assert!(matches!(result, Err(ProxyUrlError::InvalidScheme { .. }))); + insta::assert_snapshot!( + result.unwrap_err().to_string(), + @"invalid proxy URL scheme `ftp` in `ftp://proxy.example.com:8080/`: expected http, https, socks5, or socks5h" + ); + + // Invalid URL (spaces are not allowed) + let result = "not a url".parse::(); + assert!(matches!(result, Err(ProxyUrlError::InvalidUrl(_)))); + insta::assert_snapshot!( + result.unwrap_err().to_string(), + @"invalid proxy URL: invalid international domain name" + ); + + // Empty string + let result = "".parse::(); + assert!(matches!(result, Err(ProxyUrlError::InvalidUrl(_)))); + insta::assert_snapshot!( + result.unwrap_err().to_string(), + @"invalid proxy URL: empty host" + ); + + let result = "file:///path/to/file".parse::(); + assert!(matches!(result, Err(ProxyUrlError::InvalidScheme { .. }))); + insta::assert_snapshot!( + result.unwrap_err().to_string(), + @"invalid proxy URL scheme `file` in `file:///path/to/file`: expected http, https, socks5, or socks5h" + ); + } + + #[test] + fn deserialize_invalid_proxy_url() { + let result: Result = serde_json::from_str(r#""ftp://proxy.example.com:8080""#); + insta::assert_snapshot!( + result.unwrap_err().to_string(), + @r#"invalid proxy URL scheme `ftp` in `ftp://proxy.example.com:8080/`: expected http, https, socks5, or socks5h"# + ); + + let result: Result = serde_json::from_str(r#""not a url""#); + insta::assert_snapshot!( + result.unwrap_err().to_string(), + @"invalid proxy URL: invalid international domain name" + ); + } +} diff --git a/crates/uv-dev/src/generate_options_reference.rs b/crates/uv-dev/src/generate_options_reference.rs index d30b1cd89..29e6e9bab 100644 --- a/crates/uv-dev/src/generate_options_reference.rs +++ b/crates/uv-dev/src/generate_options_reference.rs @@ -283,26 +283,40 @@ fn emit_field(output: &mut String, name: &str, field: &OptionField, parents: &[S option_type: OptionType::Configuration, .. } => { - output.push_str(&format_tab( - "pyproject.toml", - &format_header( - field.scope, + // For uv_toml_only options, only show the uv.toml tab + if field.uv_toml_only { + output.push_str(&format_code( + "uv.toml", + &format_header( + field.scope, + field.example, + parents, + ConfigurationFile::UvToml, + ), field.example, - parents, - ConfigurationFile::PyprojectToml, - ), - field.example, - )); - output.push_str(&format_tab( - "uv.toml", - &format_header( - field.scope, + )); + } else { + output.push_str(&format_tab( + "pyproject.toml", + &format_header( + field.scope, + field.example, + parents, + ConfigurationFile::PyprojectToml, + ), field.example, - parents, - ConfigurationFile::UvToml, - ), - field.example, - )); + )); + output.push_str(&format_tab( + "uv.toml", + &format_header( + field.scope, + field.example, + parents, + ConfigurationFile::UvToml, + ), + field.example, + )); + } } _ => {} } diff --git a/crates/uv-macros/src/options_metadata.rs b/crates/uv-macros/src/options_metadata.rs index 127ccae14..e88b44737 100644 --- a/crates/uv-macros/src/options_metadata.rs +++ b/crates/uv-macros/src/options_metadata.rs @@ -195,6 +195,7 @@ fn handle_option(field: &Field, attr: &Attribute) -> syn::Result { example, scope, possible_values, + uv_toml_only, } = parse_field_attributes(attr)?; let kebab_name = LitStr::new(&ident.to_string().replace('_', "-"), ident.span()); @@ -254,6 +255,7 @@ fn handle_option(field: &Field, attr: &Attribute) -> syn::Result { scope: #scope, deprecated: #deprecated, possible_values: #possible_values, + uv_toml_only: #uv_toml_only, }) } )) @@ -266,6 +268,7 @@ struct FieldAttributes { example: String, scope: Option, possible_values: Option, + uv_toml_only: bool, } fn parse_field_attributes(attribute: &Attribute) -> syn::Result { @@ -274,6 +277,7 @@ fn parse_field_attributes(attribute: &Attribute) -> syn::Result let mut example = None; let mut scope = None; let mut possible_values = None; + let mut uv_toml_only = None; attribute.parse_nested_meta(|meta| { if meta.path.is_ident("default") { @@ -287,6 +291,8 @@ fn parse_field_attributes(attribute: &Attribute) -> syn::Result example = Some(dedent(&example_text).trim_matches('\n').to_string()); } else if meta.path.is_ident("possible_values") { possible_values = get_bool_literal(&meta, "possible_values", "option")?; + } else if meta.path.is_ident("uv_toml_only") { + uv_toml_only = get_bool_literal(&meta, "uv_toml_only", "option")?; } else { return Err(syn::Error::new( meta.path.span(), @@ -327,6 +333,7 @@ fn parse_field_attributes(attribute: &Attribute) -> syn::Result example, scope, possible_values, + uv_toml_only: uv_toml_only.unwrap_or(false), }) } diff --git a/crates/uv-options-metadata/src/lib.rs b/crates/uv-options-metadata/src/lib.rs index 8b8d38aa2..8ae4e38d6 100644 --- a/crates/uv-options-metadata/src/lib.rs +++ b/crates/uv-options-metadata/src/lib.rs @@ -258,6 +258,8 @@ pub struct OptionField { pub example: &'static str, pub deprecated: Option, pub possible_values: Option>, + /// If true, this option is only available in `uv.toml`, not `pyproject.toml`. + pub uv_toml_only: bool, } #[derive(Debug, Clone, Eq, PartialEq, Serialize)] @@ -343,6 +345,7 @@ mod tests { scope: None, deprecated: None, possible_values: None, + uv_toml_only: false, }, ); } @@ -368,6 +371,7 @@ mod tests { scope: None, deprecated: None, possible_values: None, + uv_toml_only: false, }, ); @@ -389,6 +393,7 @@ mod tests { scope: None, deprecated: None, possible_values: None, + uv_toml_only: false, }, ); } @@ -411,6 +416,7 @@ mod tests { scope: None, deprecated: None, possible_values: None, + uv_toml_only: false, }; impl OptionsMetadata for WithOptions { @@ -436,6 +442,7 @@ mod tests { scope: None, deprecated: None, possible_values: None, + uv_toml_only: false, }; struct Root; @@ -452,6 +459,7 @@ mod tests { scope: None, deprecated: None, possible_values: None, + uv_toml_only: false, }, ); diff --git a/crates/uv-settings/src/combine.rs b/crates/uv-settings/src/combine.rs index 15a63e1dc..d5ad9f541 100644 --- a/crates/uv-settings/src/combine.rs +++ b/crates/uv-settings/src/combine.rs @@ -4,8 +4,8 @@ use std::{collections::BTreeMap, num::NonZeroUsize}; use url::Url; use uv_configuration::{ - BuildIsolation, ExportFormat, IndexStrategy, KeyringProviderType, Reinstall, RequiredVersion, - TargetTriple, TrustedPublishing, Upgrade, + BuildIsolation, ExportFormat, IndexStrategy, KeyringProviderType, ProxyUrl, Reinstall, + RequiredVersion, TargetTriple, TrustedPublishing, Upgrade, }; use uv_distribution_types::{ ConfigSettings, ExtraBuildVariables, Index, IndexUrl, PackageConfigSettings, PipExtraIndex, @@ -100,6 +100,7 @@ impl_combine_or!(PipExtraIndex); impl_combine_or!(PipFindLinks); impl_combine_or!(PipIndex); impl_combine_or!(PrereleaseMode); +impl_combine_or!(ProxyUrl); impl_combine_or!(PythonDownloads); impl_combine_or!(PythonPreference); impl_combine_or!(PythonVersion); diff --git a/crates/uv-settings/src/lib.rs b/crates/uv-settings/src/lib.rs index 50c185a63..c01568e90 100644 --- a/crates/uv-settings/src/lib.rs +++ b/crates/uv-settings/src/lib.rs @@ -303,6 +303,9 @@ fn warn_uv_toml_masked_fields(options: &Options) { concurrent_builds, concurrent_installs, allow_insecure_host, + http_proxy, + https_proxy, + no_proxy, }, top_level: ResolverInstallerSchema { @@ -408,6 +411,15 @@ fn warn_uv_toml_masked_fields(options: &Options) { if allow_insecure_host.is_some() { masked_fields.push("allow-insecure-host"); } + if http_proxy.is_some() { + masked_fields.push("http-proxy"); + } + if https_proxy.is_some() { + masked_fields.push("https-proxy"); + } + if no_proxy.is_some() { + masked_fields.push("no-proxy"); + } if index.is_some() { masked_fields.push("index"); } diff --git a/crates/uv-settings/src/settings.rs b/crates/uv-settings/src/settings.rs index 9bf98ada9..7c086a1b1 100644 --- a/crates/uv-settings/src/settings.rs +++ b/crates/uv-settings/src/settings.rs @@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize}; use uv_cache_info::CacheKey; use uv_configuration::{ - BuildIsolation, IndexStrategy, KeyringProviderType, PackageNameSpecifier, Reinstall, + BuildIsolation, IndexStrategy, KeyringProviderType, PackageNameSpecifier, ProxyUrl, Reinstall, RequiredVersion, TargetTriple, TrustedHost, TrustedPublishing, Upgrade, }; use uv_distribution_types::{ @@ -311,6 +311,36 @@ pub struct GlobalOptions { "# )] pub concurrent_installs: Option, + /// The URL of the HTTP proxy to use. + #[option( + default = "None", + value_type = "str", + uv_toml_only = true, + example = r#" + http-proxy = "http://proxy.example.com" + "# + )] + pub http_proxy: Option, + /// The URL of the HTTPS proxy to use. + #[option( + default = "None", + value_type = "str", + uv_toml_only = true, + example = r#" + https-proxy = "https://proxy.example.com" + "# + )] + pub https_proxy: Option, + /// A list of hosts to exclude from proxying. + #[option( + default = "None", + value_type = "list[str]", + uv_toml_only = true, + example = r#" + no-proxy = ["localhost", "127.0.0.1"] + "# + )] + pub no_proxy: Option>, /// Allow insecure connections to host. /// /// Expects to receive either a hostname (e.g., `localhost`), a host-port pair (e.g., @@ -2105,6 +2135,9 @@ pub struct OptionsWire { find_links: Option>, index_strategy: Option, keyring_provider: Option, + http_proxy: Option, + https_proxy: Option, + no_proxy: Option>, allow_insecure_host: Option>, resolution: Option, prerelease: Option, @@ -2200,6 +2233,9 @@ impl From for Options { find_links, index_strategy, keyring_provider, + http_proxy, + https_proxy, + no_proxy, allow_insecure_host, resolution, prerelease, @@ -2262,6 +2298,9 @@ impl From for Options { concurrent_downloads, concurrent_builds, concurrent_installs, + http_proxy, + https_proxy, + no_proxy, // Used twice for backwards compatibility allow_insecure_host: allow_insecure_host.clone(), }, diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index de61d7817..24b7b40aa 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -199,7 +199,10 @@ async fn run(mut cli: Cli) -> Result { settings.preview, settings.network_settings.timeout, settings.network_settings.retries, - ); + ) + .http_proxy(settings.network_settings.http_proxy) + .https_proxy(settings.network_settings.https_proxy) + .no_proxy(settings.network_settings.no_proxy); Some( RunCommand::from_args(command, client_builder, *module, *script, *gui_script) .await?, @@ -472,7 +475,10 @@ async fn run(mut cli: Cli) -> Result { globals.preview, globals.network_settings.timeout, globals.network_settings.retries, - ); + ) + .http_proxy(globals.network_settings.http_proxy.clone()) + .https_proxy(globals.network_settings.https_proxy.clone()) + .no_proxy(globals.network_settings.no_proxy.clone()); match *cli.command { Commands::Auth(AuthNamespace { diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index d69da18fd..0a493c80a 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -28,8 +28,8 @@ use uv_configuration::{ BuildIsolation, BuildOptions, Concurrency, DependencyGroups, DryRun, EditableMode, EnvFile, ExportFormat, ExtrasSpecification, GitLfsSetting, HashCheckingMode, IndexStrategy, InstallOptions, KeyringProviderType, NoBinary, NoBuild, PipCompileFormat, ProjectBuildBackend, - Reinstall, RequiredVersion, SourceStrategy, TargetTriple, TrustedHost, TrustedPublishing, - Upgrade, VersionControlSystem, + ProxyUrl, Reinstall, RequiredVersion, SourceStrategy, TargetTriple, TrustedHost, + TrustedPublishing, Upgrade, VersionControlSystem, }; use uv_distribution_types::{ ConfigSettings, DependencyMetadata, ExtraBuildVariables, Index, IndexLocations, IndexUrl, @@ -184,6 +184,9 @@ fn resolve_python_preference( pub(crate) struct NetworkSettings { pub(crate) connectivity: Connectivity, pub(crate) native_tls: bool, + pub(crate) http_proxy: Option, + pub(crate) https_proxy: Option, + pub(crate) no_proxy: Option>, pub(crate) allow_insecure_host: Vec, pub(crate) timeout: Duration, pub(crate) retries: u32, @@ -223,9 +226,16 @@ impl NetworkSettings { .flatten(), ) .collect(); + let http_proxy = workspace.and_then(|workspace| workspace.globals.http_proxy.clone()); + let https_proxy = workspace.and_then(|workspace| workspace.globals.https_proxy.clone()); + let no_proxy = workspace.and_then(|workspace| workspace.globals.no_proxy.clone()); + Self { connectivity, native_tls, + http_proxy, + https_proxy, + no_proxy, allow_insecure_host, timeout: environment.http_timeout, retries: environment.http_retries, diff --git a/crates/uv/tests/it/network.rs b/crates/uv/tests/it/network.rs index 2b62a7cad..a180c7223 100644 --- a/crates/uv/tests/it/network.rs +++ b/crates/uv/tests/it/network.rs @@ -4,15 +4,121 @@ use assert_fs::fixture::{ChildPath, FileWriteStr, PathChild}; use http::StatusCode; use serde_json::json; use uv_static::EnvVars; -use wiremock::matchers::method; +use wiremock::matchers::{any, method}; use wiremock::{Mock, MockServer, Request, ResponseTemplate}; use crate::common::{TestContext, uv_snapshot}; +/// Creates a CONNECT tunnel proxy that forwards connections to the target. +/// +/// Returns the proxy address. The proxy runs in a background thread. +fn start_connect_tunnel_proxy() -> std::net::SocketAddr { + use std::io::{Read, Write}; + use std::net::{TcpListener, TcpStream}; + + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + + // Spawn a real OS thread for the proxy server + std::thread::spawn(move || { + for stream in listener.incoming() { + let Ok(mut client) = stream else { break }; + + // Handle each connection in its own thread + std::thread::spawn(move || { + // Read the CONNECT request + let mut buf = vec![0u8; 4096]; + let mut total_read = 0; + loop { + let n = match client.read(&mut buf[total_read..]) { + Ok(0) | Err(_) => return, + Ok(n) => n, + }; + total_read += n; + if buf[..total_read].windows(4).any(|w| w == b"\r\n\r\n") { + break; + } + } + + let request = String::from_utf8_lossy(&buf[..total_read]); + + // Parse "CONNECT host:port HTTP/1.1\r\n" + let Some(target_addr) = request + .lines() + .next() + .and_then(|line| line.strip_prefix("CONNECT ")) + .and_then(|s| s.split_whitespace().next()) + .map(ToString::to_string) + else { + return; + }; + + // Connect to the target + let Ok(mut target) = TcpStream::connect(&target_addr) else { + return; + }; + + // Send 200 Connection Established + if client + .write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n") + .is_err() + { + return; + } + + // Bidirectionally forward data using two threads + let mut client_read = client.try_clone().unwrap(); + let mut target_write = target.try_clone().unwrap(); + + let c2t = + std::thread::spawn(move || std::io::copy(&mut client_read, &mut target_write)); + + let _ = std::io::copy(&mut target, &mut client); + let _ = c2t.join(); + }); + } + }); + + addr +} + +/// Creates a mock that serves a Simple API index page for iniconfig. +async fn mock_simple_api(server: &MockServer) { + // Simple API response for iniconfig pointing to the real PyPI wheel. + // Uses upload-time before EXCLUDE_NEWER (2024-03-25) so the package is available. + let body = json!({ + "name": "iniconfig", + "files": [{ + "filename": "iniconfig-2.0.0-py3-none-any.whl", + "url": "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", + "hashes": { + "sha256": "2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3" + }, + "requires-python": ">=3.8", + "upload-time": "2024-01-01T00:00:00Z" + }] + }); + + // Serve the simple index for iniconfig - use any() matcher since HTTP proxy + // requests may have the full URL in the path + Mock::given(any()) + .respond_with( + ResponseTemplate::new(200) + .set_body_raw(body.to_string(), "application/vnd.pypi.simple.v1+json"), + ) + .mount(server) + .await; +} + fn connection_reset(_request: &wiremock::Request) -> io::Error { io::Error::new(io::ErrorKind::ConnectionReset, "Connection reset by peer") } +/// Returns true if the mock server has received any requests. +async fn has_received_requests(server: &MockServer) -> bool { + !server.received_requests().await.unwrap().is_empty() +} + /// Answers with a retryable HTTP status 500. async fn http_error_server() -> (MockServer, String) { let server = MockServer::start().await; @@ -538,3 +644,290 @@ async fn rfc9457_problem_details_license_violation() { ╰─▶ HTTP status client error (403 Forbidden) for url ([SERVER]/packages/tqdm-4.67.1-py3-none-any.whl) "); } + +/// Test that invalid proxy URL in uv.toml produces a helpful error message. +#[tokio::test] +async fn proxy_invalid_url_in_uv_toml() { + let context = TestContext::new("3.12"); + + let uv_toml = context.temp_dir.child("uv.toml"); + uv_toml + .write_str(indoc::indoc! {r#" + http-proxy = "ftp://proxy.example.com:8080" + "#}) + .unwrap(); + + uv_snapshot!(context.filters(), context + .pip_install() + .arg("iniconfig") + .env_remove(EnvVars::HTTP_PROXY) + .env_remove(EnvVars::HTTPS_PROXY), @r#" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Failed to parse: `uv.toml` + Caused by: TOML parse error at line 1, column 14 + | + 1 | http-proxy = "ftp://proxy.example.com:8080" + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + invalid proxy URL scheme `ftp` in `ftp://proxy.example.com:8080/`: expected http, https, socks5, or socks5h + "#); +} + +/// Test that invalid proxy URL (not a URL) in uv.toml produces a helpful error message. +#[tokio::test] +async fn proxy_invalid_url_not_a_url_in_uv_toml() { + let context = TestContext::new("3.12"); + + let uv_toml = context.temp_dir.child("uv.toml"); + uv_toml + .write_str(indoc::indoc! {r#" + http-proxy = "not a valid url" + "#}) + .unwrap(); + + uv_snapshot!(context.filters(), context + .pip_install() + .arg("iniconfig") + .env_remove(EnvVars::HTTP_PROXY) + .env_remove(EnvVars::HTTPS_PROXY), @r#" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Failed to parse: `uv.toml` + Caused by: TOML parse error at line 1, column 14 + | + 1 | http-proxy = "not a valid url" + | ^^^^^^^^^^^^^^^^^ + invalid proxy URL: invalid international domain name + "#); +} + +/// Test that valid proxy URL in uv.toml routes requests through the proxy. +#[tokio::test] +async fn proxy_valid_url_in_uv_toml() { + let context = TestContext::new("3.12"); + + let target_server = MockServer::start().await; + Mock::given(any()) + .respond_with(ResponseTemplate::new(200)) + .mount(&target_server) + .await; + + let proxy_server = MockServer::start().await; + mock_simple_api(&proxy_server).await; + + let target_uri = target_server.uri(); + let proxy_uri = proxy_server.uri(); + + let context = context + .with_filter((target_uri.clone(), "[TARGET]")) + .with_filter((proxy_uri.clone(), "[PROXY]")); + + let uv_toml = context.temp_dir.child("uv.toml"); + uv_toml + .write_str(&format!(r#"http-proxy = "{proxy_uri}""#)) + .unwrap(); + + uv_snapshot!(context.filters(), context + .pip_install() + .arg("iniconfig") + .arg("--index-url") + .arg(&target_uri) + .arg("--config-file") + .arg(uv_toml.path()) + .env_remove(EnvVars::HTTP_PROXY) + .env_remove(EnvVars::HTTPS_PROXY) + .env_remove(EnvVars::ALL_PROXY) + .env_remove(EnvVars::NO_PROXY), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 1 package in [TIME] + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + iniconfig==2.0.0 + "); + + assert!( + has_received_requests(&proxy_server).await, + "Proxy should have received the request" + ); + assert!( + !has_received_requests(&target_server).await, + "Target should NOT have been called directly when proxy is configured" + ); +} + +/// Test that https-proxy in uv.toml routes HTTPS requests through a CONNECT tunnel proxy. +#[test] +fn proxy_https_proxy_in_uv_toml() { + let context = TestContext::new("3.12"); + + let proxy_addr = start_connect_tunnel_proxy(); + let proxy_uri = format!("http://{proxy_addr}"); + + let context = context.with_filter((proxy_uri.clone(), "[PROXY]")); + + let uv_toml = context.temp_dir.child("uv.toml"); + uv_toml + .write_str(&format!(r#"https-proxy = "{proxy_uri}""#)) + .unwrap(); + + uv_snapshot!(context.filters(), context + .pip_install() + .arg("--config-file") + .arg(uv_toml.path()) + .arg("iniconfig") + .env_remove(EnvVars::HTTP_PROXY) + .env_remove(EnvVars::HTTPS_PROXY) + .env_remove(EnvVars::ALL_PROXY) + .env_remove(EnvVars::NO_PROXY), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 1 package in [TIME] + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + iniconfig==2.0.0 + "); +} + +/// Test that no-proxy in uv.toml bypasses the proxy for specified hosts. +#[tokio::test] +async fn proxy_no_proxy_in_uv_toml() { + let context = TestContext::new("3.12"); + + let target_server = MockServer::start().await; + mock_simple_api(&target_server).await; + + let proxy_server = MockServer::start().await; + Mock::given(any()) + .respond_with(ResponseTemplate::new(200)) + .mount(&proxy_server) + .await; + + let target_uri = target_server.uri(); + let proxy_uri = proxy_server.uri(); + + // Note: reqwest's NoProxy matches on host only, not host:port + let target_url = url::Url::parse(&target_uri).unwrap(); + let target_host = target_url.host_str().unwrap(); + + let context = context + .with_filter((target_uri.clone(), "[TARGET]")) + .with_filter((proxy_uri.clone(), "[PROXY]")); + + let uv_toml = context.temp_dir.child("uv.toml"); + uv_toml + .write_str(&format!( + r#" +http-proxy = "{proxy_uri}" +no-proxy = ["{target_host}"] +"# + )) + .unwrap(); + + uv_snapshot!(context.filters(), context + .pip_install() + .arg("iniconfig") + .arg("--index-url") + .arg(&target_uri) + .arg("--config-file") + .arg(uv_toml.path()) + .env_remove(EnvVars::HTTP_PROXY) + .env_remove(EnvVars::HTTPS_PROXY) + .env_remove(EnvVars::ALL_PROXY) + .env_remove(EnvVars::NO_PROXY), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 1 package in [TIME] + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + iniconfig==2.0.0 + "); + + assert!( + has_received_requests(&target_server).await, + "Target should have received the request directly when in no-proxy list" + ); + assert!( + !has_received_requests(&proxy_server).await, + "Proxy should NOT have received requests when target is in no-proxy list" + ); +} + +/// Test that proxy URLs without a scheme in uv.toml default to http://. +#[tokio::test] +async fn proxy_schemeless_url_in_uv_toml() { + let context = TestContext::new("3.12"); + + let target_server = MockServer::start().await; + Mock::given(any()) + .respond_with(ResponseTemplate::new(200)) + .mount(&target_server) + .await; + + let proxy_server = MockServer::start().await; + mock_simple_api(&proxy_server).await; + + let target_uri = target_server.uri(); + let proxy_uri = proxy_server.uri(); + + // Strip scheme to test schemeless URL handling + let proxy_host = proxy_uri + .strip_prefix("http://") + .unwrap_or(proxy_uri.as_str()); + + let context = context + .with_filter((target_uri.clone(), "[TARGET]")) + .with_filter((proxy_uri.clone(), "[PROXY]")) + .with_filter((proxy_host, "[PROXY_HOST]")); + + let uv_toml = context.temp_dir.child("uv.toml"); + uv_toml + .write_str(&format!(r#"http-proxy = "{proxy_host}""#)) + .unwrap(); + + uv_snapshot!(context.filters(), context + .pip_install() + .arg("iniconfig") + .arg("--index-url") + .arg(&target_uri) + .arg("--config-file") + .arg(uv_toml.path()) + .env_remove(EnvVars::HTTP_PROXY) + .env_remove(EnvVars::HTTPS_PROXY) + .env_remove(EnvVars::ALL_PROXY) + .env_remove(EnvVars::NO_PROXY), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 1 package in [TIME] + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + iniconfig==2.0.0 + "); + + assert!( + has_received_requests(&proxy_server).await, + "Proxy should have received the request even with schemeless URL" + ); + assert!( + !has_received_requests(&target_server).await, + "Target should NOT have been called directly when proxy is configured" + ); +} diff --git a/crates/uv/tests/it/show_settings.rs b/crates/uv/tests/it/show_settings.rs index f951d754d..3f2fc384f 100644 --- a/crates/uv/tests/it/show_settings.rs +++ b/crates/uv/tests/it/show_settings.rs @@ -65,6 +65,9 @@ fn resolve_uv_toml() -> anyhow::Result<()> { network_settings: NetworkSettings { connectivity: Online, native_tls: false, + http_proxy: None, + https_proxy: None, + no_proxy: None, allow_insecure_host: [], timeout: [TIME], retries: 3, @@ -268,6 +271,9 @@ fn resolve_uv_toml() -> anyhow::Result<()> { network_settings: NetworkSettings { connectivity: Online, native_tls: false, + http_proxy: None, + https_proxy: None, + no_proxy: None, allow_insecure_host: [], timeout: [TIME], retries: 3, @@ -472,6 +478,9 @@ fn resolve_uv_toml() -> anyhow::Result<()> { network_settings: NetworkSettings { connectivity: Online, native_tls: false, + http_proxy: None, + https_proxy: None, + no_proxy: None, allow_insecure_host: [], timeout: [TIME], retries: 3, @@ -708,6 +717,9 @@ fn resolve_pyproject_toml() -> anyhow::Result<()> { network_settings: NetworkSettings { connectivity: Online, native_tls: false, + http_proxy: None, + https_proxy: None, + no_proxy: None, allow_insecure_host: [], timeout: [TIME], retries: 3, @@ -913,6 +925,9 @@ fn resolve_pyproject_toml() -> anyhow::Result<()> { network_settings: NetworkSettings { connectivity: Online, native_tls: false, + http_proxy: None, + https_proxy: None, + no_proxy: None, allow_insecure_host: [], timeout: [TIME], retries: 3, @@ -1094,6 +1109,9 @@ fn resolve_pyproject_toml() -> anyhow::Result<()> { network_settings: NetworkSettings { connectivity: Online, native_tls: false, + http_proxy: None, + https_proxy: None, + no_proxy: None, allow_insecure_host: [], timeout: [TIME], retries: 3, @@ -1324,6 +1342,9 @@ fn resolve_index_url() -> anyhow::Result<()> { network_settings: NetworkSettings { connectivity: Online, native_tls: false, + http_proxy: None, + https_proxy: None, + no_proxy: None, allow_insecure_host: [], timeout: [TIME], retries: 3, @@ -1562,6 +1583,9 @@ fn resolve_index_url() -> anyhow::Result<()> { network_settings: NetworkSettings { connectivity: Online, native_tls: false, + http_proxy: None, + https_proxy: None, + no_proxy: None, allow_insecure_host: [], timeout: [TIME], retries: 3, @@ -1858,6 +1882,9 @@ fn resolve_find_links() -> anyhow::Result<()> { network_settings: NetworkSettings { connectivity: Online, native_tls: false, + http_proxy: None, + https_proxy: None, + no_proxy: None, allow_insecure_host: [], timeout: [TIME], retries: 3, @@ -2085,6 +2112,9 @@ fn resolve_top_level() -> anyhow::Result<()> { network_settings: NetworkSettings { connectivity: Online, native_tls: false, + http_proxy: None, + https_proxy: None, + no_proxy: None, allow_insecure_host: [], timeout: [TIME], retries: 3, @@ -2271,6 +2301,9 @@ fn resolve_top_level() -> anyhow::Result<()> { network_settings: NetworkSettings { connectivity: Online, native_tls: false, + http_proxy: None, + https_proxy: None, + no_proxy: None, allow_insecure_host: [], timeout: [TIME], retries: 3, @@ -2507,6 +2540,9 @@ fn resolve_top_level() -> anyhow::Result<()> { network_settings: NetworkSettings { connectivity: Online, native_tls: false, + http_proxy: None, + https_proxy: None, + no_proxy: None, allow_insecure_host: [], timeout: [TIME], retries: 3, @@ -2766,6 +2802,9 @@ fn resolve_user_configuration() -> anyhow::Result<()> { network_settings: NetworkSettings { connectivity: Online, native_tls: false, + http_proxy: None, + https_proxy: None, + no_proxy: None, allow_insecure_host: [], timeout: [TIME], retries: 3, @@ -2942,6 +2981,9 @@ fn resolve_user_configuration() -> anyhow::Result<()> { network_settings: NetworkSettings { connectivity: Online, native_tls: false, + http_proxy: None, + https_proxy: None, + no_proxy: None, allow_insecure_host: [], timeout: [TIME], retries: 3, @@ -3118,6 +3160,9 @@ fn resolve_user_configuration() -> anyhow::Result<()> { network_settings: NetworkSettings { connectivity: Online, native_tls: false, + http_proxy: None, + https_proxy: None, + no_proxy: None, allow_insecure_host: [], timeout: [TIME], retries: 3, @@ -3296,6 +3341,9 @@ fn resolve_user_configuration() -> anyhow::Result<()> { network_settings: NetworkSettings { connectivity: Online, native_tls: false, + http_proxy: None, + https_proxy: None, + no_proxy: None, allow_insecure_host: [], timeout: [TIME], retries: 3, @@ -3493,6 +3541,9 @@ fn resolve_tool() -> anyhow::Result<()> { network_settings: NetworkSettings { connectivity: Online, native_tls: false, + http_proxy: None, + https_proxy: None, + no_proxy: None, allow_insecure_host: [], timeout: [TIME], retries: 3, @@ -3684,6 +3735,9 @@ fn resolve_poetry_toml() -> anyhow::Result<()> { network_settings: NetworkSettings { connectivity: Online, native_tls: false, + http_proxy: None, + https_proxy: None, + no_proxy: None, allow_insecure_host: [], timeout: [TIME], retries: 3, @@ -3894,6 +3948,9 @@ fn resolve_both() -> anyhow::Result<()> { network_settings: NetworkSettings { connectivity: Online, native_tls: false, + http_proxy: None, + https_proxy: None, + no_proxy: None, allow_insecure_host: [], timeout: [TIME], retries: 3, @@ -4143,6 +4200,9 @@ fn resolve_both_special_fields() -> anyhow::Result<()> { network_settings: NetworkSettings { connectivity: Online, native_tls: false, + http_proxy: None, + https_proxy: None, + no_proxy: None, allow_insecure_host: [], timeout: [TIME], retries: 3, @@ -4471,6 +4531,9 @@ fn resolve_config_file() -> anyhow::Result<()> { network_settings: NetworkSettings { connectivity: Online, native_tls: false, + http_proxy: None, + https_proxy: None, + no_proxy: None, allow_insecure_host: [], timeout: [TIME], retries: 3, @@ -4686,7 +4749,7 @@ fn resolve_config_file() -> anyhow::Result<()> { | 1 | [project] | ^^^^^^^ - unknown field `project`, expected one of `required-version`, `native-tls`, `offline`, `no-cache`, `cache-dir`, `preview`, `python-preference`, `python-downloads`, `concurrent-downloads`, `concurrent-builds`, `concurrent-installs`, `index`, `index-url`, `extra-index-url`, `no-index`, `find-links`, `index-strategy`, `keyring-provider`, `allow-insecure-host`, `resolution`, `prerelease`, `fork-strategy`, `dependency-metadata`, `config-settings`, `config-settings-package`, `no-build-isolation`, `no-build-isolation-package`, `extra-build-dependencies`, `extra-build-variables`, `exclude-newer`, `exclude-newer-package`, `link-mode`, `compile-bytecode`, `no-sources`, `upgrade`, `upgrade-package`, `reinstall`, `reinstall-package`, `no-build`, `no-build-package`, `no-binary`, `no-binary-package`, `torch-backend`, `python-install-mirror`, `pypy-install-mirror`, `python-downloads-json-url`, `publish-url`, `trusted-publishing`, `check-url`, `add-bounds`, `pip`, `cache-keys`, `override-dependencies`, `exclude-dependencies`, `constraint-dependencies`, `build-constraint-dependencies`, `environments`, `required-environments`, `conflicts`, `workspace`, `sources`, `managed`, `package`, `default-groups`, `dependency-groups`, `dev-dependencies`, `build-backend` + unknown field `project`, expected one of `required-version`, `native-tls`, `offline`, `no-cache`, `cache-dir`, `preview`, `python-preference`, `python-downloads`, `concurrent-downloads`, `concurrent-builds`, `concurrent-installs`, `index`, `index-url`, `extra-index-url`, `no-index`, `find-links`, `index-strategy`, `keyring-provider`, `http-proxy`, `https-proxy`, `no-proxy`, `allow-insecure-host`, `resolution`, `prerelease`, `fork-strategy`, `dependency-metadata`, `config-settings`, `config-settings-package`, `no-build-isolation`, `no-build-isolation-package`, `extra-build-dependencies`, `extra-build-variables`, `exclude-newer`, `exclude-newer-package`, `link-mode`, `compile-bytecode`, `no-sources`, `upgrade`, `upgrade-package`, `reinstall`, `reinstall-package`, `no-build`, `no-build-package`, `no-binary`, `no-binary-package`, `torch-backend`, `python-install-mirror`, `pypy-install-mirror`, `python-downloads-json-url`, `publish-url`, `trusted-publishing`, `check-url`, `add-bounds`, `pip`, `cache-keys`, `override-dependencies`, `exclude-dependencies`, `constraint-dependencies`, `build-constraint-dependencies`, `environments`, `required-environments`, `conflicts`, `workspace`, `sources`, `managed`, `package`, `default-groups`, `dependency-groups`, `dev-dependencies`, `build-backend` " ); @@ -4774,6 +4837,9 @@ fn resolve_skip_empty() -> anyhow::Result<()> { network_settings: NetworkSettings { connectivity: Online, native_tls: false, + http_proxy: None, + https_proxy: None, + no_proxy: None, allow_insecure_host: [], timeout: [TIME], retries: 3, @@ -4953,6 +5019,9 @@ fn resolve_skip_empty() -> anyhow::Result<()> { network_settings: NetworkSettings { connectivity: Online, native_tls: false, + http_proxy: None, + https_proxy: None, + no_proxy: None, allow_insecure_host: [], timeout: [TIME], retries: 3, @@ -5140,6 +5209,9 @@ fn allow_insecure_host() -> anyhow::Result<()> { network_settings: NetworkSettings { connectivity: Online, native_tls: false, + http_proxy: None, + https_proxy: None, + no_proxy: None, allow_insecure_host: [ Host { scheme: None, @@ -5341,6 +5413,9 @@ fn index_priority() -> anyhow::Result<()> { network_settings: NetworkSettings { connectivity: Online, native_tls: false, + http_proxy: None, + https_proxy: None, + no_proxy: None, allow_insecure_host: [], timeout: [TIME], retries: 3, @@ -5579,6 +5654,9 @@ fn index_priority() -> anyhow::Result<()> { network_settings: NetworkSettings { connectivity: Online, native_tls: false, + http_proxy: None, + https_proxy: None, + no_proxy: None, allow_insecure_host: [], timeout: [TIME], retries: 3, @@ -5823,6 +5901,9 @@ fn index_priority() -> anyhow::Result<()> { network_settings: NetworkSettings { connectivity: Online, native_tls: false, + http_proxy: None, + https_proxy: None, + no_proxy: None, allow_insecure_host: [], timeout: [TIME], retries: 3, @@ -6062,6 +6143,9 @@ fn index_priority() -> anyhow::Result<()> { network_settings: NetworkSettings { connectivity: Online, native_tls: false, + http_proxy: None, + https_proxy: None, + no_proxy: None, allow_insecure_host: [], timeout: [TIME], retries: 3, @@ -6308,6 +6392,9 @@ fn index_priority() -> anyhow::Result<()> { network_settings: NetworkSettings { connectivity: Online, native_tls: false, + http_proxy: None, + https_proxy: None, + no_proxy: None, allow_insecure_host: [], timeout: [TIME], retries: 3, @@ -6547,6 +6634,9 @@ fn index_priority() -> anyhow::Result<()> { network_settings: NetworkSettings { connectivity: Online, native_tls: false, + http_proxy: None, + https_proxy: None, + no_proxy: None, allow_insecure_host: [], timeout: [TIME], retries: 3, @@ -6799,6 +6889,9 @@ fn verify_hashes() -> anyhow::Result<()> { network_settings: NetworkSettings { connectivity: Online, native_tls: false, + http_proxy: None, + https_proxy: None, + no_proxy: None, allow_insecure_host: [], timeout: [TIME], retries: 3, @@ -6968,6 +7061,9 @@ fn verify_hashes() -> anyhow::Result<()> { network_settings: NetworkSettings { connectivity: Online, native_tls: false, + http_proxy: None, + https_proxy: None, + no_proxy: None, allow_insecure_host: [], timeout: [TIME], retries: 3, @@ -7135,6 +7231,9 @@ fn verify_hashes() -> anyhow::Result<()> { network_settings: NetworkSettings { connectivity: Online, native_tls: false, + http_proxy: None, + https_proxy: None, + no_proxy: None, allow_insecure_host: [], timeout: [TIME], retries: 3, @@ -7304,6 +7403,9 @@ fn verify_hashes() -> anyhow::Result<()> { network_settings: NetworkSettings { connectivity: Online, native_tls: false, + http_proxy: None, + https_proxy: None, + no_proxy: None, allow_insecure_host: [], timeout: [TIME], retries: 3, @@ -7471,6 +7573,9 @@ fn verify_hashes() -> anyhow::Result<()> { network_settings: NetworkSettings { connectivity: Online, native_tls: false, + http_proxy: None, + https_proxy: None, + no_proxy: None, allow_insecure_host: [], timeout: [TIME], retries: 3, @@ -7639,6 +7744,9 @@ fn verify_hashes() -> anyhow::Result<()> { network_settings: NetworkSettings { connectivity: Online, native_tls: false, + http_proxy: None, + https_proxy: None, + no_proxy: None, allow_insecure_host: [], timeout: [TIME], retries: 3, @@ -7822,6 +7930,9 @@ fn preview_features() { network_settings: NetworkSettings { connectivity: Online, native_tls: false, + http_proxy: None, + https_proxy: None, + no_proxy: None, allow_insecure_host: [], timeout: [TIME], retries: 3, @@ -7937,6 +8048,9 @@ fn preview_features() { network_settings: NetworkSettings { connectivity: Online, native_tls: false, + http_proxy: None, + https_proxy: None, + no_proxy: None, allow_insecure_host: [], timeout: [TIME], retries: 3, @@ -8052,6 +8166,9 @@ fn preview_features() { network_settings: NetworkSettings { connectivity: Online, native_tls: false, + http_proxy: None, + https_proxy: None, + no_proxy: None, allow_insecure_host: [], timeout: [TIME], retries: 3, @@ -8167,6 +8284,9 @@ fn preview_features() { network_settings: NetworkSettings { connectivity: Online, native_tls: false, + http_proxy: None, + https_proxy: None, + no_proxy: None, allow_insecure_host: [], timeout: [TIME], retries: 3, @@ -8282,6 +8402,9 @@ fn preview_features() { network_settings: NetworkSettings { connectivity: Online, native_tls: false, + http_proxy: None, + https_proxy: None, + no_proxy: None, allow_insecure_host: [], timeout: [TIME], retries: 3, @@ -8399,6 +8522,9 @@ fn preview_features() { network_settings: NetworkSettings { connectivity: Online, native_tls: false, + http_proxy: None, + https_proxy: None, + no_proxy: None, allow_insecure_host: [], timeout: [TIME], retries: 3, @@ -8535,6 +8661,9 @@ fn upgrade_pip_cli_config_interaction() -> anyhow::Result<()> { network_settings: NetworkSettings { connectivity: Online, native_tls: false, + http_proxy: None, + https_proxy: None, + no_proxy: None, allow_insecure_host: [], timeout: [TIME], retries: 3, @@ -8712,6 +8841,9 @@ fn upgrade_pip_cli_config_interaction() -> anyhow::Result<()> { network_settings: NetworkSettings { connectivity: Online, native_tls: false, + http_proxy: None, + https_proxy: None, + no_proxy: None, allow_insecure_host: [], timeout: [TIME], retries: 3, @@ -8912,6 +9044,9 @@ fn upgrade_pip_cli_config_interaction() -> anyhow::Result<()> { network_settings: NetworkSettings { connectivity: Online, native_tls: false, + http_proxy: None, + https_proxy: None, + no_proxy: None, allow_insecure_host: [], timeout: [TIME], retries: 3, @@ -9087,6 +9222,9 @@ fn upgrade_pip_cli_config_interaction() -> anyhow::Result<()> { network_settings: NetworkSettings { connectivity: Online, native_tls: false, + http_proxy: None, + https_proxy: None, + no_proxy: None, allow_insecure_host: [], timeout: [TIME], retries: 3, @@ -9256,6 +9394,9 @@ fn upgrade_pip_cli_config_interaction() -> anyhow::Result<()> { network_settings: NetworkSettings { connectivity: Online, native_tls: false, + http_proxy: None, + https_proxy: None, + no_proxy: None, allow_insecure_host: [], timeout: [TIME], retries: 3, @@ -9426,6 +9567,9 @@ fn upgrade_pip_cli_config_interaction() -> anyhow::Result<()> { network_settings: NetworkSettings { connectivity: Online, native_tls: false, + http_proxy: None, + https_proxy: None, + no_proxy: None, allow_insecure_host: [], timeout: [TIME], retries: 3, @@ -9661,6 +9805,9 @@ fn upgrade_project_cli_config_interaction() -> anyhow::Result<()> { network_settings: NetworkSettings { connectivity: Online, native_tls: false, + http_proxy: None, + https_proxy: None, + no_proxy: None, allow_insecure_host: [], timeout: [TIME], retries: 3, @@ -9781,6 +9928,9 @@ fn upgrade_project_cli_config_interaction() -> anyhow::Result<()> { network_settings: NetworkSettings { connectivity: Online, native_tls: false, + http_proxy: None, + https_proxy: None, + no_proxy: None, allow_insecure_host: [], timeout: [TIME], retries: 3, @@ -9924,6 +10074,9 @@ fn upgrade_project_cli_config_interaction() -> anyhow::Result<()> { network_settings: NetworkSettings { connectivity: Online, native_tls: false, + http_proxy: None, + https_proxy: None, + no_proxy: None, allow_insecure_host: [], timeout: [TIME], retries: 3, @@ -10042,6 +10195,9 @@ fn upgrade_project_cli_config_interaction() -> anyhow::Result<()> { network_settings: NetworkSettings { connectivity: Online, native_tls: false, + http_proxy: None, + https_proxy: None, + no_proxy: None, allow_insecure_host: [], timeout: [TIME], retries: 3, @@ -10150,6 +10306,9 @@ fn upgrade_project_cli_config_interaction() -> anyhow::Result<()> { network_settings: NetworkSettings { connectivity: Online, native_tls: false, + http_proxy: None, + https_proxy: None, + no_proxy: None, allow_insecure_host: [], timeout: [TIME], retries: 3, @@ -10259,6 +10418,9 @@ fn upgrade_project_cli_config_interaction() -> anyhow::Result<()> { network_settings: NetworkSettings { connectivity: Online, native_tls: false, + http_proxy: None, + https_proxy: None, + no_proxy: None, allow_insecure_host: [], timeout: [TIME], retries: 3, @@ -10432,6 +10594,9 @@ fn build_isolation_override() -> anyhow::Result<()> { network_settings: NetworkSettings { connectivity: Online, native_tls: false, + http_proxy: None, + https_proxy: None, + no_proxy: None, allow_insecure_host: [], timeout: [TIME], retries: 3, @@ -10604,6 +10769,9 @@ fn build_isolation_override() -> anyhow::Result<()> { network_settings: NetworkSettings { connectivity: Online, native_tls: false, + http_proxy: None, + https_proxy: None, + no_proxy: None, allow_insecure_host: [], timeout: [TIME], retries: 3, diff --git a/uv.schema.json b/uv.schema.json index 9a19d39fc..f37657526 100644 --- a/uv.schema.json +++ b/uv.schema.json @@ -243,6 +243,28 @@ } ] }, + "http-proxy": { + "description": "The URL of the HTTP proxy to use.", + "anyOf": [ + { + "$ref": "#/definitions/ProxyUrl" + }, + { + "type": "null" + } + ] + }, + "https-proxy": { + "description": "The URL of the HTTPS proxy to use.", + "anyOf": [ + { + "$ref": "#/definitions/ProxyUrl" + }, + { + "type": "null" + } + ] + }, "index": { "description": "The indexes to use when resolving dependencies.\n\nAccepts either a repository compliant with [PEP 503](https://peps.python.org/pep-0503/)\n(the simple repository API), or a local directory laid out in the same format.\n\nIndexes are considered in the order in which they're defined, such that the first-defined\nindex has the highest priority. Further, the indexes provided by this setting are given\nhigher priority than any indexes specified via [`index_url`](#index-url) or\n[`extra_index_url`](#extra-index-url). uv will only consider the first index that contains\na given package, unless an alternative [index strategy](#index-strategy) is specified.\n\nIf an index is marked as `explicit = true`, it will be used exclusively for the\ndependencies that select it explicitly via `[tool.uv.sources]`, as in:\n\n```toml\n[[tool.uv.index]]\nname = \"pytorch\"\nurl = \"https://download.pytorch.org/whl/cu121\"\nexplicit = true\n\n[tool.uv.sources]\ntorch = { index = \"pytorch\" }\n```\n\nIf an index is marked as `default = true`, it will be moved to the end of the prioritized list, such that it is\ngiven the lowest priority when resolving packages. Additionally, marking an index as default will disable the\nPyPI default index.", "type": ["array", "null"], @@ -344,6 +366,13 @@ "description": "Ignore all registry indexes (e.g., PyPI), instead relying on direct URL dependencies and\nthose provided via `--find-links`.", "type": ["boolean", "null"] }, + "no-proxy": { + "description": "A list of hosts to exclude from proxying.", + "type": ["array", "null"], + "items": { + "type": "string" + } + }, "no-sources": { "description": "Ignore the `tool.uv.sources` table when resolving dependencies. Used to lock against the\nstandards-compliant, publishable package metadata, as opposed to using any local or Git\nsources.", "type": ["boolean", "null"] @@ -1521,6 +1550,11 @@ } ] }, + "ProxyUrl": { + "description": "A proxy URL (e.g., `http://proxy.example.com:8080`).", + "type": "string", + "format": "uri" + }, "PythonDownloads": { "oneOf": [ {