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