diff --git a/Cargo.lock b/Cargo.lock index 9f37717f49660..860611f515536 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5133,6 +5133,7 @@ dependencies = [ "arcstr", "bitflags 2.9.0", "fs-err 3.1.0", + "http", "itertools 0.14.0", "jiff", "owo-colors", diff --git a/crates/uv-auth/src/cache.rs b/crates/uv-auth/src/cache.rs index e8894727b7b6e..636ec4ec4c80d 100644 --- a/crates/uv-auth/src/cache.rs +++ b/crates/uv-auth/src/cache.rs @@ -1,3 +1,4 @@ +use std::fmt::Display; use std::fmt::Formatter; use std::hash::BuildHasherDefault; use std::sync::Arc; @@ -14,11 +15,28 @@ use crate::Realm; type FxOnceMap = OnceMap>; +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub(crate) enum FetchUrl { + /// A full index URL + Index(Url), + /// A realm URL + Realm(Realm), +} + +impl Display for FetchUrl { + fn fmt(&self, f: &mut Formatter) -> std::fmt::Result { + match self { + Self::Index(index) => Display::fmt(index, f), + Self::Realm(realm) => Display::fmt(realm, f), + } + } +} + pub struct CredentialsCache { /// A cache per realm and username realms: RwLock>>, /// A cache tracking the result of realm or index URL fetches from external services - pub(crate) fetches: FxOnceMap<(String, Username), Option>>, + pub(crate) fetches: FxOnceMap<(FetchUrl, Username), Option>>, /// A cache per URL, uses a trie for efficient prefix queries. urls: RwLock, } diff --git a/crates/uv-auth/src/index.rs b/crates/uv-auth/src/index.rs index b420c5ec33762..9419a9a227e42 100644 --- a/crates/uv-auth/src/index.rs +++ b/crates/uv-auth/src/index.rs @@ -60,6 +60,23 @@ pub struct Index { pub auth_policy: AuthPolicy, } +impl Index { + pub fn is_prefix_for(&self, url: &Url) -> bool { + if self.root_url.scheme() != url.scheme() + || self.root_url.host_str() != url.host_str() + || self.root_url.port_or_known_default() != url.port_or_known_default() + { + return false; + } + + url.path().starts_with(self.root_url.path()) + } +} + +// TODO(john): Multiple methods in this struct need to iterate over +// all the indexes in the set. There are probably not many URLs to +// iterate through, but we could use a trie instead of a HashSet here +// for more efficient search. #[derive(Debug, Default, Clone, Eq, PartialEq)] pub struct Indexes(FxHashSet); @@ -79,36 +96,17 @@ impl Indexes { /// Get the index URL prefix for a URL if one exists. pub fn index_url_for(&self, url: &Url) -> Option<&Url> { - // TODO(john): There are probably not many URLs to iterate through, - // but we could use a trie instead of a HashSet here for more - // efficient search. - self.0 - .iter() - .find(|index| is_url_prefix(&index.root_url, url)) - .map(|index| &index.url) + self.find_prefix_index(url).map(|index| &index.url) } /// Get the [`AuthPolicy`] for a URL. - pub fn policy_for(&self, url: &Url) -> AuthPolicy { - // TODO(john): There are probably not many URLs to iterate through, - // but we could use a trie instead of a HashMap here for more - // efficient search. - for index in &self.0 { - if is_url_prefix(&index.root_url, url) { - return index.auth_policy; - } - } - AuthPolicy::Auto + pub fn auth_policy_for(&self, url: &Url) -> AuthPolicy { + self.find_prefix_index(url) + .map(|index| index.auth_policy) + .unwrap_or(AuthPolicy::Auto) } -} -fn is_url_prefix(base: &Url, url: &Url) -> bool { - if base.scheme() != url.scheme() - || base.host_str() != url.host_str() - || base.port_or_known_default() != url.port_or_known_default() - { - return false; + fn find_prefix_index(&self, url: &Url) -> Option<&Index> { + self.0.iter().find(|&index| index.is_prefix_for(url)) } - - url.path().starts_with(base.path()) } diff --git a/crates/uv-auth/src/middleware.rs b/crates/uv-auth/src/middleware.rs index 544351b58f98a..cb9b2be2004ea 100644 --- a/crates/uv-auth/src/middleware.rs +++ b/crates/uv-auth/src/middleware.rs @@ -4,6 +4,7 @@ use http::{Extensions, StatusCode}; use url::Url; use crate::{ + cache::FetchUrl, credentials::{Credentials, Username}, index::{AuthPolicy, Indexes}, realm::Realm, @@ -182,7 +183,7 @@ impl Middleware for AuthMiddleware { // to the headers so for display purposes we restore some information let url = tracing_url(&request, request_credentials.as_ref()); let maybe_index_url = self.indexes.index_url_for(request.url()); - let auth_policy = self.indexes.policy_for(request.url()); + let auth_policy = self.indexes.auth_policy_for(request.url()); trace!("Handling request for {url} with authentication policy {auth_policy}"); let credentials: Option> = if matches!(auth_policy, AuthPolicy::Never) { @@ -384,7 +385,7 @@ impl AuthMiddleware { extensions: &mut Extensions, next: Next<'_>, url: &str, - maybe_index_url: Option<&Url>, + index_url: Option<&Url>, auth_policy: AuthPolicy, ) -> reqwest_middleware::Result { let credentials = Arc::new(credentials); @@ -402,7 +403,7 @@ impl AuthMiddleware { // There's just a username, try to find a password. // If we have an index URL, check the cache for that URL. Otherwise, // check for the realm. - let maybe_cached_credentials = if let Some(index_url) = maybe_index_url { + let maybe_cached_credentials = if let Some(index_url) = index_url { self.cache() .get_url(index_url, credentials.as_username().as_ref()) } else { @@ -426,17 +427,12 @@ impl AuthMiddleware { // Do not insert already-cached credentials None } else if let Some(credentials) = self - .fetch_credentials( - Some(&credentials), - request.url(), - maybe_index_url, - auth_policy, - ) + .fetch_credentials(Some(&credentials), request.url(), index_url, auth_policy) .await { request = credentials.authenticate(request); Some(credentials) - } else if maybe_index_url.is_some() { + } else if index_url.is_some() { // If this is a known index, we fall back to checking for the realm. self.cache() .get_realm(Realm::from(request.url()), credentials.to_username()) @@ -468,9 +464,9 @@ impl AuthMiddleware { // Fetches can be expensive, so we will only run them _once_ per realm or index URL and username combination // All other requests for the same realm or index URL will wait until the first one completes let key = if let Some(index_url) = maybe_index_url { - (index_url.to_string(), username) + (FetchUrl::Index(index_url.clone()), username) } else { - (Realm::from(url).to_string(), username) + (FetchUrl::Realm(Realm::from(url)), username) }; if !self.cache().fetches.register(key.clone()) { let credentials = self @@ -520,7 +516,7 @@ impl AuthMiddleware { debug!("Checking keyring for credentials for index URL {}@{}", username, index_url); keyring.fetch(index_url, Some(username)).await } else { - debug!("Checking keyring for credentials for full URL {}@{}", username, *url); + debug!("Checking keyring for credentials for full URL {}@{}", username, url); keyring.fetch(url, Some(username)).await } } else if matches!(auth_policy, AuthPolicy::Always) { @@ -530,10 +526,7 @@ impl AuthMiddleware { ); keyring.fetch(index_url, None).await } else { - debug!( - "Checking keyring for credentials for full URL {url} without username due to `authenticate = always`" - ); - keyring.fetch(url, None).await + None } } else { debug!("Skipping keyring fetch for {url} without username; use `authenticate = always` to force"); diff --git a/crates/uv-client/src/registry_client.rs b/crates/uv-client/src/registry_client.rs index d08bdb7e10e09..8bdf2c065261b 100644 --- a/crates/uv-client/src/registry_client.rs +++ b/crates/uv-client/src/registry_client.rs @@ -7,13 +7,13 @@ use std::time::Duration; use async_http_range_reader::AsyncHttpRangeReader; use futures::{FutureExt, StreamExt, TryStreamExt}; -use http::HeaderMap; +use http::{HeaderMap, StatusCode}; use itertools::Either; -use reqwest::{Proxy, Response, StatusCode}; +use reqwest::{Proxy, Response}; use reqwest_middleware::ClientWithMiddleware; use rustc_hash::FxHashMap; use tokio::sync::{Mutex, Semaphore}; -use tracing::{info_span, instrument, trace, warn, Instrument}; +use tracing::{debug, info_span, instrument, trace, warn, Instrument}; use url::Url; use uv_auth::Indexes; @@ -23,7 +23,7 @@ use uv_configuration::{IndexStrategy, TrustedHost}; use uv_distribution_filename::{DistFilename, SourceDistFilename, WheelFilename}; use uv_distribution_types::{ BuiltDist, File, FileLocation, IndexCapabilities, IndexFormat, IndexLocations, - IndexMetadataRef, IndexUrl, IndexUrls, Name, + IndexMetadataRef, IndexStatusCodeDecision, IndexStatusCodeStrategy, IndexUrl, IndexUrls, Name, }; use uv_metadata::{read_metadata_async_seek, read_metadata_async_stream}; use uv_normalize::PackageName; @@ -331,12 +331,29 @@ impl RegistryClient { let _permit = download_concurrency.acquire().await; match index.format { IndexFormat::Simple => { - if let Some(metadata) = self - .simple_single_index(package_name, index.url, capabilities) + let status_code_strategy = + self.index_urls.status_code_strategy_for(index.url); + match self + .simple_single_index( + package_name, + index.url, + capabilities, + &status_code_strategy, + ) .await? { - results.push((index.url, MetadataFormat::Simple(metadata))); - break; + SimpleMetadataSearchOutcome::Found(metadata) => { + results.push((index.url, MetadataFormat::Simple(metadata))); + break; + } + // Package not found, so we will continue on to the next index (if there is one) + SimpleMetadataSearchOutcome::NotFound => {} + // The search failed because of an HTTP status code that we don't ignore for + // this index. We end our search here. + SimpleMetadataSearchOutcome::StatusCodeFailure(status_code) => { + debug!("Indexes search failed because of status code failure: {status_code}"); + break; + } } } IndexFormat::Flat => { @@ -357,9 +374,21 @@ impl RegistryClient { let _permit = download_concurrency.acquire().await; match index.format { IndexFormat::Simple => { - let metadata = self - .simple_single_index(package_name, index.url, capabilities) - .await?; + // For unsafe matches, ignore authentication failures. + let status_code_strategy = + IndexStatusCodeStrategy::ignore_authentication_error_codes(); + let metadata = match self + .simple_single_index( + package_name, + index.url, + capabilities, + &status_code_strategy, + ) + .await? + { + SimpleMetadataSearchOutcome::Found(metadata) => Some(metadata), + _ => None, + }; Ok((index.url, metadata.map(MetadataFormat::Simple))) } IndexFormat::Flat => { @@ -438,14 +467,13 @@ impl RegistryClient { /// /// The index can either be a PEP 503-compatible remote repository, or a local directory laid /// out in the same format. - /// - /// Returns `Ok(None)` if the package is not found in the index. async fn simple_single_index( &self, package_name: &PackageName, index: &IndexUrl, capabilities: &IndexCapabilities, - ) -> Result>, Error> { + status_code_strategy: &IndexStatusCodeStrategy, + ) -> Result { // Format the URL for PyPI. let mut url = index.url().clone(); url.path_segments_mut() @@ -487,27 +515,31 @@ impl RegistryClient { }; match result { - Ok(metadata) => Ok(Some(metadata)), + Ok(metadata) => Ok(SimpleMetadataSearchOutcome::Found(metadata)), Err(err) => match err.into_kind() { // The package could not be found in the remote index. - ErrorKind::WrappedReqwestError(url, err) => match err.status() { - Some(StatusCode::NOT_FOUND) => Ok(None), - Some(StatusCode::UNAUTHORIZED) => { - capabilities.set_unauthorized(index.clone()); - Ok(None) - } - Some(StatusCode::FORBIDDEN) => { - capabilities.set_forbidden(index.clone()); - Ok(None) + ErrorKind::WrappedReqwestError(url, err) => { + let Some(status_code) = err.status() else { + return Err(ErrorKind::WrappedReqwestError(url, err).into()); + }; + let decision = + status_code_strategy.handle_status_code(status_code, index, capabilities); + if let IndexStatusCodeDecision::Fail(status_code) = decision { + if !matches!( + status_code, + StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN + ) { + return Err(ErrorKind::WrappedReqwestError(url, err).into()); + } } - _ => Err(ErrorKind::WrappedReqwestError(url, err).into()), - }, + Ok(SimpleMetadataSearchOutcome::from(decision)) + } // The package is unavailable due to a lack of connectivity. - ErrorKind::Offline(_) => Ok(None), + ErrorKind::Offline(_) => Ok(SimpleMetadataSearchOutcome::NotFound), // The package could not be found in the local index. - ErrorKind::FileNotFound(_) => Ok(None), + ErrorKind::FileNotFound(_) => Ok(SimpleMetadataSearchOutcome::NotFound), err => Err(err.into()), }, @@ -960,6 +992,26 @@ impl RegistryClient { } } +#[derive(Debug)] +pub(crate) enum SimpleMetadataSearchOutcome { + /// Simple metadata was found + Found(OwnedArchive), + /// Simple metadata was not found + NotFound, + /// A status code failure was encountered when searching for + /// simple metadata and our strategy did not ignore it + StatusCodeFailure(StatusCode), +} + +impl From for SimpleMetadataSearchOutcome { + fn from(item: IndexStatusCodeDecision) -> Self { + match item { + IndexStatusCodeDecision::Ignore => Self::NotFound, + IndexStatusCodeDecision::Fail(status_code) => Self::StatusCodeFailure(status_code), + } + } +} + /// A map from [`IndexUrl`] to [`FlatIndexEntry`] entries found at the given URL, indexed by /// [`PackageName`]. #[derive(Default, Debug, Clone)] diff --git a/crates/uv-distribution-types/Cargo.toml b/crates/uv-distribution-types/Cargo.toml index b77f5dd050850..91943937e1b2e 100644 --- a/crates/uv-distribution-types/Cargo.toml +++ b/crates/uv-distribution-types/Cargo.toml @@ -32,6 +32,7 @@ uv-small-str = { workspace = true } arcstr = { workspace = true } bitflags = { workspace = true } fs-err = { workspace = true } +http = { workspace = true } itertools = { workspace = true } jiff = { workspace = true } owo-colors = { workspace = true } @@ -47,7 +48,5 @@ tracing = { workspace = true } url = { workspace = true } version-ranges = { workspace = true } - [dev-dependencies] toml = { workspace = true } - diff --git a/crates/uv-distribution-types/src/index.rs b/crates/uv-distribution-types/src/index.rs index 77fc9f008e241..d3f974ad3c69d 100644 --- a/crates/uv-distribution-types/src/index.rs +++ b/crates/uv-distribution-types/src/index.rs @@ -1,6 +1,7 @@ use std::path::Path; use std::str::FromStr; +use serde::{Deserialize, Serialize}; use thiserror::Error; use url::Url; @@ -8,11 +9,9 @@ use uv_auth::{AuthPolicy, Credentials}; use crate::index_name::{IndexName, IndexNameError}; use crate::origin::Origin; -use crate::{IndexUrl, IndexUrlError}; +use crate::{IndexStatusCodeStrategy, IndexUrl, IndexUrlError, SerializableStatusCode}; -#[derive( - Debug, Clone, Hash, Eq, PartialEq, Ord, PartialOrd, serde::Serialize, serde::Deserialize, -)] +#[derive(Debug, Clone, Hash, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[serde(rename_all = "kebab-case")] pub struct Index { @@ -94,6 +93,17 @@ pub struct Index { /// ``` #[serde(default)] pub authenticate: AuthPolicy, + /// Status codes that uv should ignore when deciding whether + /// to continue searching in the next index after a failure. + /// + /// ```toml + /// [[tool.uv.index]] + /// name = "my-index" + /// url = "https:///simple" + /// ignore-error-codes = [401, 403] + /// ``` + #[serde(default)] + pub ignore_error_codes: Option>, } #[derive( @@ -131,6 +141,7 @@ impl Index { format: IndexFormat::Simple, publish_url: None, authenticate: AuthPolicy::default(), + ignore_error_codes: None, } } @@ -145,6 +156,7 @@ impl Index { format: IndexFormat::Simple, publish_url: None, authenticate: AuthPolicy::default(), + ignore_error_codes: None, } } @@ -159,6 +171,7 @@ impl Index { format: IndexFormat::Flat, publish_url: None, authenticate: AuthPolicy::default(), + ignore_error_codes: None, } } @@ -214,6 +227,15 @@ impl Index { } Ok(self) } + + /// Return the [`IndexStatusCodeStrategy`] for this index. + pub fn status_code_strategy(&self) -> IndexStatusCodeStrategy { + if let Some(ignore_error_codes) = &self.ignore_error_codes { + IndexStatusCodeStrategy::from_ignored_error_codes(ignore_error_codes) + } else { + IndexStatusCodeStrategy::from_index_url(self.url.url()) + } + } } impl From for Index { @@ -227,6 +249,7 @@ impl From for Index { format: IndexFormat::Simple, publish_url: None, authenticate: AuthPolicy::default(), + ignore_error_codes: None, } } } @@ -249,6 +272,7 @@ impl FromStr for Index { format: IndexFormat::Simple, publish_url: None, authenticate: AuthPolicy::default(), + ignore_error_codes: None, }); } } @@ -264,6 +288,7 @@ impl FromStr for Index { format: IndexFormat::Simple, publish_url: None, authenticate: AuthPolicy::default(), + ignore_error_codes: None, }) } } diff --git a/crates/uv-distribution-types/src/index_url.rs b/crates/uv-distribution-types/src/index_url.rs index fdb4ea1e5a6b4..e7f50cd189fef 100644 --- a/crates/uv-distribution-types/src/index_url.rs +++ b/crates/uv-distribution-types/src/index_url.rs @@ -12,7 +12,7 @@ use url::{ParseError, Url}; use uv_pep508::{split_scheme, Scheme, VerbatimUrl, VerbatimUrlError}; -use crate::{Index, Verbatim}; +use crate::{Index, IndexStatusCodeStrategy, Verbatim}; static PYPI_URL: LazyLock = LazyLock::new(|| Url::parse("https://pypi.org/simple").unwrap()); @@ -536,6 +536,16 @@ impl<'a> IndexUrls { pub fn no_index(&self) -> bool { self.no_index } + + /// Return the [`IndexStatusCodeStrategy`] for an [`IndexUrl`]. + pub fn status_code_strategy_for(&self, url: &IndexUrl) -> IndexStatusCodeStrategy { + for index in &self.indexes { + if index.url() == url { + return index.status_code_strategy(); + } + } + IndexStatusCodeStrategy::Default + } } bitflags::bitflags! { diff --git a/crates/uv-distribution-types/src/lib.rs b/crates/uv-distribution-types/src/lib.rs index 2732d1fdb0123..c062e8b01625d 100644 --- a/crates/uv-distribution-types/src/lib.rs +++ b/crates/uv-distribution-types/src/lib.rs @@ -75,6 +75,7 @@ pub use crate::requirement::*; pub use crate::resolution::*; pub use crate::resolved::*; pub use crate::specified_requirement::*; +pub use crate::status_code_strategy::*; pub use crate::traits::*; mod annotation; @@ -101,6 +102,7 @@ mod requirement; mod resolution; mod resolved; mod specified_requirement; +mod status_code_strategy; mod traits; #[derive(Debug, Clone)] diff --git a/crates/uv-distribution-types/src/status_code_strategy.rs b/crates/uv-distribution-types/src/status_code_strategy.rs new file mode 100644 index 0000000000000..758fb001fcaf4 --- /dev/null +++ b/crates/uv-distribution-types/src/status_code_strategy.rs @@ -0,0 +1,291 @@ +use std::ops::Deref; + +use http::StatusCode; +use rustc_hash::FxHashSet; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use url::Url; + +use crate::{IndexCapabilities, IndexUrl}; + +#[derive(Debug, Clone, Default, Eq, PartialEq)] +pub enum IndexStatusCodeStrategy { + #[default] + Default, + IgnoreErrorCodes { + status_codes: FxHashSet, + }, +} + +impl IndexStatusCodeStrategy { + /// Derive a strategy from an index URL. We special-case PyTorch. Otherwise, + /// we follow the default strategy. + pub fn from_index_url(url: &Url) -> Self { + if url + .host_str() + .is_some_and(|host| host.ends_with("pytorch.org")) + { + // The PyTorch registry returns a 403 when a package is not found, so + // we ignore them when deciding whether to search other indexes. + Self::IgnoreErrorCodes { + status_codes: FxHashSet::from_iter([StatusCode::FORBIDDEN]), + } + } else { + Self::Default + } + } + + /// Derive a strategy from a list of status codes to ignore. + pub fn from_ignored_error_codes(status_codes: &[SerializableStatusCode]) -> Self { + Self::IgnoreErrorCodes { + status_codes: status_codes + .iter() + .map(SerializableStatusCode::deref) + .copied() + .collect::>(), + } + } + + /// Derive a strategy for ignoring authentication error codes. + pub fn ignore_authentication_error_codes() -> Self { + Self::IgnoreErrorCodes { + status_codes: FxHashSet::from_iter([ + StatusCode::UNAUTHORIZED, + StatusCode::FORBIDDEN, + StatusCode::NETWORK_AUTHENTICATION_REQUIRED, + StatusCode::PROXY_AUTHENTICATION_REQUIRED, + ]), + } + } + + /// Based on the strategy, decide whether to continue searching the next index + /// based on the status code returned by this one. + pub fn handle_status_code( + &self, + status_code: StatusCode, + index_url: &IndexUrl, + capabilities: &IndexCapabilities, + ) -> IndexStatusCodeDecision { + match self { + IndexStatusCodeStrategy::Default => match status_code { + StatusCode::NOT_FOUND => IndexStatusCodeDecision::Ignore, + StatusCode::UNAUTHORIZED => { + capabilities.set_unauthorized(index_url.clone()); + IndexStatusCodeDecision::Fail(status_code) + } + StatusCode::FORBIDDEN => { + capabilities.set_forbidden(index_url.clone()); + IndexStatusCodeDecision::Fail(status_code) + } + _ => IndexStatusCodeDecision::Fail(status_code), + }, + IndexStatusCodeStrategy::IgnoreErrorCodes { status_codes } => { + if status_codes.contains(&status_code) { + IndexStatusCodeDecision::Ignore + } else { + IndexStatusCodeStrategy::Default.handle_status_code( + status_code, + index_url, + capabilities, + ) + } + } + } + } +} + +/// Decision on whether to continue searching the next index. +#[derive(Debug, Clone, Copy, Hash, Eq, PartialEq)] +pub enum IndexStatusCodeDecision { + Ignore, + Fail(StatusCode), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct SerializableStatusCode(StatusCode); + +impl Deref for SerializableStatusCode { + type Target = StatusCode; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl Serialize for SerializableStatusCode { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_u16(self.0.as_u16()) + } +} + +impl<'de> Deserialize<'de> for SerializableStatusCode { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let code = u16::deserialize(deserializer)?; + StatusCode::from_u16(code) + .map(SerializableStatusCode) + .map_err(|_| { + serde::de::Error::custom(format!("{code} is not a valid HTTP status code")) + }) + } +} + +#[cfg(feature = "schemars")] +impl schemars::JsonSchema for SerializableStatusCode { + fn schema_name() -> String { + "StatusCode".to_string() + } + + fn json_schema(gen: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema { + let mut schema = gen.subschema_for::().into_object(); + schema.metadata().description = Some("HTTP status code (100-599)".to_string()); + schema.number().minimum = Some(100.0); + schema.number().maximum = Some(599.0); + + schema.into() + } +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use url::Url; + + use super::*; + + #[test] + fn test_strategy_normal_registry() { + let url = Url::from_str("https://internal-registry.com/simple").unwrap(); + assert_eq!( + IndexStatusCodeStrategy::from_index_url(&url), + IndexStatusCodeStrategy::Default + ); + } + + #[test] + fn test_strategy_pytorch_registry() { + let status_codes = std::iter::once(StatusCode::FORBIDDEN).collect::>(); + let url = Url::from_str("https://download.pytorch.org/whl/cu118").unwrap(); + assert_eq!( + IndexStatusCodeStrategy::from_index_url(&url), + IndexStatusCodeStrategy::IgnoreErrorCodes { status_codes } + ); + } + + #[test] + fn test_strategy_custom_error_codes() { + let status_codes = FxHashSet::from_iter([StatusCode::UNAUTHORIZED, StatusCode::FORBIDDEN]); + let serializable_status_codes = status_codes + .iter() + .map(|code| SerializableStatusCode(*code)) + .collect::>(); + assert_eq!( + IndexStatusCodeStrategy::from_ignored_error_codes(&serializable_status_codes), + IndexStatusCodeStrategy::IgnoreErrorCodes { status_codes } + ); + } + + #[test] + fn test_decision_default_400() { + let strategy = IndexStatusCodeStrategy::Default; + let status_code = StatusCode::BAD_REQUEST; + let index_url = IndexUrl::parse("https://internal-registry.com/simple", None).unwrap(); + let capabilities = IndexCapabilities::default(); + let decision = strategy.handle_status_code(status_code, &index_url, &capabilities); + assert_eq!( + decision, + IndexStatusCodeDecision::Fail(StatusCode::BAD_REQUEST) + ); + } + + #[test] + fn test_decision_default_401() { + let strategy = IndexStatusCodeStrategy::Default; + let status_code = StatusCode::UNAUTHORIZED; + let index_url = IndexUrl::parse("https://internal-registry.com/simple", None).unwrap(); + let capabilities = IndexCapabilities::default(); + let decision = strategy.handle_status_code(status_code, &index_url, &capabilities); + assert_eq!( + decision, + IndexStatusCodeDecision::Fail(StatusCode::UNAUTHORIZED) + ); + assert!(capabilities.unauthorized(&index_url)); + assert!(!capabilities.forbidden(&index_url)); + } + + #[test] + fn test_decision_default_403() { + let strategy = IndexStatusCodeStrategy::Default; + let status_code = StatusCode::FORBIDDEN; + let index_url = IndexUrl::parse("https://internal-registry.com/simple", None).unwrap(); + let capabilities = IndexCapabilities::default(); + let decision = strategy.handle_status_code(status_code, &index_url, &capabilities); + assert_eq!( + decision, + IndexStatusCodeDecision::Fail(StatusCode::FORBIDDEN) + ); + assert!(capabilities.forbidden(&index_url)); + assert!(!capabilities.unauthorized(&index_url)); + } + + #[test] + fn test_decision_default_404() { + let strategy = IndexStatusCodeStrategy::Default; + let status_code = StatusCode::NOT_FOUND; + let index_url = IndexUrl::parse("https://internal-registry.com/simple", None).unwrap(); + let capabilities = IndexCapabilities::default(); + let decision = strategy.handle_status_code(status_code, &index_url, &capabilities); + assert_eq!(decision, IndexStatusCodeDecision::Ignore); + assert!(!capabilities.forbidden(&index_url)); + assert!(!capabilities.unauthorized(&index_url)); + } + + #[test] + fn test_decision_pytorch() { + let index_url = IndexUrl::parse("https://download.pytorch.org/whl/cu118", None).unwrap(); + let strategy = IndexStatusCodeStrategy::from_index_url(&index_url); + let capabilities = IndexCapabilities::default(); + // Test we continue on 403 for PyTorch registry. + let status_code = StatusCode::FORBIDDEN; + let decision = strategy.handle_status_code(status_code, &index_url, &capabilities); + assert_eq!(decision, IndexStatusCodeDecision::Ignore); + // Test we stop on 401 for PyTorch registry. + let status_code = StatusCode::UNAUTHORIZED; + let decision = strategy.handle_status_code(status_code, &index_url, &capabilities); + assert_eq!( + decision, + IndexStatusCodeDecision::Fail(StatusCode::UNAUTHORIZED) + ); + } + + #[test] + fn test_decision_multiple_ignored_status_codes() { + let status_codes = vec![ + StatusCode::UNAUTHORIZED, + StatusCode::BAD_GATEWAY, + StatusCode::SERVICE_UNAVAILABLE, + ]; + let strategy = IndexStatusCodeStrategy::IgnoreErrorCodes { + status_codes: status_codes.iter().copied().collect::>(), + }; + let index_url = IndexUrl::parse("https://internal-registry.com/simple", None).unwrap(); + let capabilities = IndexCapabilities::default(); + // Test each ignored status code + for status_code in status_codes { + let decision = strategy.handle_status_code(status_code, &index_url, &capabilities); + assert_eq!(decision, IndexStatusCodeDecision::Ignore); + } + // Test a status code that's not ignored + let other_status_code = StatusCode::FORBIDDEN; + let decision = strategy.handle_status_code(other_status_code, &index_url, &capabilities); + assert_eq!( + decision, + IndexStatusCodeDecision::Fail(StatusCode::FORBIDDEN) + ); + } +} diff --git a/crates/uv-resolver/src/pubgrub/report.rs b/crates/uv-resolver/src/pubgrub/report.rs index 06f3e8d4a439f..5247f5ca374e8 100644 --- a/crates/uv-resolver/src/pubgrub/report.rs +++ b/crates/uv-resolver/src/pubgrub/report.rs @@ -930,12 +930,6 @@ impl PubGrubReportFormatter<'_> { }); } if index_capabilities.forbidden(&index.url) { - // If the index is a PyTorch index (e.g., `https://download.pytorch.org/whl/cu118`), - // avoid noting the lack of credentials. PyTorch returns a 403 (Forbidden) status - // code for any package that does not exist. - if index.url.url().host_str() == Some("download.pytorch.org") { - continue; - } hints.insert(PubGrubHint::ForbiddenIndex { index: index.url.clone(), }); diff --git a/crates/uv/tests/it/edit.rs b/crates/uv/tests/it/edit.rs index a101e18120b66..4e9923cb680ba 100644 --- a/crates/uv/tests/it/edit.rs +++ b/crates/uv/tests/it/edit.rs @@ -15,6 +15,7 @@ use indoc::{formatdoc, indoc}; use insta::assert_snapshot; use std::path::Path; use uv_fs::Simplified; +use wiremock::{matchers::method, Mock, MockServer, ResponseTemplate}; use uv_static::EnvVars; @@ -10533,10 +10534,8 @@ fn add_index_url_in_keyring() -> Result<()> { version = "1.0.0" requires-python = ">=3.11, <4" dependencies = [] - [tool.uv] keyring-provider = "subprocess" - [[tool.uv.index]] name = "proxy" url = "https://pypi-proxy.fly.dev/basic-auth/simple" @@ -10593,10 +10592,8 @@ fn add_full_url_in_keyring() -> Result<()> { version = "1.0.0" requires-python = ">=3.11, <4" dependencies = [] - [tool.uv] keyring-provider = "subprocess" - [[tool.uv.index]] name = "proxy" url = "https://pypi-proxy.fly.dev/basic-auth/simple" @@ -10625,6 +10622,244 @@ fn add_full_url_in_keyring() -> Result<()> { Ok(()) } +/// If uv receives an authentication failure from a configured index, it +/// should not fall back to the default index. +#[test] +fn add_stop_index_search_early_on_auth_failure() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(indoc! { r#" + [project] + name = "foo" + version = "1.0.0" + requires-python = ">=3.11, <4" + dependencies = [] + [[tool.uv.index]] + name = "my-index" + url = "https://pypi-proxy.fly.dev/basic-auth/simple" + "# + })?; + + uv_snapshot!(context.add().arg("anyio"), @r" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + × No solution found when resolving dependencies: + ╰─▶ Because anyio was not found in the package registry and your project depends on anyio, we can conclude that your project's requirements are unsatisfiable. + + hint: An index URL (https://pypi-proxy.fly.dev/basic-auth/simple) could not be queried due to a lack of valid authentication credentials (401 Unauthorized). + help: If you want to add the package regardless of the failed resolution, provide the `--frozen` flag to skip locking and syncing. + " + ); + Ok(()) +} + +/// uv should continue searching the default index if it receives an +/// authentication failure that is specified in `ignore-error-codes`. +#[test] +fn add_ignore_error_codes() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(indoc! { r#" + [project] + name = "foo" + version = "1.0.0" + requires-python = ">=3.11, <4" + dependencies = [] + [[tool.uv.index]] + name = "my-index" + url = "https://pypi-proxy.fly.dev/basic-auth/simple" + ignore-error-codes = [401, 403] + "# + })?; + + uv_snapshot!(context.add().arg("anyio"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 4 packages in [TIME] + Prepared 3 packages in [TIME] + Installed 3 packages in [TIME] + + anyio==4.3.0 + + idna==3.6 + + sniffio==1.3.1 + " + ); + + context.assert_command("import anyio").success(); + Ok(()) +} + +/// uv should only fall through on 404s if an empty list is specified +/// in `ignore-error-codes`, even for pytorch. +#[test] +fn add_empty_ignore_error_codes() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(indoc! { r#" + [project] + name = "foo" + version = "1.0.0" + requires-python = ">=3.11, <4" + dependencies = [] + + [tool.uv.sources] + jinja2 = { index = "pytorch" } + + [[tool.uv.index]] + name = "pytorch" + url = "https://download.pytorch.org/whl/cpu" + ignore-error-codes = [] + "# + })?; + + // The default behavior of ignoring pytorch 403s has been overridden + // by the empty ignore-error-codes list. + uv_snapshot!(context.add().arg("flask"), @r" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + × No solution found when resolving dependencies: + ╰─▶ Because flask was not found in the package registry and your project depends on flask, we can conclude that your project's requirements are unsatisfiable. + + hint: An index URL (https://download.pytorch.org/whl/cpu) could not be queried due to a lack of valid authentication credentials (403 Forbidden). + help: If you want to add the package regardless of the failed resolution, provide the `--frozen` flag to skip locking and syncing. + " + ); + Ok(()) +} + +/// uv should not report a credential error on a missing package for pytorch since +/// pytorch returns 403s to indicate not found. +#[test] +fn add_missing_package_on_pytorch() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(indoc! { r#" + [project] + name = "foo" + version = "1.0.0" + requires-python = ">=3.11, <4" + dependencies = [] + + [tool.uv.sources] + fakepkg = { index = "pytorch" } + + [[tool.uv.index]] + name = "pytorch" + url = "https://download.pytorch.org/whl/cpu" + "# + })?; + + uv_snapshot!(context.add().arg("fakepkg"), @r" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + × No solution found when resolving dependencies: + ╰─▶ Because fakepkg was not found in the package registry and your project depends on fakepkg, we can conclude that your project's requirements are unsatisfiable. + help: If you want to add the package regardless of the failed resolution, provide the `--frozen` flag to skip locking and syncing. + " + ); + Ok(()) +} + +/// Test HTTP errors other than 401s and 403s. +#[tokio::test] +async fn add_unexpected_error_code() -> Result<()> { + let context = TestContext::new("3.12"); + let filters = context + .filters() + .into_iter() + .chain([(r"127\.0\.0\.1(?::\d+)?", "[LOCALHOST]")]) + .collect::>(); + + let server = MockServer::start().await; + + Mock::given(method("GET")) + .respond_with(ResponseTemplate::new(503)) + .mount(&server) + .await; + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(indoc! { r#" + [project] + name = "foo" + version = "1.0.0" + requires-python = ">=3.11, <4" + dependencies = [] + "# + })?; + + uv_snapshot!(filters, context.add().arg("anyio").arg("--index").arg(server.uri()), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Failed to fetch: `http://[LOCALHOST]/anyio/` + Caused by: HTTP status server error (503 Service Unavailable) for url (http://[LOCALHOST]/anyio/) + " + ); + Ok(()) +} + +/// uv should fail to parse `pyproject.toml` if `ignore-error-codes` +/// contains an invalid status code number. +#[test] +fn add_invalid_ignore_error_code() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(indoc! { r#" + [project] + name = "foo" + version = "1.0.0" + requires-python = ">=3.11, <4" + dependencies = [] + [[tool.uv.index]] + name = "my-index" + url = "https://pypi-proxy.fly.dev/basic-auth/simple" + ignore-error-codes = [401, 403, 1234] + "# + })?; + + uv_snapshot!(context.add().arg("anyio"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + warning: Failed to parse `pyproject.toml` during settings discovery: + TOML parse error at line 9, column 22 + | + 9 | ignore-error-codes = [401, 403, 1234] + | ^^^^^^^^^^^^^^^^ + 1234 is not a valid HTTP status code + + error: Failed to parse: `pyproject.toml` + Caused by: TOML parse error at line 9, column 22 + | + 9 | ignore-error-codes = [401, 403, 1234] + | ^^^^^^^^^^^^^^^^ + 1234 is not a valid HTTP status code + " + ); + + Ok(()) +} + /// In authentication "always", the normal authentication flow should still work. #[test] fn add_auth_policy_always_with_credentials() -> Result<()> { diff --git a/crates/uv/tests/it/lock.rs b/crates/uv/tests/it/lock.rs index fb88ebc6d521e..faab528a55efc 100644 --- a/crates/uv/tests/it/lock.rs +++ b/crates/uv/tests/it/lock.rs @@ -7517,7 +7517,7 @@ fn lock_index_workspace_member() -> Result<()> { )?; // Locking without the necessary credentials should fail. - uv_snapshot!(context.filters(), context.lock(), @r###" + uv_snapshot!(context.filters(), context.lock(), @r" success: false exit_code: 1 ----- stdout ----- @@ -7526,7 +7526,7 @@ fn lock_index_workspace_member() -> Result<()> { × No solution found when resolving dependencies: ╰─▶ Because iniconfig was not found in the package registry and child depends on iniconfig>=2, we can conclude that child's requirements are unsatisfiable. And because your workspace requires child, we can conclude that your workspace's requirements are unsatisfiable. - "###); + "); uv_snapshot!(context.filters(), context.lock() .env("UV_INDEX_MY_INDEX_USERNAME", "public") @@ -8459,7 +8459,7 @@ fn lock_env_credentials() -> Result<()> { )?; // Without credentials, the resolution should fail. - uv_snapshot!(context.filters(), context.lock(), @r###" + uv_snapshot!(context.filters(), context.lock(), @r" success: false exit_code: 1 ----- stdout ----- @@ -8469,7 +8469,7 @@ fn lock_env_credentials() -> Result<()> { ╰─▶ Because iniconfig was not found in the package registry and your project depends on iniconfig, we can conclude that your project's requirements are unsatisfiable. hint: An index URL (https://pypi-proxy.fly.dev/basic-auth/simple) could not be queried due to a lack of valid authentication credentials (401 Unauthorized). - "###); + "); // Provide credentials via environment variables. uv_snapshot!(context.filters(), context.lock() diff --git a/crates/uv/tests/it/show_settings.rs b/crates/uv/tests/it/show_settings.rs index 55da48df55c90..cf7c1c7a2854e 100644 --- a/crates/uv/tests/it/show_settings.rs +++ b/crates/uv/tests/it/show_settings.rs @@ -138,6 +138,7 @@ fn resolve_uv_toml() -> anyhow::Result<()> { format: Simple, publish_url: None, authenticate: Auto, + ignore_error_codes: None, }, ], flat_index: [], @@ -299,6 +300,7 @@ fn resolve_uv_toml() -> anyhow::Result<()> { format: Simple, publish_url: None, authenticate: Auto, + ignore_error_codes: None, }, ], flat_index: [], @@ -461,6 +463,7 @@ fn resolve_uv_toml() -> anyhow::Result<()> { format: Simple, publish_url: None, authenticate: Auto, + ignore_error_codes: None, }, ], flat_index: [], @@ -655,6 +658,7 @@ fn resolve_pyproject_toml() -> anyhow::Result<()> { format: Simple, publish_url: None, authenticate: Auto, + ignore_error_codes: None, }, ], flat_index: [], @@ -959,6 +963,7 @@ fn resolve_pyproject_toml() -> anyhow::Result<()> { format: Simple, publish_url: None, authenticate: Auto, + ignore_error_codes: None, }, ], flat_index: [], @@ -1147,6 +1152,7 @@ fn resolve_index_url() -> anyhow::Result<()> { format: Simple, publish_url: None, authenticate: Auto, + ignore_error_codes: None, }, Index { name: None, @@ -1178,6 +1184,7 @@ fn resolve_index_url() -> anyhow::Result<()> { format: Simple, publish_url: None, authenticate: Auto, + ignore_error_codes: None, }, ], flat_index: [], @@ -1343,6 +1350,7 @@ fn resolve_index_url() -> anyhow::Result<()> { format: Simple, publish_url: None, authenticate: Auto, + ignore_error_codes: None, }, Index { name: None, @@ -1374,6 +1382,7 @@ fn resolve_index_url() -> anyhow::Result<()> { format: Simple, publish_url: None, authenticate: Auto, + ignore_error_codes: None, }, Index { name: None, @@ -1405,6 +1414,7 @@ fn resolve_index_url() -> anyhow::Result<()> { format: Simple, publish_url: None, authenticate: Auto, + ignore_error_codes: None, }, ], flat_index: [], @@ -1592,6 +1602,7 @@ fn resolve_find_links() -> anyhow::Result<()> { format: Flat, publish_url: None, authenticate: Auto, + ignore_error_codes: None, }, ], no_index: true, @@ -1922,6 +1933,7 @@ fn resolve_top_level() -> anyhow::Result<()> { format: Simple, publish_url: None, authenticate: Auto, + ignore_error_codes: None, }, Index { name: None, @@ -1953,6 +1965,7 @@ fn resolve_top_level() -> anyhow::Result<()> { format: Simple, publish_url: None, authenticate: Auto, + ignore_error_codes: None, }, ], flat_index: [], @@ -2114,6 +2127,7 @@ fn resolve_top_level() -> anyhow::Result<()> { format: Simple, publish_url: None, authenticate: Auto, + ignore_error_codes: None, }, Index { name: None, @@ -2145,6 +2159,7 @@ fn resolve_top_level() -> anyhow::Result<()> { format: Simple, publish_url: None, authenticate: Auto, + ignore_error_codes: None, }, ], flat_index: [], @@ -3224,6 +3239,7 @@ fn resolve_both() -> anyhow::Result<()> { format: Simple, publish_url: None, authenticate: Auto, + ignore_error_codes: None, }, ], flat_index: [], @@ -3510,6 +3526,7 @@ fn resolve_config_file() -> anyhow::Result<()> { format: Simple, publish_url: None, authenticate: Auto, + ignore_error_codes: None, }, ], flat_index: [], @@ -4221,6 +4238,7 @@ fn index_priority() -> anyhow::Result<()> { format: Simple, publish_url: None, authenticate: Auto, + ignore_error_codes: None, }, Index { name: None, @@ -4252,6 +4270,7 @@ fn index_priority() -> anyhow::Result<()> { format: Simple, publish_url: None, authenticate: Auto, + ignore_error_codes: None, }, ], flat_index: [], @@ -4415,6 +4434,7 @@ fn index_priority() -> anyhow::Result<()> { format: Simple, publish_url: None, authenticate: Auto, + ignore_error_codes: None, }, Index { name: None, @@ -4446,6 +4466,7 @@ fn index_priority() -> anyhow::Result<()> { format: Simple, publish_url: None, authenticate: Auto, + ignore_error_codes: None, }, ], flat_index: [], @@ -4615,6 +4636,7 @@ fn index_priority() -> anyhow::Result<()> { format: Simple, publish_url: None, authenticate: Auto, + ignore_error_codes: None, }, Index { name: None, @@ -4646,6 +4668,7 @@ fn index_priority() -> anyhow::Result<()> { format: Simple, publish_url: None, authenticate: Auto, + ignore_error_codes: None, }, ], flat_index: [], @@ -4810,6 +4833,7 @@ fn index_priority() -> anyhow::Result<()> { format: Simple, publish_url: None, authenticate: Auto, + ignore_error_codes: None, }, Index { name: None, @@ -4841,6 +4865,7 @@ fn index_priority() -> anyhow::Result<()> { format: Simple, publish_url: None, authenticate: Auto, + ignore_error_codes: None, }, ], flat_index: [], @@ -5012,6 +5037,7 @@ fn index_priority() -> anyhow::Result<()> { format: Simple, publish_url: None, authenticate: Auto, + ignore_error_codes: None, }, Index { name: None, @@ -5043,6 +5069,7 @@ fn index_priority() -> anyhow::Result<()> { format: Simple, publish_url: None, authenticate: Auto, + ignore_error_codes: None, }, ], flat_index: [], @@ -5207,6 +5234,7 @@ fn index_priority() -> anyhow::Result<()> { format: Simple, publish_url: None, authenticate: Auto, + ignore_error_codes: None, }, Index { name: None, @@ -5238,6 +5266,7 @@ fn index_priority() -> anyhow::Result<()> { format: Simple, publish_url: None, authenticate: Auto, + ignore_error_codes: None, }, ], flat_index: [], diff --git a/docs/configuration/indexes.md b/docs/configuration/indexes.md index a4e66ea6e26d0..b740082bea8ac 100644 --- a/docs/configuration/indexes.md +++ b/docs/configuration/indexes.md @@ -122,7 +122,7 @@ malicious package to be installed instead of the internal package. See, for exam [the `torchtriton` attack](https://pytorch.org/blog/compromised-nightly-dependency/) from December 2022. -Users can opt in to alternate index behaviors via the`--index-strategy` command-line option, or the +To opt in to alternate index behaviors, use the`--index-strategy` command-line option, or the `UV_INDEX_STRATEGY` environment variable, which supports the following values: - `first-index` (default): Search for each package across all indexes, limiting the candidate @@ -211,6 +211,27 @@ credentials cannot be found. If the discovered credentials are not valid (i.e., HTTP 401 or 403), then uv will treat packages as unavailable and query the next configured index as described in the [index strategy](#searching-across-multiple-indexes) section. +### Ignoring error codes when searching across indexes + +When using the [first-index strategy](#searching-across-multiple-indexes), uv will stop searching +across indexes if an HTTP 401 Unauthorized or HTTP 403 Forbidden status code is encountered. The one +exception is that uv will ignore 403s when searching the `pytorch` index (since this index returns a +403 when a package is not present). + +To configure which error codes are ignored for an index, use the `ignored-error-codes` setting. For +example, to ignore 403s (but not 401s) for a private index: + +```toml +[[tool.uv.index]] +name = "private-index" +url = "https://private-index.com/simple" +authenticate = "always" +ignore-error-codes = [403] +``` + +uv will always continue searching across indexes when it encounters a `404 Not Found`. This cannot +be overridden. + ### Disabling authentication To prevent leaking credentials, authentication can be disabled for an index: diff --git a/uv.schema.json b/uv.schema.json index aab9c9e455022..ee2b2c1e186ee 100644 --- a/uv.schema.json +++ b/uv.schema.json @@ -870,6 +870,17 @@ } ] }, + "ignore-error-codes": { + "description": "Status codes that uv should ignore when deciding whether to continue searching in the next index after a failure.\n\n```toml [[tool.uv.index]] name = \"my-index\" url = \"https:///simple\" ignore-error-codes = [401, 403] ```", + "default": null, + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/StatusCode" + } + }, "name": { "description": "The name of the index.\n\nIndex names can be used to reference indexes elsewhere in the configuration. For example, you can pin a package to a specific index by name:\n\n```toml [[tool.uv.index]] name = \"pytorch\" url = \"https://download.pytorch.org/whl/cu121\"\n\n[tool.uv.sources] torch = { index = \"pytorch\" } ```", "anyOf": [ @@ -2005,6 +2016,13 @@ } } }, + "StatusCode": { + "description": "HTTP status code (100-599)", + "type": "integer", + "format": "uint16", + "maximum": 599.0, + "minimum": 100.0 + }, "String": { "type": "string" },