Skip to main content

polars_io/cloud/
object_store_setup.rs

1use std::sync::{Arc, LazyLock};
2
3use object_store::ObjectStore;
4use object_store::local::LocalFileSystem;
5use polars_core::config::{self, verbose, verbose_print_sensitive};
6use polars_error::{PolarsError, PolarsResult, polars_bail, polars_err, to_compute_err};
7use polars_utils::aliases::PlHashMap;
8use polars_utils::pl_path::{ALLOWED_EXT_SCHEMES, CloudScheme, PlPath, PlRefPath};
9use polars_utils::pl_str::PlSmallStr;
10use polars_utils::{format_pl_smallstr, pl_serialize};
11use tokio::sync::RwLock;
12
13use super::{CloudLocation, CloudOptions, CloudType, PolarsObjectStore};
14use crate::cloud::http_rate_limit::{
15    DirectionalRateLimitConfig, InitPolicy, PacingBudget, RateLimiter,
16};
17use crate::cloud::{CloudConfig, CloudRateLimitConfig, CloudRetryConfig};
18
19/// Object stores must be cached. Every object-store will do DNS lookups and
20/// get rate limited when querying the DNS (can take up to 5s).
21/// Other reasons are connection pools that must be shared between as much as possible.
22#[allow(clippy::type_complexity)]
23static OBJECT_STORE_CACHE: LazyLock<RwLock<PlHashMap<Vec<u8>, PolarsObjectStore>>> =
24    LazyLock::new(Default::default);
25
26/// Trait for external ObjectStore builder (e.g., for HDFS). Unstable.
27pub trait ExtObjectStoreBuilder {
28    /// Build new object_store.
29    fn build(
30        &self,
31        url: &PlRefPath,
32        options: Option<&CloudOptions>,
33    ) -> PolarsResult<Arc<dyn ObjectStore + Send + Sync>>;
34
35    /// Return a stable cache key for this store.
36    /// Defaults to `None`, which uses the default key (URL authority + serialised CloudOptions).
37    fn stable_cache_key(
38        &self,
39        _url: &PlRefPath,
40        _options: Option<&CloudOptions>,
41    ) -> Option<Vec<u8>> {
42        None
43    }
44}
45
46static EXT_OBJECT_STORE_BUILDER_REGISTRY: LazyLock<
47    std::sync::RwLock<PlHashMap<PlSmallStr, Arc<dyn ExtObjectStoreBuilder + Send + Sync>>>,
48> = LazyLock::new(Default::default);
49
50/// Register custom object_store builder for a given cloud scheme.
51/// Example: for 'hdfs://', the scheme is "hdfs".
52/// Rejects native cloud schemes (e.g. "s3").
53pub fn register_object_store_builder(
54    scheme: &str,
55    builder: Arc<dyn ExtObjectStoreBuilder + Send + Sync>,
56) -> PolarsResult<()> {
57    // Reject schemes already handled natively.
58    // TODO: allow shadowing of existing schemes.
59    if CloudScheme::is_native_str(scheme) {
60        polars_bail!(
61            InvalidOperation:
62            "cannot register object_store_builder for scheme '{}': \
63             this scheme is handled natively",
64            scheme
65        );
66    }
67
68    if !polars_utils::pl_path::ext_scheme_allowed(scheme) {
69        polars_bail!(
70            InvalidOperation:
71            "cannot register object_store_builder for scheme '{}': \
72             allowed external schemes are: {:?}",
73            scheme,
74            ALLOWED_EXT_SCHEMES
75        );
76    }
77
78    if polars_config::config().verbose() {
79        eprintln!(
80            "[ObjectStoreBuilderRegistry]: register object_store_builder for scheme '{scheme}'"
81        )
82    }
83
84    EXT_OBJECT_STORE_BUILDER_REGISTRY
85        .write()
86        .unwrap()
87        .insert(scheme.into(), builder);
88    Ok(())
89}
90
91pub fn deregister_object_store_builder(scheme: &str) {
92    if polars_config::config().verbose() {
93        eprintln!(
94            "[ObjectStoreBuilderRegistry]: deregister object_store_builder for scheme '{scheme}'"
95        )
96    }
97
98    EXT_OBJECT_STORE_BUILDER_REGISTRY
99        .write()
100        .unwrap()
101        .remove(scheme);
102}
103
104#[allow(dead_code)]
105fn err_missing_feature(
106    feature: &str,
107    cloud_type: &CloudType,
108) -> PolarsResult<Arc<dyn ObjectStore>> {
109    polars_bail!(
110        ComputeError:
111        "feature '{}' must be enabled in order to use '{:?}' cloud urls",
112        feature,
113        cloud_type,
114    );
115}
116
117/// Get the key of a url for object store registration.
118fn path_and_creds_to_key(path: &PlPath, options: Option<&CloudOptions>) -> PolarsResult<Vec<u8>> {
119    // We include credentials as they can expire, so users will send new credentials for the same url.
120
121    #[cfg(feature = "cloud")]
122    let credential_cache_key = CacheKeyBytes(
123        options
124            .and_then(|o| o.credential_provider.as_ref())
125            .map(|x| x.stable_cache_key())
126            .transpose()?
127            .unwrap_or_default(),
128    );
129
130    let cloud_options = options
131        .map(
132            |CloudOptions {
133                 // Destructure to ensure this breaks if anything changes.
134                 #[cfg(feature = "file_cache")]
135                 file_cache_ttl,
136                 config,
137                 retry_config,
138                 rate_limit_config,
139                 #[cfg(feature = "cloud")]
140                     credential_provider: _,
141             }|
142             -> PolarsResult<CloudOptionsKey> {
143                Ok(CloudOptionsKey {
144                    #[cfg(feature = "file_cache")]
145                    file_cache_ttl: *file_cache_ttl,
146                    config: config.clone(),
147                    retry_config: *retry_config,
148                    rate_limit_config: *rate_limit_config,
149                    #[cfg(feature = "cloud")]
150                    credential_provider: credential_cache_key,
151                })
152            },
153        )
154        .transpose()?;
155
156    let cache_key = CacheKey {
157        url_base: format_pl_smallstr!("{}", &path.as_str()[..path.authority_end_position()]),
158        cloud_options,
159    };
160
161    verbose_print_sensitive(|| {
162        format!(
163            "object store cache key for path at '{}': {:?}",
164            path, cache_key
165        )
166    });
167
168    return pl_serialize::serialize_to_bytes::<_, false>(&cache_key);
169
170    #[derive(Clone, Debug, PartialEq, Hash, Eq)]
171    #[cfg_attr(feature = "serde", derive(serde::Serialize))]
172    struct CacheKey {
173        url_base: PlSmallStr,
174        cloud_options: Option<CloudOptionsKey>,
175    }
176
177    #[derive(Clone, PartialEq, Hash, Eq)]
178    #[cfg_attr(feature = "serde", derive(serde::Serialize))]
179    struct CacheKeyBytes(Vec<u8>);
180
181    impl std::fmt::Debug for CacheKeyBytes {
182        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
183            if self.0.is_empty() {
184                write!(f, "None")
185            } else {
186                for b in &self.0 {
187                    write!(f, "{:02x}", b)?;
188                }
189                Ok(())
190            }
191        }
192    }
193
194    /// Variant of CloudOptions for serializing to a cache key. The credential
195    /// provider is replaced by the function address.
196    #[derive(Clone, Debug, PartialEq, Hash, Eq)]
197    #[cfg_attr(feature = "serde", derive(serde::Serialize))]
198    struct CloudOptionsKey {
199        #[cfg(feature = "file_cache")]
200        file_cache_ttl: u64,
201        config: Option<CloudConfig>,
202        retry_config: CloudRetryConfig,
203        rate_limit_config: CloudRateLimitConfig,
204        #[cfg(feature = "cloud")]
205        credential_provider: CacheKeyBytes,
206    }
207}
208
209/// Construct an object_store `Path` from a string without any encoding/decoding.
210pub fn object_path_from_str(path: &str) -> PolarsResult<object_store::path::Path> {
211    object_store::path::Path::parse(path).map_err(to_compute_err)
212}
213
214#[derive(Debug, Clone)]
215pub(crate) struct PolarsObjectStoreBuilder {
216    path: PlRefPath,
217    cloud_type: CloudType,
218    options: Option<CloudOptions>,
219    // RateLimiter. Authoritative for config and read/write request rate cells (atomics).
220    // The rate-limiter, below the object store, updates them dynamically. The concurrency controller,
221    // above the object store, uses the request rate to limit the number of concurrent requests.
222    rate_limiter: Option<Arc<RateLimiter>>,
223}
224
225impl PolarsObjectStoreBuilder {
226    pub(super) fn path(&self) -> &PlRefPath {
227        &self.path
228    }
229
230    pub(crate) fn rate_limit_signal(&self) -> Option<PacingBudget> {
231        self.rate_limiter.as_ref().map(|r| r.read_budget())
232    }
233
234    pub(super) async fn build_impl(
235        &self,
236        // Whether to clear cached credentials for Python credential providers.
237        clear_cached_credentials: bool,
238    ) -> PolarsResult<Arc<dyn ObjectStore>> {
239        let options = self
240            .options
241            .as_ref()
242            .unwrap_or_else(|| CloudOptions::default_static_ref());
243
244        if let Some(options) = &self.options
245            && verbose()
246        {
247            eprintln!(
248                "build object-store: file_cache_ttl: {}",
249                options.file_cache_ttl
250            );
251
252            fn eprint_rate_config(direction: &str, config: &DirectionalRateLimitConfig) {
253                eprintln!(
254                    "object-store rate_limiter config({})]: init_rate: {:.0}rps, floor_rate: {:.0}rps, ceiling_rate: {:.0}rps, horizon: {}ms, max_wait: {}ms, init_policy: {}",
255                    direction,
256                    config.init_rate,
257                    config.floor_rate,
258                    config.ceiling_rate,
259                    config.horizon.as_millis(),
260                    config.max_wait.as_millis(),
261                    {
262                        match config.init_policy {
263                            InitPolicy::SetToInit => "to_init",
264                            InitPolicy::SetToFloor => "to_floor",
265                            InitPolicy::Inherit => "inherit",
266                        }
267                    }
268                )
269            }
270
271            if let Some(rate_limiter) = &self.rate_limiter {
272                eprint_rate_config("read", &rate_limiter.config.read);
273                eprint_rate_config("write", &rate_limiter.config.write);
274            }
275        }
276
277        let store = match self.cloud_type {
278            CloudType::Aws => {
279                #[cfg(feature = "aws")]
280                {
281                    let store = options
282                        .build_aws(
283                            self.path.clone(),
284                            clear_cached_credentials,
285                            self.rate_limiter.as_deref(),
286                        )
287                        .await?;
288                    Ok::<_, PolarsError>(Arc::new(store) as Arc<dyn ObjectStore>)
289                }
290                #[cfg(not(feature = "aws"))]
291                return err_missing_feature("aws", &self.cloud_type);
292            },
293            CloudType::Gcp => {
294                #[cfg(feature = "gcp")]
295                {
296                    let store = options.build_gcp(
297                        self.path.clone(),
298                        clear_cached_credentials,
299                        self.rate_limiter.as_deref(),
300                    )?;
301
302                    Ok::<_, PolarsError>(Arc::new(store) as Arc<dyn ObjectStore>)
303                }
304                #[cfg(not(feature = "gcp"))]
305                return err_missing_feature("gcp", &self.cloud_type);
306            },
307            CloudType::Azure => {
308                {
309                    #[cfg(feature = "azure")]
310                    {
311                        let store = options.build_azure(
312                            self.path.clone(),
313                            clear_cached_credentials,
314                            self.rate_limiter.as_deref(),
315                        )?;
316                        Ok::<_, PolarsError>(Arc::new(store) as Arc<dyn ObjectStore>)
317                    }
318                }
319                #[cfg(not(feature = "azure"))]
320                return err_missing_feature("azure", &self.cloud_type);
321            },
322            CloudType::File => {
323                let local = LocalFileSystem::new();
324                Ok::<_, PolarsError>(Arc::new(local) as Arc<dyn ObjectStore>)
325            },
326            CloudType::Http => {
327                {
328                    #[cfg(feature = "http")]
329                    {
330                        let store = options.build_http(self.path.clone())?;
331                        PolarsResult::Ok(Arc::new(store) as Arc<dyn ObjectStore>)
332                    }
333                }
334                #[cfg(not(feature = "http"))]
335                return err_missing_feature("http", &cloud_location.scheme);
336            },
337            CloudType::Hf => panic!("impl error: unresolved hf:// path"),
338            CloudType::Ext(scheme) => {
339                let prefix = &self.path.as_str()[..self.path.authority_end_position()];
340
341                verbose_print_sensitive(|| {
342                    format!(
343                        "build external object_store: scheme='{}', prefix='{}', options={:?}",
344                        scheme, prefix, self.options
345                    )
346                });
347
348                let store = EXT_OBJECT_STORE_BUILDER_REGISTRY
349                    .read()
350                    .unwrap()
351                    .get(scheme)
352                    .ok_or_else(|| {
353                        polars_err!(
354                            ComputeError:
355                            "no object_store_builder registered for prefix: {}; \
356                             call register_object_store_builder() before executing queries \
357                             against the scheme: {}",
358                            prefix, scheme
359                        )
360                    })?
361                    .build(&self.path, self.options.as_ref())?;
362
363                return Ok(store);
364            },
365        }?;
366
367        Ok(store)
368    }
369
370    /// Note: Use `build_impl` for a non-caching version.
371    pub(super) async fn build(self) -> PolarsResult<PolarsObjectStore> {
372        let opt_cache_key = match self.cloud_type {
373            CloudType::Aws | CloudType::Gcp | CloudType::Azure => {
374                Some(path_and_creds_to_key(&self.path, self.options.as_ref())?)
375            },
376            CloudType::File | CloudType::Http | CloudType::Hf => None,
377            CloudType::Ext(scheme) => {
378                let registry = EXT_OBJECT_STORE_BUILDER_REGISTRY.read().unwrap();
379                let builder = registry.get(scheme).ok_or_else(|| {
380                    polars_err!(
381                        ComputeError:
382                        "no object_store_builder registered for scheme '{}'; \
383                         call register_object_store_builder() before executing queries \
384                         against this scheme",
385                        scheme
386                    )
387                })?;
388
389                let key = match builder.stable_cache_key(&self.path, self.options.as_ref()) {
390                    Some(key) => key,
391                    None => path_and_creds_to_key(&self.path, self.options.as_ref())?,
392                };
393
394                Some(key)
395            },
396        };
397
398        let opt_cache_write_guard = if let Some(cache_key) = opt_cache_key.as_deref() {
399            let cache = OBJECT_STORE_CACHE.read().await;
400
401            if let Some(store) = cache.get(cache_key) {
402                return Ok(store.clone());
403            }
404
405            drop(cache);
406
407            let cache = OBJECT_STORE_CACHE.write().await;
408
409            if let Some(store) = cache.get(cache_key) {
410                return Ok(store.clone());
411            }
412
413            Some(cache)
414        } else {
415            None
416        };
417
418        let store = self.build_impl(false).await?;
419        let store = PolarsObjectStore::new_from_inner(store, self);
420
421        if let Some(mut cache) = opt_cache_write_guard {
422            // Clear the cache if we surpass a certain amount of buckets.
423            if cache.len() >= 8 {
424                if config::verbose() {
425                    eprintln!(
426                        "build_object_store: clearing store cache (cache.len(): {})",
427                        cache.len()
428                    );
429                }
430                cache.clear()
431            }
432
433            cache.insert(opt_cache_key.unwrap(), store.clone());
434        }
435
436        Ok(store)
437    }
438
439    pub(crate) fn is_azure(&self) -> bool {
440        matches!(&self.cloud_type, CloudType::Azure)
441    }
442}
443
444/// Build an [`ObjectStore`] based on the URL and passed in url. Return the cloud location and an implementation of the object store.
445pub async fn build_object_store(
446    path: PlRefPath,
447    #[cfg_attr(
448        not(any(feature = "aws", feature = "gcp", feature = "azure")),
449        allow(unused_variables)
450    )]
451    options: Option<&CloudOptions>,
452    glob: bool,
453) -> PolarsResult<(CloudLocation, PolarsObjectStore)> {
454    let path = path.to_absolute_path()?.into_owned();
455
456    let cloud_type = path
457        .scheme()
458        .map_or(CloudType::File, CloudType::from_cloud_scheme);
459    let cloud_location = CloudLocation::new(path.clone(), glob)?;
460
461    let rate_limiter = match cloud_type {
462        CloudType::Aws | CloudType::Azure | CloudType::Gcp => Some(Arc::new(RateLimiter::new(
463            options
464                .map(|options| options.rate_limit_config)
465                .unwrap_or_default()
466                .into(),
467        ))),
468        _ => None,
469    };
470
471    let store = PolarsObjectStoreBuilder {
472        path,
473        cloud_type,
474        options: options.cloned(),
475        rate_limiter,
476    }
477    .build()
478    .await?;
479
480    Ok((cloud_location, store))
481}
482
483mod test {
484    #[test]
485    fn test_object_path_from_str() {
486        use super::object_path_from_str;
487
488        let path = "%25";
489        let out = object_path_from_str(path).unwrap();
490
491        assert_eq!(out.as_ref(), path);
492    }
493}
494
495#[cfg(all(test, feature = "cloud"))]
496mod ext_store_tests {
497    use std::sync::Arc;
498
499    use object_store::ObjectStore;
500    use object_store::memory::InMemory;
501    use polars_utils::pl_path::PlRefPath;
502    use polars_utils::relaxed_cell::RelaxedCell;
503
504    use super::*;
505
506    struct TestBuilder {
507        store: Arc<dyn ObjectStore + Send + Sync>,
508        build_count: RelaxedCell<usize>,
509    }
510
511    impl TestBuilder {
512        fn new() -> Arc<Self> {
513            Arc::new(Self {
514                store: Arc::new(InMemory::new()),
515                build_count: RelaxedCell::new_usize(0),
516            })
517        }
518
519        fn build_count(&self) -> usize {
520            self.build_count.load()
521        }
522
523        fn inc_build_count(&self) {
524            self.build_count.fetch_add(1);
525        }
526    }
527
528    impl ExtObjectStoreBuilder for TestBuilder {
529        fn build(
530            &self,
531            _url: &PlRefPath,
532            _options: Option<&CloudOptions>,
533        ) -> PolarsResult<Arc<dyn ObjectStore + Send + Sync>> {
534            self.inc_build_count();
535            Ok(self.store.clone())
536        }
537    }
538
539    #[tokio::test]
540    async fn test_register_and_resolve() -> PolarsResult<()> {
541        let builder = TestBuilder::new();
542        polars_utils::pl_path::_allow_ext_scheme("pl-test1")?;
543        register_object_store_builder("pl-test1", builder.clone()).unwrap();
544
545        let path = PlRefPath::new("pl-test1://host:1234/data/file.parquet");
546        let result = build_object_store(path, None, false).await;
547        assert!(result.is_ok());
548        assert_eq!(builder.build_count(), 1);
549
550        deregister_object_store_builder("pl-test1");
551        polars_utils::pl_path::_disallow_ext_scheme("pl-test1");
552        Ok(())
553    }
554
555    #[tokio::test]
556    async fn test_cache_hit_after_first_build() -> PolarsResult<()> {
557        let builder = TestBuilder::new();
558        polars_utils::pl_path::_allow_ext_scheme("pl-test2")?;
559        register_object_store_builder("pl-test2", builder.clone()).unwrap();
560
561        let path = PlRefPath::new("pl-test2://host:1234/data/file.parquet");
562
563        // First call — cache miss, build_impl called
564        build_object_store(path.clone(), None, false).await.unwrap();
565        assert_eq!(builder.build_count(), 1);
566
567        // Second call — cache hit, build_impl not called
568        build_object_store(path.clone(), None, false).await.unwrap();
569        assert_eq!(builder.build_count(), 1);
570
571        deregister_object_store_builder("pl-test2");
572        polars_utils::pl_path::_disallow_ext_scheme("pl-test2");
573        Ok(())
574    }
575
576    #[test]
577    fn test_native_scheme_rejected() {
578        let builder = TestBuilder::new();
579        let result = register_object_store_builder("s3", builder);
580        assert!(result.is_err());
581        assert!(result.unwrap_err().to_string().contains("handled natively"));
582    }
583
584    #[tokio::test]
585    async fn test_stable_cache_key_override() -> PolarsResult<()> {
586        #[derive(Clone)]
587        struct AuthorityOnlyBuilder {
588            store: Arc<dyn ObjectStore + Send + Sync>,
589            build_count: Arc<RelaxedCell<usize>>,
590        }
591
592        impl AuthorityOnlyBuilder {
593            fn new() -> Self {
594                Self {
595                    store: Arc::new(InMemory::new()),
596                    build_count: Arc::new(RelaxedCell::new_usize(0)),
597                }
598            }
599
600            fn build_count(&self) -> usize {
601                self.build_count.load()
602            }
603
604            fn inc_build_count(&self) -> usize {
605                self.build_count.fetch_add(1)
606            }
607        }
608
609        impl ExtObjectStoreBuilder for AuthorityOnlyBuilder {
610            fn build(
611                &self,
612                _url: &PlRefPath,
613                _options: Option<&CloudOptions>,
614            ) -> PolarsResult<Arc<dyn ObjectStore + Send + Sync>> {
615                self.inc_build_count();
616                Ok(self.store.clone())
617            }
618
619            fn stable_cache_key(
620                &self,
621                url: &PlRefPath,
622                _options: Option<&CloudOptions>,
623            ) -> Option<Vec<u8>> {
624                let authority = &url.as_str()[..url.authority_end_position()];
625                Some(authority.as_bytes().to_vec())
626            }
627        }
628
629        let builder = AuthorityOnlyBuilder::new();
630        polars_utils::pl_path::_allow_ext_scheme("pl-test3")?;
631        register_object_store_builder("pl-test3", Arc::new(builder.clone())).unwrap();
632
633        use crate::cloud::{CloudConfig, CloudOptions};
634
635        let options_a = CloudOptions {
636            config: Some(CloudConfig::Ext {
637                options: vec![("user".to_string(), "alice".to_string())],
638            }),
639            ..CloudOptions::default()
640        };
641
642        let options_b = CloudOptions {
643            config: Some(CloudConfig::Ext {
644                options: vec![("user".to_string(), "bob".to_string())],
645            }),
646            ..CloudOptions::default()
647        };
648
649        let path = PlRefPath::new("pl-test3://host:1234/data/file.parquet");
650
651        build_object_store(path.clone(), Some(&options_a), false)
652            .await
653            .unwrap();
654        build_object_store(path.clone(), Some(&options_b), false)
655            .await
656            .unwrap();
657
658        assert_eq!(builder.build_count(), 1);
659
660        deregister_object_store_builder("pl-test3");
661        polars_utils::pl_path::_disallow_ext_scheme("pl-test3");
662        Ok(())
663    }
664
665    #[tokio::test]
666    async fn test_storage_options_passed_to_builder() -> PolarsResult<()> {
667        use crate::cloud::{CloudConfig, CloudOptions};
668
669        #[allow(clippy::type_complexity)]
670        struct CapturingBuilder {
671            received_options: Arc<std::sync::Mutex<Option<Vec<(String, String)>>>>,
672            store: Arc<dyn ObjectStore + Send + Sync>,
673        }
674
675        impl ExtObjectStoreBuilder for CapturingBuilder {
676            fn build(
677                &self,
678                _url: &PlRefPath,
679                options: Option<&CloudOptions>,
680            ) -> PolarsResult<Arc<dyn ObjectStore + Send + Sync>> {
681                let captured = match options {
682                    Some(CloudOptions {
683                        config: Some(CloudConfig::Ext { options }),
684                        ..
685                    }) => Some(options.clone()),
686                    _ => None,
687                };
688                *self.received_options.lock().unwrap() = captured;
689                Ok(self.store.clone())
690            }
691        }
692
693        let received = Arc::new(std::sync::Mutex::new(None));
694
695        let builder = Arc::new(CapturingBuilder {
696            received_options: received.clone(),
697            store: Arc::new(InMemory::new()),
698        });
699
700        polars_utils::pl_path::_allow_ext_scheme("pl-test4")?;
701        register_object_store_builder("pl-test4", builder).unwrap();
702
703        let options = CloudOptions {
704            config: Some(CloudConfig::Ext {
705                options: vec![
706                    ("user".to_string(), "hadoop".to_string()),
707                    ("token".to_string(), "abc123".to_string()),
708                ],
709            }),
710            ..CloudOptions::default()
711        };
712
713        let path = PlRefPath::new("pl-test4://host:1234/data/file.parquet");
714        build_object_store(path, Some(&options), false)
715            .await
716            .unwrap();
717
718        let captured = received.lock().unwrap().clone().unwrap();
719        assert_eq!(captured.len(), 2);
720        assert!(captured.iter().any(|(k, v)| k == "user" && v == "hadoop"));
721        assert!(captured.iter().any(|(k, v)| k == "token" && v == "abc123"));
722
723        deregister_object_store_builder("pl-test4");
724        polars_utils::pl_path::_disallow_ext_scheme("pl-test4");
725        Ok(())
726    }
727}