polars_io/cloud/
options.rs

1#[cfg(feature = "aws")]
2use std::io::Read;
3#[cfg(feature = "aws")]
4use std::path::Path;
5use std::str::FromStr;
6use std::sync::LazyLock;
7
8#[cfg(any(feature = "aws", feature = "gcp", feature = "azure", feature = "http"))]
9use object_store::ClientOptions;
10#[cfg(feature = "aws")]
11use object_store::aws::AmazonS3Builder;
12#[cfg(feature = "aws")]
13pub use object_store::aws::AmazonS3ConfigKey;
14#[cfg(feature = "azure")]
15pub use object_store::azure::AzureConfigKey;
16#[cfg(feature = "azure")]
17use object_store::azure::MicrosoftAzureBuilder;
18#[cfg(feature = "gcp")]
19use object_store::gcp::GoogleCloudStorageBuilder;
20#[cfg(feature = "gcp")]
21pub use object_store::gcp::GoogleConfigKey;
22#[cfg(any(feature = "aws", feature = "gcp", feature = "azure"))]
23use object_store::{BackoffConfig, RetryConfig};
24use polars_error::*;
25#[cfg(feature = "aws")]
26use polars_utils::cache::LruCache;
27#[cfg(feature = "http")]
28use reqwest::header::HeaderMap;
29#[cfg(feature = "serde")]
30use serde::{Deserialize, Serialize};
31#[cfg(feature = "cloud")]
32use url::Url;
33
34#[cfg(feature = "cloud")]
35use super::credential_provider::PlCredentialProvider;
36#[cfg(feature = "file_cache")]
37use crate::file_cache::get_env_file_cache_ttl;
38#[cfg(feature = "aws")]
39use crate::pl_async::with_concurrency_budget;
40
41#[cfg(feature = "aws")]
42static BUCKET_REGION: LazyLock<
43    std::sync::Mutex<LruCache<polars_utils::pl_str::PlSmallStr, polars_utils::pl_str::PlSmallStr>>,
44> = LazyLock::new(|| std::sync::Mutex::new(LruCache::with_capacity(32)));
45
46/// The type of the config keys must satisfy the following requirements:
47/// 1. must be easily collected into a HashMap, the type required by the object_crate API.
48/// 2. be Serializable, required when the serde-lazy feature is defined.
49/// 3. not actually use HashMap since that type is disallowed in Polars for performance reasons.
50///
51/// Currently this type is a vector of pairs config key - config value.
52#[allow(dead_code)]
53type Configs<T> = Vec<(T, String)>;
54
55#[derive(Clone, Debug, PartialEq, Hash, Eq)]
56#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
57pub(crate) enum CloudConfig {
58    #[cfg(feature = "aws")]
59    Aws(Configs<AmazonS3ConfigKey>),
60    #[cfg(feature = "azure")]
61    Azure(Configs<AzureConfigKey>),
62    #[cfg(feature = "gcp")]
63    Gcp(Configs<GoogleConfigKey>),
64    #[cfg(feature = "http")]
65    Http { headers: Vec<(String, String)> },
66}
67
68#[derive(Clone, Debug, PartialEq, Hash, Eq)]
69#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
70/// Options to connect to various cloud providers.
71pub struct CloudOptions {
72    pub max_retries: usize,
73    #[cfg(feature = "file_cache")]
74    pub file_cache_ttl: u64,
75    pub(crate) config: Option<CloudConfig>,
76    #[cfg(feature = "cloud")]
77    /// Note: In most cases you will want to access this via [`CloudOptions::initialized_credential_provider`]
78    /// rather than directly.
79    pub(crate) credential_provider: Option<PlCredentialProvider>,
80}
81
82impl Default for CloudOptions {
83    fn default() -> Self {
84        Self::default_static_ref().clone()
85    }
86}
87
88impl CloudOptions {
89    pub fn default_static_ref() -> &'static Self {
90        static DEFAULT: LazyLock<CloudOptions> = LazyLock::new(|| CloudOptions {
91            max_retries: 2,
92            #[cfg(feature = "file_cache")]
93            file_cache_ttl: get_env_file_cache_ttl(),
94            config: None,
95            #[cfg(feature = "cloud")]
96            credential_provider: None,
97        });
98
99        &DEFAULT
100    }
101}
102
103#[cfg(feature = "http")]
104pub(crate) fn try_build_http_header_map_from_items_slice<S: AsRef<str>>(
105    headers: &[(S, S)],
106) -> PolarsResult<HeaderMap> {
107    use reqwest::header::{HeaderName, HeaderValue};
108
109    let mut map = HeaderMap::with_capacity(headers.len());
110    for (k, v) in headers {
111        let (k, v) = (k.as_ref(), v.as_ref());
112        map.insert(
113            HeaderName::from_str(k).map_err(to_compute_err)?,
114            HeaderValue::from_str(v).map_err(to_compute_err)?,
115        );
116    }
117
118    Ok(map)
119}
120
121#[allow(dead_code)]
122/// Parse an untype configuration hashmap to a typed configuration for the given configuration key type.
123fn parsed_untyped_config<T, I: IntoIterator<Item = (impl AsRef<str>, impl Into<String>)>>(
124    config: I,
125) -> PolarsResult<Configs<T>>
126where
127    T: FromStr + Eq + std::hash::Hash,
128{
129    Ok(config
130        .into_iter()
131        // Silently ignores custom upstream storage_options
132        .filter_map(|(key, val)| {
133            T::from_str(key.as_ref().to_ascii_lowercase().as_str())
134                .ok()
135                .map(|typed_key| (typed_key, val.into()))
136        })
137        .collect::<Configs<T>>())
138}
139
140#[derive(Debug, Clone, PartialEq)]
141pub enum CloudType {
142    Aws,
143    Azure,
144    File,
145    Gcp,
146    Http,
147    Hf,
148}
149
150impl CloudType {
151    #[cfg(feature = "cloud")]
152    pub(crate) fn from_url(parsed: &Url) -> PolarsResult<Self> {
153        Ok(match parsed.scheme() {
154            "s3" | "s3a" => Self::Aws,
155            "az" | "azure" | "adl" | "abfs" | "abfss" => Self::Azure,
156            "gs" | "gcp" | "gcs" => Self::Gcp,
157            "file" => Self::File,
158            "http" | "https" => Self::Http,
159            "hf" => Self::Hf,
160            _ => polars_bail!(ComputeError: "unknown url scheme"),
161        })
162    }
163}
164
165#[cfg(feature = "cloud")]
166pub(crate) fn parse_url(input: &str) -> std::result::Result<url::Url, url::ParseError> {
167    Ok(if input.contains("://") {
168        if input.starts_with("http://") || input.starts_with("https://") {
169            url::Url::parse(input)
170        } else {
171            url::Url::parse(&input.replace("%", "%25"))
172        }?
173    } else {
174        let path = std::path::Path::new(input);
175        let mut tmp;
176        url::Url::from_file_path(if path.is_relative() {
177            tmp = std::env::current_dir().unwrap();
178            tmp.push(path);
179            tmp.as_path()
180        } else {
181            path
182        })
183        .unwrap()
184    })
185}
186
187impl FromStr for CloudType {
188    type Err = PolarsError;
189
190    #[cfg(feature = "cloud")]
191    fn from_str(url: &str) -> Result<Self, Self::Err> {
192        let parsed = parse_url(url).map_err(to_compute_err)?;
193        Self::from_url(&parsed)
194    }
195
196    #[cfg(not(feature = "cloud"))]
197    fn from_str(_s: &str) -> Result<Self, Self::Err> {
198        polars_bail!(ComputeError: "at least one of the cloud features must be enabled");
199    }
200}
201#[cfg(any(feature = "aws", feature = "gcp", feature = "azure"))]
202fn get_retry_config(max_retries: usize) -> RetryConfig {
203    RetryConfig {
204        backoff: BackoffConfig::default(),
205        max_retries,
206        retry_timeout: std::time::Duration::from_secs(10),
207    }
208}
209
210#[cfg(any(feature = "aws", feature = "gcp", feature = "azure", feature = "http"))]
211pub(super) fn get_client_options() -> ClientOptions {
212    ClientOptions::new()
213        // We set request timeout super high as the timeout isn't reset at ACK,
214        // but starts from the moment we start downloading a body.
215        // https://docs.rs/reqwest/latest/reqwest/struct.ClientBuilder.html#method.timeout
216        .with_timeout_disabled()
217        // Concurrency can increase connection latency, so set to None, similar to default.
218        .with_connect_timeout_disabled()
219        .with_allow_http(true)
220}
221
222#[cfg(feature = "aws")]
223fn read_config(
224    builder: &mut AmazonS3Builder,
225    items: &[(&Path, &[(&str, AmazonS3ConfigKey)])],
226) -> Option<()> {
227    use crate::path_utils::resolve_homedir;
228
229    for (path, keys) in items {
230        if keys
231            .iter()
232            .all(|(_, key)| builder.get_config_value(key).is_some())
233        {
234            continue;
235        }
236
237        let mut config = std::fs::File::open(resolve_homedir(path)).ok()?;
238        let mut buf = vec![];
239        config.read_to_end(&mut buf).ok()?;
240        let content = std::str::from_utf8(buf.as_ref()).ok()?;
241
242        for (pattern, key) in keys.iter() {
243            if builder.get_config_value(key).is_none() {
244                let reg = polars_utils::regex_cache::compile_regex(pattern).unwrap();
245                let cap = reg.captures(content)?;
246                let m = cap.get(1)?;
247                let parsed = m.as_str();
248                *builder = std::mem::take(builder).with_config(*key, parsed);
249            }
250        }
251    }
252    Some(())
253}
254
255impl CloudOptions {
256    /// Set the maximum number of retries.
257    pub fn with_max_retries(mut self, max_retries: usize) -> Self {
258        self.max_retries = max_retries;
259        self
260    }
261
262    #[cfg(feature = "cloud")]
263    pub fn with_credential_provider(
264        mut self,
265        credential_provider: Option<PlCredentialProvider>,
266    ) -> Self {
267        self.credential_provider = credential_provider;
268        self
269    }
270
271    /// Set the configuration for AWS connections. This is the preferred API from rust.
272    #[cfg(feature = "aws")]
273    pub fn with_aws<I: IntoIterator<Item = (AmazonS3ConfigKey, impl Into<String>)>>(
274        mut self,
275        configs: I,
276    ) -> Self {
277        self.config = Some(CloudConfig::Aws(
278            configs.into_iter().map(|(k, v)| (k, v.into())).collect(),
279        ));
280        self
281    }
282
283    /// Build the [`object_store::ObjectStore`] implementation for AWS.
284    #[cfg(feature = "aws")]
285    pub async fn build_aws(&self, url: &str) -> PolarsResult<impl object_store::ObjectStore> {
286        use super::credential_provider::IntoCredentialProvider;
287
288        let mut builder = AmazonS3Builder::from_env()
289            .with_client_options(get_client_options())
290            .with_url(url);
291
292        read_config(
293            &mut builder,
294            &[(
295                Path::new("~/.aws/config"),
296                &[("region\\s*=\\s*([^\r\n]*)", AmazonS3ConfigKey::Region)],
297            )],
298        );
299
300        read_config(
301            &mut builder,
302            &[(
303                Path::new("~/.aws/credentials"),
304                &[
305                    (
306                        "aws_access_key_id\\s*=\\s*([^\\r\\n]*)",
307                        AmazonS3ConfigKey::AccessKeyId,
308                    ),
309                    (
310                        "aws_secret_access_key\\s*=\\s*([^\\r\\n]*)",
311                        AmazonS3ConfigKey::SecretAccessKey,
312                    ),
313                    (
314                        "aws_session_token\\s*=\\s*([^\\r\\n]*)",
315                        AmazonS3ConfigKey::Token,
316                    ),
317                ],
318            )],
319        );
320
321        if let Some(options) = &self.config {
322            let CloudConfig::Aws(options) = options else {
323                panic!("impl error: cloud type mismatch")
324            };
325            for (key, value) in options.iter() {
326                builder = builder.with_config(*key, value);
327            }
328        }
329
330        if builder
331            .get_config_value(&AmazonS3ConfigKey::DefaultRegion)
332            .is_none()
333            && builder
334                .get_config_value(&AmazonS3ConfigKey::Region)
335                .is_none()
336        {
337            let bucket = crate::cloud::CloudLocation::new(url, false)?.bucket;
338            let region = {
339                let mut bucket_region = BUCKET_REGION.lock().unwrap();
340                bucket_region.get(bucket.as_str()).cloned()
341            };
342
343            match region {
344                Some(region) => {
345                    builder = builder.with_config(AmazonS3ConfigKey::Region, region.as_str())
346                },
347                None => {
348                    if builder
349                        .get_config_value(&AmazonS3ConfigKey::Endpoint)
350                        .is_some()
351                    {
352                        // Set a default value if the endpoint is not aws.
353                        // See: #13042
354                        builder = builder.with_config(AmazonS3ConfigKey::Region, "us-east-1");
355                    } else {
356                        polars_warn!(
357                            "'(default_)region' not set; polars will try to get it from bucket\n\nSet the region manually to silence this warning."
358                        );
359                        let result = with_concurrency_budget(1, || async {
360                            reqwest::Client::builder()
361                                .build()
362                                .unwrap()
363                                .head(format!("https://{bucket}.s3.amazonaws.com"))
364                                .send()
365                                .await
366                                .map_err(to_compute_err)
367                        })
368                        .await?;
369                        if let Some(region) = result.headers().get("x-amz-bucket-region") {
370                            let region =
371                                std::str::from_utf8(region.as_bytes()).map_err(to_compute_err)?;
372                            let mut bucket_region = BUCKET_REGION.lock().unwrap();
373                            bucket_region.insert(bucket, region.into());
374                            builder = builder.with_config(AmazonS3ConfigKey::Region, region)
375                        }
376                    }
377                },
378            };
379        };
380
381        let builder = builder.with_retry(get_retry_config(self.max_retries));
382
383        let builder = if let Some(v) = self.initialized_credential_provider()? {
384            builder.with_credentials(v.into_aws_provider())
385        } else {
386            builder
387        };
388
389        let out = builder.build()?;
390
391        Ok(out)
392    }
393
394    /// Set the configuration for Azure connections. This is the preferred API from rust.
395    #[cfg(feature = "azure")]
396    pub fn with_azure<I: IntoIterator<Item = (AzureConfigKey, impl Into<String>)>>(
397        mut self,
398        configs: I,
399    ) -> Self {
400        self.config = Some(CloudConfig::Azure(
401            configs.into_iter().map(|(k, v)| (k, v.into())).collect(),
402        ));
403        self
404    }
405
406    /// Build the [`object_store::ObjectStore`] implementation for Azure.
407    #[cfg(feature = "azure")]
408    pub fn build_azure(&self, url: &str) -> PolarsResult<impl object_store::ObjectStore> {
409        use super::credential_provider::IntoCredentialProvider;
410
411        let verbose = polars_core::config::verbose();
412
413        // The credential provider `self.credentials` is prioritized if it is set. We also need
414        // `from_env()` as it may source environment configured storage account name.
415        let mut builder =
416            MicrosoftAzureBuilder::from_env().with_client_options(get_client_options());
417
418        if let Some(options) = &self.config {
419            let CloudConfig::Azure(options) = options else {
420                panic!("impl error: cloud type mismatch")
421            };
422            for (key, value) in options.iter() {
423                builder = builder.with_config(*key, value);
424            }
425        }
426
427        let builder = builder
428            .with_url(url)
429            .with_retry(get_retry_config(self.max_retries));
430
431        let builder = if let Some(v) = self.initialized_credential_provider()? {
432            if verbose {
433                eprintln!(
434                    "[CloudOptions::build_azure]: Using credential provider {:?}",
435                    &v
436                );
437            }
438            builder.with_credentials(v.into_azure_provider())
439        } else {
440            builder
441        };
442
443        let out = builder.build()?;
444
445        Ok(out)
446    }
447
448    /// Set the configuration for GCP connections. This is the preferred API from rust.
449    #[cfg(feature = "gcp")]
450    pub fn with_gcp<I: IntoIterator<Item = (GoogleConfigKey, impl Into<String>)>>(
451        mut self,
452        configs: I,
453    ) -> Self {
454        self.config = Some(CloudConfig::Gcp(
455            configs.into_iter().map(|(k, v)| (k, v.into())).collect(),
456        ));
457        self
458    }
459
460    /// Build the [`object_store::ObjectStore`] implementation for GCP.
461    #[cfg(feature = "gcp")]
462    pub fn build_gcp(&self, url: &str) -> PolarsResult<impl object_store::ObjectStore> {
463        use super::credential_provider::IntoCredentialProvider;
464
465        let credential_provider = self.initialized_credential_provider()?;
466
467        let builder = if credential_provider.is_none() {
468            GoogleCloudStorageBuilder::from_env()
469        } else {
470            GoogleCloudStorageBuilder::new()
471        };
472
473        let mut builder = builder.with_client_options(get_client_options());
474
475        if let Some(options) = &self.config {
476            let CloudConfig::Gcp(options) = options else {
477                panic!("impl error: cloud type mismatch")
478            };
479            for (key, value) in options.iter() {
480                builder = builder.with_config(*key, value);
481            }
482        }
483
484        let builder = builder
485            .with_url(url)
486            .with_retry(get_retry_config(self.max_retries));
487
488        let builder = if let Some(v) = credential_provider.clone() {
489            builder.with_credentials(v.into_gcp_provider())
490        } else {
491            builder
492        };
493
494        let out = builder.build()?;
495
496        Ok(out)
497    }
498
499    #[cfg(feature = "http")]
500    pub fn build_http(&self, url: &str) -> PolarsResult<impl object_store::ObjectStore> {
501        let out = object_store::http::HttpBuilder::new()
502            .with_url(url)
503            .with_client_options({
504                let mut opts = super::get_client_options();
505                if let Some(CloudConfig::Http { headers }) = &self.config {
506                    opts = opts.with_default_headers(try_build_http_header_map_from_items_slice(
507                        headers.as_slice(),
508                    )?);
509                }
510                opts
511            })
512            .build()?;
513
514        Ok(out)
515    }
516
517    /// Parse a configuration from a Hashmap. This is the interface from Python.
518    #[allow(unused_variables)]
519    pub fn from_untyped_config<I: IntoIterator<Item = (impl AsRef<str>, impl Into<String>)>>(
520        url: &str,
521        config: I,
522    ) -> PolarsResult<Self> {
523        match CloudType::from_str(url)? {
524            CloudType::Aws => {
525                #[cfg(feature = "aws")]
526                {
527                    parsed_untyped_config::<AmazonS3ConfigKey, _>(config)
528                        .map(|aws| Self::default().with_aws(aws))
529                }
530                #[cfg(not(feature = "aws"))]
531                {
532                    polars_bail!(ComputeError: "'aws' feature is not enabled");
533                }
534            },
535            CloudType::Azure => {
536                #[cfg(feature = "azure")]
537                {
538                    parsed_untyped_config::<AzureConfigKey, _>(config)
539                        .map(|azure| Self::default().with_azure(azure))
540                }
541                #[cfg(not(feature = "azure"))]
542                {
543                    polars_bail!(ComputeError: "'azure' feature is not enabled");
544                }
545            },
546            CloudType::File => Ok(Self::default()),
547            CloudType::Http => Ok(Self::default()),
548            CloudType::Gcp => {
549                #[cfg(feature = "gcp")]
550                {
551                    parsed_untyped_config::<GoogleConfigKey, _>(config)
552                        .map(|gcp| Self::default().with_gcp(gcp))
553                }
554                #[cfg(not(feature = "gcp"))]
555                {
556                    polars_bail!(ComputeError: "'gcp' feature is not enabled");
557                }
558            },
559            CloudType::Hf => {
560                #[cfg(feature = "http")]
561                {
562                    use polars_core::config;
563
564                    use crate::path_utils::resolve_homedir;
565
566                    let mut this = Self::default();
567                    let mut token = None;
568                    let verbose = config::verbose();
569
570                    for (i, (k, v)) in config.into_iter().enumerate() {
571                        let (k, v) = (k.as_ref(), v.into());
572
573                        if i == 0 && k == "token" {
574                            if verbose {
575                                eprintln!("HF token sourced from storage_options");
576                            }
577                            token = Some(v);
578                        } else {
579                            polars_bail!(ComputeError: "unknown configuration key for HF: {}", k)
580                        }
581                    }
582
583                    token = token
584                        .or_else(|| {
585                            let v = std::env::var("HF_TOKEN").ok();
586                            if v.is_some() && verbose {
587                                eprintln!("HF token sourced from HF_TOKEN env var");
588                            }
589                            v
590                        })
591                        .or_else(|| {
592                            let hf_home = std::env::var("HF_HOME");
593                            let hf_home = hf_home.as_deref();
594                            let hf_home = hf_home.unwrap_or("~/.cache/huggingface");
595                            let hf_home = resolve_homedir(&hf_home);
596                            let cached_token_path = hf_home.join("token");
597
598                            let v = std::string::String::from_utf8(
599                                std::fs::read(&cached_token_path).ok()?,
600                            )
601                            .ok()
602                            .filter(|x| !x.is_empty());
603
604                            if v.is_some() && verbose {
605                                eprintln!(
606                                    "HF token sourced from {}",
607                                    cached_token_path.to_str().unwrap()
608                                );
609                            }
610
611                            v
612                        });
613
614                    if let Some(v) = token {
615                        this.config = Some(CloudConfig::Http {
616                            headers: vec![("Authorization".into(), format!("Bearer {}", v))],
617                        })
618                    }
619
620                    Ok(this)
621                }
622                #[cfg(not(feature = "http"))]
623                {
624                    polars_bail!(ComputeError: "'http' feature is not enabled");
625                }
626            },
627        }
628    }
629
630    /// Python passes a credential provider builder that needs to be called to get the actual credential
631    /// provider.
632    #[cfg(feature = "cloud")]
633    fn initialized_credential_provider(&self) -> PolarsResult<Option<PlCredentialProvider>> {
634        if let Some(v) = self.credential_provider.clone() {
635            v.try_into_initialized()
636        } else {
637            Ok(None)
638        }
639    }
640}
641
642#[cfg(feature = "cloud")]
643#[cfg(test)]
644mod tests {
645    use hashbrown::HashMap;
646
647    use super::{parse_url, parsed_untyped_config};
648
649    #[test]
650    fn test_parse_url() {
651        assert_eq!(
652            parse_url(r"http://Users/Jane Doe/data.csv")
653                .unwrap()
654                .as_str(),
655            "http://users/Jane%20Doe/data.csv"
656        );
657        assert_eq!(
658            parse_url(r"http://Users/Jane Doe/data.csv")
659                .unwrap()
660                .as_str(),
661            "http://users/Jane%20Doe/data.csv"
662        );
663        #[cfg(target_os = "windows")]
664        {
665            assert_eq!(
666                parse_url(r"file:///c:/Users/Jane Doe/data.csv")
667                    .unwrap()
668                    .as_str(),
669                "file:///c:/Users/Jane%20Doe/data.csv"
670            );
671            assert_eq!(
672                parse_url(r"file://\c:\Users\Jane Doe\data.csv")
673                    .unwrap()
674                    .as_str(),
675                "file:///c:/Users/Jane%20Doe/data.csv"
676            );
677            assert_eq!(
678                parse_url(r"c:\Users\Jane Doe\data.csv").unwrap().as_str(),
679                "file:///C:/Users/Jane%20Doe/data.csv"
680            );
681            assert_eq!(
682                parse_url(r"data.csv").unwrap().as_str(),
683                url::Url::from_file_path(
684                    [
685                        std::env::current_dir().unwrap().as_path(),
686                        std::path::Path::new("data.csv")
687                    ]
688                    .into_iter()
689                    .collect::<std::path::PathBuf>()
690                )
691                .unwrap()
692                .as_str()
693            );
694        }
695        #[cfg(not(target_os = "windows"))]
696        {
697            assert_eq!(
698                parse_url(r"file:///home/Jane Doe/data.csv")
699                    .unwrap()
700                    .as_str(),
701                "file:///home/Jane%20Doe/data.csv"
702            );
703            assert_eq!(
704                parse_url(r"/home/Jane Doe/data.csv").unwrap().as_str(),
705                "file:///home/Jane%20Doe/data.csv"
706            );
707            assert_eq!(
708                parse_url(r"data.csv").unwrap().as_str(),
709                url::Url::from_file_path(
710                    [
711                        std::env::current_dir().unwrap().as_path(),
712                        std::path::Path::new("data.csv")
713                    ]
714                    .into_iter()
715                    .collect::<std::path::PathBuf>()
716                )
717                .unwrap()
718                .as_str()
719            );
720        }
721    }
722    #[cfg(feature = "aws")]
723    #[test]
724    fn test_parse_untyped_config() {
725        use object_store::aws::AmazonS3ConfigKey;
726
727        let aws_config = [
728            ("aws_secret_access_key", "a_key"),
729            ("aws_s3_allow_unsafe_rename", "true"),
730        ]
731        .into_iter()
732        .collect::<HashMap<_, _>>();
733        let aws_keys = parsed_untyped_config::<AmazonS3ConfigKey, _>(aws_config)
734            .expect("Parsing keys shouldn't have thrown an error");
735
736        assert_eq!(
737            aws_keys.first().unwrap().0,
738            AmazonS3ConfigKey::SecretAccessKey
739        );
740        assert_eq!(aws_keys.len(), 1);
741
742        let aws_config = [
743            ("AWS_SECRET_ACCESS_KEY", "a_key"),
744            ("aws_s3_allow_unsafe_rename", "true"),
745        ]
746        .into_iter()
747        .collect::<HashMap<_, _>>();
748        let aws_keys = parsed_untyped_config::<AmazonS3ConfigKey, _>(aws_config)
749            .expect("Parsing keys shouldn't have thrown an error");
750
751        assert_eq!(
752            aws_keys.first().unwrap().0,
753            AmazonS3ConfigKey::SecretAccessKey
754        );
755        assert_eq!(aws_keys.len(), 1);
756    }
757}