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