Skip to main content

uv_distribution_types/
index.rs

1use std::path::Path;
2use std::str::FromStr;
3
4use serde::{Deserialize, Serialize};
5use thiserror::Error;
6use url::Url;
7
8use uv_auth::{AuthPolicy, Credentials};
9use uv_redacted::DisplaySafeUrl;
10use uv_small_str::SmallString;
11
12use crate::index_name::{IndexName, IndexNameError};
13use crate::origin::Origin;
14use crate::{IndexStatusCodeStrategy, IndexUrl, IndexUrlError, SerializableStatusCode};
15
16/// Cache control configuration for an index.
17#[derive(Debug, Clone, Hash, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize, Default)]
18#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
19#[serde(rename_all = "kebab-case")]
20pub struct IndexCacheControl {
21    /// Cache control header for Simple API requests.
22    pub api: Option<SmallString>,
23    /// Cache control header for file downloads.
24    pub files: Option<SmallString>,
25}
26
27impl IndexCacheControl {
28    /// Return the default Simple API cache control headers for the given index URL, if applicable.
29    pub fn simple_api_cache_control(_url: &Url) -> Option<&'static str> {
30        None
31    }
32
33    /// Return the default files cache control headers for the given index URL, if applicable.
34    pub fn artifact_cache_control(url: &Url) -> Option<&'static str> {
35        let dominated_by_pytorch_or_nvidia = url.host_str().is_some_and(|host| {
36            host.eq_ignore_ascii_case("download.pytorch.org")
37                || host.eq_ignore_ascii_case("pypi.nvidia.com")
38        });
39        if dominated_by_pytorch_or_nvidia {
40            // Some wheels in the PyTorch registry were accidentally uploaded with `no-cache,no-store,must-revalidate`.
41            // The PyTorch team plans to correct this in the future, but in the meantime we override
42            // the cache control headers to allow caching of static files.
43            //
44            // See: https://github.com/pytorch/pytorch/pull/149218
45            //
46            // The same issue applies to files hosted on `pypi.nvidia.com`.
47            Some("max-age=365000000, immutable, public")
48        } else {
49            None
50        }
51    }
52}
53
54#[derive(Debug, Clone, Serialize)]
55#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
56#[serde(rename_all = "kebab-case")]
57pub struct Index {
58    /// The name of the index.
59    ///
60    /// Index names can be used to reference indexes elsewhere in the configuration. For example,
61    /// you can pin a package to a specific index by name:
62    ///
63    /// ```toml
64    /// [[tool.uv.index]]
65    /// name = "pytorch"
66    /// url = "https://download.pytorch.org/whl/cu121"
67    ///
68    /// [tool.uv.sources]
69    /// torch = { index = "pytorch" }
70    /// ```
71    pub name: Option<IndexName>,
72    /// The URL of the index.
73    ///
74    /// Expects to receive a URL (e.g., `https://pypi.org/simple`) or a local path.
75    pub url: IndexUrl,
76    /// Mark the index as explicit.
77    ///
78    /// Explicit indexes will _only_ be used when explicitly requested via a `[tool.uv.sources]`
79    /// definition, as in:
80    ///
81    /// ```toml
82    /// [[tool.uv.index]]
83    /// name = "pytorch"
84    /// url = "https://download.pytorch.org/whl/cu121"
85    /// explicit = true
86    ///
87    /// [tool.uv.sources]
88    /// torch = { index = "pytorch" }
89    /// ```
90    #[serde(default)]
91    pub explicit: bool,
92    /// Mark the index as the default index.
93    ///
94    /// By default, uv uses PyPI as the default index, such that even if additional indexes are
95    /// defined via `[[tool.uv.index]]`, PyPI will still be used as a fallback for packages that
96    /// aren't found elsewhere. To disable the PyPI default, set `default = true` on at least one
97    /// other index.
98    ///
99    /// Marking an index as default will move it to the front of the list of indexes, such that it
100    /// is given the highest priority when resolving packages.
101    #[serde(default)]
102    pub default: bool,
103    /// The origin of the index (e.g., a CLI flag, a user-level configuration file, etc.).
104    #[serde(skip)]
105    pub origin: Option<Origin>,
106    /// The format used by the index.
107    ///
108    /// Indexes can either be PEP 503-compliant (i.e., a PyPI-style registry implementing the Simple
109    /// API) or structured as a flat list of distributions (e.g., `--find-links`). In both cases,
110    /// indexes can point to either local or remote resources.
111    #[serde(default)]
112    pub format: IndexFormat,
113    /// The URL of the upload endpoint.
114    ///
115    /// When using `uv publish --index <name>`, this URL is used for publishing.
116    ///
117    /// A configuration for the default index PyPI would look as follows:
118    ///
119    /// ```toml
120    /// [[tool.uv.index]]
121    /// name = "pypi"
122    /// url = "https://pypi.org/simple"
123    /// publish-url = "https://upload.pypi.org/legacy/"
124    /// ```
125    pub publish_url: Option<DisplaySafeUrl>,
126    /// When uv should use authentication for requests to the index.
127    ///
128    /// ```toml
129    /// [[tool.uv.index]]
130    /// name = "my-index"
131    /// url = "https://<omitted>/simple"
132    /// authenticate = "always"
133    /// ```
134    #[serde(default)]
135    pub authenticate: AuthPolicy,
136    /// Status codes that uv should ignore when deciding whether
137    /// to continue searching in the next index after a failure.
138    ///
139    /// ```toml
140    /// [[tool.uv.index]]
141    /// name = "my-index"
142    /// url = "https://<omitted>/simple"
143    /// ignore-error-codes = [401, 403]
144    /// ```
145    #[serde(default)]
146    pub ignore_error_codes: Option<Vec<SerializableStatusCode>>,
147    /// Cache control configuration for this index.
148    ///
149    /// When set, these headers will override the server's cache control headers
150    /// for both package metadata requests and artifact downloads.
151    ///
152    /// ```toml
153    /// [[tool.uv.index]]
154    /// name = "my-index"
155    /// url = "https://<omitted>/simple"
156    /// cache-control = { api = "max-age=600", files = "max-age=3600" }
157    /// ```
158    #[serde(default)]
159    pub cache_control: Option<IndexCacheControl>,
160}
161
162impl PartialEq for Index {
163    fn eq(&self, other: &Self) -> bool {
164        let Self {
165            name,
166            url,
167            explicit,
168            default,
169            origin: _,
170            format,
171            publish_url,
172            authenticate,
173            ignore_error_codes,
174            cache_control,
175        } = self;
176        *url == other.url
177            && *name == other.name
178            && *explicit == other.explicit
179            && *default == other.default
180            && *format == other.format
181            && *publish_url == other.publish_url
182            && *authenticate == other.authenticate
183            && *ignore_error_codes == other.ignore_error_codes
184            && *cache_control == other.cache_control
185    }
186}
187
188impl Eq for Index {}
189
190impl PartialOrd for Index {
191    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
192        Some(self.cmp(other))
193    }
194}
195
196impl Ord for Index {
197    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
198        let Self {
199            name,
200            url,
201            explicit,
202            default,
203            origin: _,
204            format,
205            publish_url,
206            authenticate,
207            ignore_error_codes,
208            cache_control,
209        } = self;
210        url.cmp(&other.url)
211            .then_with(|| name.cmp(&other.name))
212            .then_with(|| explicit.cmp(&other.explicit))
213            .then_with(|| default.cmp(&other.default))
214            .then_with(|| format.cmp(&other.format))
215            .then_with(|| publish_url.cmp(&other.publish_url))
216            .then_with(|| authenticate.cmp(&other.authenticate))
217            .then_with(|| ignore_error_codes.cmp(&other.ignore_error_codes))
218            .then_with(|| cache_control.cmp(&other.cache_control))
219    }
220}
221
222impl std::hash::Hash for Index {
223    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
224        let Self {
225            name,
226            url,
227            explicit,
228            default,
229            origin: _,
230            format,
231            publish_url,
232            authenticate,
233            ignore_error_codes,
234            cache_control,
235        } = self;
236        url.hash(state);
237        name.hash(state);
238        explicit.hash(state);
239        default.hash(state);
240        format.hash(state);
241        publish_url.hash(state);
242        authenticate.hash(state);
243        ignore_error_codes.hash(state);
244        cache_control.hash(state);
245    }
246}
247
248#[derive(
249    Default,
250    Debug,
251    Copy,
252    Clone,
253    Hash,
254    Eq,
255    PartialEq,
256    Ord,
257    PartialOrd,
258    serde::Serialize,
259    serde::Deserialize,
260)]
261#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
262#[serde(rename_all = "kebab-case")]
263pub enum IndexFormat {
264    /// A PyPI-style index implementing the Simple Repository API.
265    #[default]
266    Simple,
267    /// A `--find-links`-style index containing a flat list of wheels and source distributions.
268    Flat,
269}
270
271impl Index {
272    /// Initialize an [`Index`] from a pip-style `--index-url`.
273    pub fn from_index_url(url: IndexUrl) -> Self {
274        Self {
275            url,
276            name: None,
277            explicit: false,
278            default: true,
279            origin: None,
280            format: IndexFormat::Simple,
281            publish_url: None,
282            authenticate: AuthPolicy::default(),
283            ignore_error_codes: None,
284            cache_control: None,
285        }
286    }
287
288    /// Initialize an [`Index`] from a pip-style `--extra-index-url`.
289    pub fn from_extra_index_url(url: IndexUrl) -> Self {
290        Self {
291            url,
292            name: None,
293            explicit: false,
294            default: false,
295            origin: None,
296            format: IndexFormat::Simple,
297            publish_url: None,
298            authenticate: AuthPolicy::default(),
299            ignore_error_codes: None,
300            cache_control: None,
301        }
302    }
303
304    /// Initialize an [`Index`] from a pip-style `--find-links`.
305    pub fn from_find_links(url: IndexUrl) -> Self {
306        Self {
307            url,
308            name: None,
309            explicit: false,
310            default: false,
311            origin: None,
312            format: IndexFormat::Flat,
313            publish_url: None,
314            authenticate: AuthPolicy::default(),
315            ignore_error_codes: None,
316            cache_control: None,
317        }
318    }
319
320    /// Set the [`Origin`] of the index.
321    #[must_use]
322    pub fn with_origin(mut self, origin: Origin) -> Self {
323        self.origin = Some(origin);
324        self
325    }
326
327    /// Return the [`IndexUrl`] of the index.
328    pub fn url(&self) -> &IndexUrl {
329        &self.url
330    }
331
332    /// Consume the [`Index`] and return the [`IndexUrl`].
333    pub fn into_url(self) -> IndexUrl {
334        self.url
335    }
336
337    /// Return the raw [`Url`] of the index.
338    pub fn raw_url(&self) -> &DisplaySafeUrl {
339        self.url.url()
340    }
341
342    /// Return the root [`Url`] of the index, if applicable.
343    ///
344    /// For indexes with a `/simple` endpoint, this is simply the URL with the final segment
345    /// removed. This is useful, e.g., for credential propagation to other endpoints on the index.
346    pub fn root_url(&self) -> Option<DisplaySafeUrl> {
347        self.url.root()
348    }
349
350    /// If credentials are available (via the URL or environment) and [`AuthPolicy`] is
351    /// [`AuthPolicy::Auto`], promote to [`AuthPolicy::Always`] so that future operations
352    /// (e.g., `uv tool upgrade`) know that authentication is required even after the credentials
353    /// are stripped from the stored URL.
354    #[must_use]
355    pub fn with_promoted_auth_policy(mut self) -> Self {
356        if matches!(self.authenticate, AuthPolicy::Auto) && self.credentials().is_some() {
357            self.authenticate = AuthPolicy::Always;
358        }
359        self
360    }
361
362    /// Retrieve the credentials for the index, either from the environment, or from the URL itself.
363    pub fn credentials(&self) -> Option<Credentials> {
364        // If the index is named, and credentials are provided via the environment, prefer those.
365        if let Some(name) = self.name.as_ref() {
366            if let Some(credentials) = Credentials::from_env(name.to_env_var()) {
367                return Some(credentials);
368            }
369        }
370
371        // Otherwise, extract the credentials from the URL.
372        Credentials::from_url(self.url.url())
373    }
374
375    /// Resolve the index relative to the given root directory.
376    pub fn relative_to(mut self, root_dir: &Path) -> Result<Self, IndexUrlError> {
377        if let IndexUrl::Path(ref url) = self.url {
378            if let Some(given) = url.given() {
379                self.url = IndexUrl::parse(given, Some(root_dir))?;
380            }
381        }
382        Ok(self)
383    }
384
385    /// Return the [`IndexStatusCodeStrategy`] for this index.
386    pub fn status_code_strategy(&self) -> IndexStatusCodeStrategy {
387        if let Some(ignore_error_codes) = &self.ignore_error_codes {
388            IndexStatusCodeStrategy::from_ignored_error_codes(ignore_error_codes)
389        } else {
390            IndexStatusCodeStrategy::from_index_url(self.url.url())
391        }
392    }
393
394    /// Return the cache control header for file requests to this index, if any.
395    pub fn artifact_cache_control(&self) -> Option<&str> {
396        if let Some(artifact_cache_control) = self
397            .cache_control
398            .as_ref()
399            .and_then(|cache_control| cache_control.files.as_deref())
400        {
401            Some(artifact_cache_control)
402        } else {
403            IndexCacheControl::artifact_cache_control(self.url.url())
404        }
405    }
406
407    /// Return the cache control header for API requests to this index, if any.
408    pub fn simple_api_cache_control(&self) -> Option<&str> {
409        if let Some(api_cache_control) = self
410            .cache_control
411            .as_ref()
412            .and_then(|cache_control| cache_control.api.as_deref())
413        {
414            Some(api_cache_control)
415        } else {
416            IndexCacheControl::simple_api_cache_control(self.url.url())
417        }
418    }
419}
420
421impl From<IndexUrl> for Index {
422    fn from(value: IndexUrl) -> Self {
423        Self {
424            name: None,
425            url: value,
426            explicit: false,
427            default: false,
428            origin: None,
429            format: IndexFormat::Simple,
430            publish_url: None,
431            authenticate: AuthPolicy::default(),
432            ignore_error_codes: None,
433            cache_control: None,
434        }
435    }
436}
437
438impl FromStr for Index {
439    type Err = IndexSourceError;
440
441    fn from_str(s: &str) -> Result<Self, Self::Err> {
442        // Determine whether the source is prefixed with a name, as in `name=https://pypi.org/simple`.
443        if let Some((name, url)) = s.split_once('=') {
444            if !name.chars().any(|c| c == ':') {
445                let name = IndexName::from_str(name)?;
446                let url = IndexUrl::from_str(url)?;
447                return Ok(Self {
448                    name: Some(name),
449                    url,
450                    explicit: false,
451                    default: false,
452                    origin: None,
453                    format: IndexFormat::Simple,
454                    publish_url: None,
455                    authenticate: AuthPolicy::default(),
456                    ignore_error_codes: None,
457                    cache_control: None,
458                });
459            }
460        }
461
462        // Otherwise, assume the source is a URL.
463        let url = IndexUrl::from_str(s)?;
464        Ok(Self {
465            name: None,
466            url,
467            explicit: false,
468            default: false,
469            origin: None,
470            format: IndexFormat::Simple,
471            publish_url: None,
472            authenticate: AuthPolicy::default(),
473            ignore_error_codes: None,
474            cache_control: None,
475        })
476    }
477}
478
479/// An [`IndexUrl`] along with the metadata necessary to query the index.
480#[derive(Debug, Clone, Hash, Eq, PartialEq, Ord, PartialOrd)]
481pub struct IndexMetadata {
482    /// The URL of the index.
483    pub url: IndexUrl,
484    /// The format used by the index.
485    pub format: IndexFormat,
486}
487
488impl IndexMetadata {
489    /// Return a reference to the [`IndexMetadata`].
490    pub fn as_ref(&self) -> IndexMetadataRef<'_> {
491        let Self { url, format: kind } = self;
492        IndexMetadataRef { url, format: *kind }
493    }
494
495    /// Consume the [`IndexMetadata`] and return the [`IndexUrl`].
496    pub fn into_url(self) -> IndexUrl {
497        self.url
498    }
499}
500
501/// A reference to an [`IndexMetadata`].
502#[derive(Debug, Copy, Clone)]
503pub struct IndexMetadataRef<'a> {
504    /// The URL of the index.
505    pub url: &'a IndexUrl,
506    /// The format used by the index.
507    pub format: IndexFormat,
508}
509
510impl IndexMetadata {
511    /// Return the [`IndexUrl`] of the index.
512    pub fn url(&self) -> &IndexUrl {
513        &self.url
514    }
515}
516
517impl IndexMetadataRef<'_> {
518    /// Return the [`IndexUrl`] of the index.
519    pub fn url(&self) -> &IndexUrl {
520        self.url
521    }
522}
523
524impl<'a> From<&'a Index> for IndexMetadataRef<'a> {
525    fn from(value: &'a Index) -> Self {
526        Self {
527            url: &value.url,
528            format: value.format,
529        }
530    }
531}
532
533impl<'a> From<&'a IndexMetadata> for IndexMetadataRef<'a> {
534    fn from(value: &'a IndexMetadata) -> Self {
535        Self {
536            url: &value.url,
537            format: value.format,
538        }
539    }
540}
541
542impl From<IndexUrl> for IndexMetadata {
543    fn from(value: IndexUrl) -> Self {
544        Self {
545            url: value,
546            format: IndexFormat::Simple,
547        }
548    }
549}
550
551impl<'a> From<&'a IndexUrl> for IndexMetadataRef<'a> {
552    fn from(value: &'a IndexUrl) -> Self {
553        Self {
554            url: value,
555            format: IndexFormat::Simple,
556        }
557    }
558}
559
560/// Wire type for deserializing an [`Index`] with validation.
561#[derive(Deserialize)]
562#[serde(rename_all = "kebab-case")]
563struct IndexWire {
564    name: Option<IndexName>,
565    url: IndexUrl,
566    #[serde(default)]
567    explicit: bool,
568    #[serde(default)]
569    default: bool,
570    #[serde(default)]
571    format: IndexFormat,
572    publish_url: Option<DisplaySafeUrl>,
573    #[serde(default)]
574    authenticate: AuthPolicy,
575    #[serde(default)]
576    ignore_error_codes: Option<Vec<SerializableStatusCode>>,
577    #[serde(default)]
578    cache_control: Option<IndexCacheControl>,
579}
580
581impl<'de> Deserialize<'de> for Index {
582    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
583    where
584        D: serde::Deserializer<'de>,
585    {
586        let wire = IndexWire::deserialize(deserializer)?;
587
588        if wire.explicit && wire.name.is_none() {
589            return Err(serde::de::Error::custom(format!(
590                "An index with `explicit = true` requires a `name`: {}",
591                wire.url
592            )));
593        }
594
595        Ok(Self {
596            name: wire.name,
597            url: wire.url,
598            explicit: wire.explicit,
599            default: wire.default,
600            origin: None,
601            format: wire.format,
602            publish_url: wire.publish_url,
603            authenticate: wire.authenticate,
604            ignore_error_codes: wire.ignore_error_codes,
605            cache_control: wire.cache_control,
606        })
607    }
608}
609
610/// An error that can occur when parsing an [`Index`].
611#[derive(Error, Debug)]
612pub enum IndexSourceError {
613    #[error(transparent)]
614    Url(#[from] IndexUrlError),
615    #[error(transparent)]
616    IndexName(#[from] IndexNameError),
617    #[error("Index included a name, but the name was empty")]
618    EmptyName,
619}
620
621#[cfg(test)]
622mod tests {
623    use super::*;
624
625    #[test]
626    fn test_index_cache_control_headers() {
627        // Test that cache control headers are properly parsed from TOML
628        let toml_str = r#"
629            name = "test-index"
630            url = "https://test.example.com/simple"
631            cache-control = { api = "max-age=600", files = "max-age=3600" }
632        "#;
633
634        let index: Index = toml::from_str(toml_str).unwrap();
635        assert_eq!(index.name.as_ref().unwrap().as_ref(), "test-index");
636        assert!(index.cache_control.is_some());
637        let cache_control = index.cache_control.as_ref().unwrap();
638        assert_eq!(cache_control.api.as_deref(), Some("max-age=600"));
639        assert_eq!(cache_control.files.as_deref(), Some("max-age=3600"));
640    }
641
642    #[test]
643    fn test_index_without_cache_control() {
644        // Test that indexes work without cache control headers
645        let toml_str = r#"
646            name = "test-index"
647            url = "https://test.example.com/simple"
648        "#;
649
650        let index: Index = toml::from_str(toml_str).unwrap();
651        assert_eq!(index.name.as_ref().unwrap().as_ref(), "test-index");
652        assert_eq!(index.cache_control, None);
653    }
654
655    #[test]
656    fn test_index_partial_cache_control() {
657        // Test that cache control can have just one field
658        let toml_str = r#"
659            name = "test-index"
660            url = "https://test.example.com/simple"
661            cache-control = { api = "max-age=300" }
662        "#;
663
664        let index: Index = toml::from_str(toml_str).unwrap();
665        assert_eq!(index.name.as_ref().unwrap().as_ref(), "test-index");
666        assert!(index.cache_control.is_some());
667        let cache_control = index.cache_control.as_ref().unwrap();
668        assert_eq!(cache_control.api.as_deref(), Some("max-age=300"));
669        assert_eq!(cache_control.files, None);
670    }
671}