1use std::borrow::Cow;
2use std::collections::VecDeque;
3use std::path::{Path, PathBuf};
4use std::sync::LazyLock;
5
6use polars_buffer::Buffer;
7use polars_core::config;
8use polars_core::error::{PolarsResult, polars_bail, to_compute_err};
9use polars_utils::pl_path::{CloudScheme, PlRefPath};
10use polars_utils::pl_str::PlSmallStr;
11
12#[cfg(feature = "cloud")]
13mod hugging_face;
14
15use crate::cloud::CloudOptions;
16
17#[allow(clippy::bind_instead_of_map)]
18pub static POLARS_TEMP_DIR_BASE_PATH: LazyLock<Box<Path>> = LazyLock::new(|| {
19 (|| {
20 let verbose = config::verbose();
21
22 let path = if let Ok(v) = std::env::var("POLARS_TEMP_DIR").map(PathBuf::from) {
23 if verbose {
24 eprintln!("init_temp_dir: sourced from POLARS_TEMP_DIR")
25 }
26 v
27 } else if cfg!(target_family = "unix") {
28 let id = std::env::var("USER")
29 .inspect(|_| {
30 if verbose {
31 eprintln!("init_temp_dir: sourced $USER")
32 }
33 })
34 .or_else(|_e| {
35 #[cfg(feature = "file_cache")]
38 {
39 std::env::var("HOME")
40 .inspect(|_| {
41 if verbose {
42 eprintln!("init_temp_dir: sourced $HOME")
43 }
44 })
45 .map(|x| blake3::hash(x.as_bytes()).to_hex()[..32].to_string())
46 }
47 #[cfg(not(feature = "file_cache"))]
48 {
49 Err(_e)
50 }
51 });
52
53 if let Ok(v) = id {
54 std::env::temp_dir().join(format!("polars-{v}/"))
55 } else {
56 return Err(std::io::Error::other(
57 "could not load $USER or $HOME environment variables",
58 ));
59 }
60 } else if cfg!(target_family = "windows") {
61 std::env::temp_dir().join("polars/")
65 } else {
66 std::env::temp_dir().join("polars/")
67 }
68 .into_boxed_path();
69
70 let perm_result = create_dir_owner_only(path.as_ref());
71
72 if std::env::var("POLARS_ALLOW_UNSECURED_TEMP_DIR").as_deref() != Ok("1") {
73 perm_result?;
74 }
75
76 std::io::Result::Ok(path)
77 })()
78 .map_err(|e| {
79 std::io::Error::new(
80 e.kind(),
81 format!(
82 "error initializing temporary directory: {e} \
83 consider explicitly setting POLARS_TEMP_DIR"
84 ),
85 )
86 })
87 .unwrap()
88});
89
90pub fn create_dir_owner_only(path: &Path) -> std::io::Result<()> {
92 std::fs::create_dir_all(path)?;
93
94 #[cfg(target_family = "unix")]
95 {
96 use std::os::unix::fs::PermissionsExt;
97
98 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))?;
99 let perms = std::fs::metadata(path)?.permissions();
100
101 if (perms.mode() % 0o1000) != 0o700 {
102 return Err(std::io::Error::other(format!(
103 "error setting directory permissions: permission mismatch: {perms:?} (path = {path:?})"
104 )));
105 }
106 }
107
108 Ok(())
109}
110
111pub fn resolve_homedir<'a, S: AsRef<Path> + ?Sized>(path: &'a S) -> Cow<'a, Path> {
113 return inner(path.as_ref());
114
115 fn inner(path: &Path) -> Cow<'_, Path> {
116 if path.starts_with("~") {
117 #[cfg(not(target_family = "wasm"))]
119 if let Some(homedir) = home::home_dir() {
120 return Cow::Owned(homedir.join(path.strip_prefix("~").unwrap()));
121 }
122 }
123
124 Cow::Borrowed(path)
125 }
126}
127
128fn has_glob(path: &[u8]) -> bool {
129 return get_glob_start_idx(path).is_some();
130
131 fn get_glob_start_idx(path: &[u8]) -> Option<usize> {
133 memchr::memchr3(b'*', b'?', b'[', path)
134 }
135}
136
137fn is_file_uri_with_escape(path: &PlRefPath) -> bool {
139 path.scheme().is_some_and(|s| s.is_file()) && path.strip_scheme().contains('%')
140}
141
142pub fn decode_file_uri_paths(paths: &[PlRefPath], glob: bool) -> Cow<'_, [PlRefPath]> {
157 if !paths.iter().any(is_file_uri_with_escape) {
159 return Cow::Borrowed(paths);
160 }
161
162 Cow::Owned(
163 paths
164 .iter()
165 .map(|path| {
166 if is_file_uri_with_escape(path)
167 && let Some(decoded) = decode_file_uri_path(path.strip_scheme(), glob)
168 {
169 PlRefPath::new(decoded)
170 } else {
171 path.clone()
174 }
175 })
176 .collect(),
177 )
178}
179
180fn decode_file_uri_path(path: &str, glob: bool) -> Option<String> {
184 let decoded = percent_encoding::percent_decode_str(path)
185 .decode_utf8()
186 .ok()?;
187 let path = strip_windows_drive_slash(&decoded);
188 Some(if glob {
189 glob::Pattern::escape(path)
190 } else {
191 path.to_owned()
192 })
193}
194
195fn strip_windows_drive_slash(path: &str) -> &str {
199 #[cfg(target_family = "windows")]
200 {
201 let b = path.as_bytes();
202 if b.len() >= 3 && b[0] == b'/' && b[1].is_ascii_alphabetic() && b[2] == b':' {
203 return &path[1..];
204 }
205 }
206 path
207}
208
209pub fn expanded_from_single_directory(paths: &[PlRefPath], expanded_paths: &[PlRefPath]) -> bool {
211 paths.len() == 1 && !has_glob(paths[0].strip_scheme().as_bytes())
213 && {
215 (
216 !paths[0].has_scheme() && paths[0].as_std_path().is_dir()
218 )
219 || (
220 expanded_paths.is_empty() || (paths[0] != expanded_paths[0])
223 )
224 }
225}
226
227pub async fn expand_paths(
229 paths: &[PlRefPath],
230 glob: bool,
231 hidden_file_prefix: &[PlSmallStr],
232 #[allow(unused_variables)] cloud_options: &mut Option<CloudOptions>,
233) -> PolarsResult<Buffer<PlRefPath>> {
234 expand_paths_hive(paths, glob, hidden_file_prefix, cloud_options, false)
235 .await
236 .map(|x| x.0)
237}
238
239struct HiveIdxTracker<'a> {
240 idx: usize,
241 paths: &'a [PlRefPath],
242 check_directory_level: bool,
243}
244
245impl HiveIdxTracker<'_> {
246 fn update(&mut self, i: usize, path_idx: usize) -> PolarsResult<()> {
247 let check_directory_level = self.check_directory_level;
248 let paths = self.paths;
249
250 if check_directory_level
251 && ![usize::MAX, i].contains(&self.idx)
252 && (path_idx > 0 && paths[path_idx].parent() != paths[path_idx - 1].parent())
254 {
255 polars_bail!(
256 InvalidOperation:
257 "attempted to read from different directory levels with hive partitioning enabled: \
258 first path: {}, second path: {}",
259 &paths[path_idx - 1],
260 &paths[path_idx],
261 )
262 } else {
263 self.idx = std::cmp::min(self.idx, i);
264 Ok(())
265 }
266 }
267}
268
269#[cfg(feature = "cloud")]
270async fn expand_path_cloud(
271 path: PlRefPath,
272 cloud_options: Option<&CloudOptions>,
273 glob: bool,
274 first_path_has_scheme: bool,
275) -> PolarsResult<(usize, Vec<PlRefPath>)> {
276 let format_path = |scheme: &str, bucket: &str, location: &str| {
277 if first_path_has_scheme {
278 format!("{scheme}://{bucket}/{location}")
279 } else {
280 format!("/{location}")
281 }
282 };
283
284 use polars_utils::_limit_path_len_io_err;
285
286 use crate::cloud::object_path_from_str;
287 let path_str = path.as_str();
288
289 let (cloud_location, store) =
290 crate::cloud::build_object_store(path.clone(), cloud_options, glob).await?;
291 let prefix = object_path_from_str(&cloud_location.prefix)?;
292
293 let out = if !path_str.ends_with("/") && (!glob || cloud_location.expansion.is_none()) && {
294 path.has_scheme() || path.as_std_path().is_file()
299 } {
300 (
301 0,
302 vec![PlRefPath::new(format_path(
303 cloud_location.scheme,
304 &cloud_location.bucket,
305 prefix.as_ref(),
306 ))],
307 )
308 } else {
309 use futures::TryStreamExt;
310
311 if !path.has_scheme() {
312 path.as_std_path()
317 .metadata()
318 .map_err(|err| _limit_path_len_io_err(path.as_std_path(), err))?;
319 }
320
321 let cloud_location = &cloud_location;
322 let prefix_ref = &prefix;
323
324 let mut paths = store
325 .exec_with_rebuild_retry_on_err(|s| async move {
326 let out = s
327 .list(Some(prefix_ref))
328 .try_filter_map(|x| async move {
329 let out = (x.size > 0).then(|| {
330 PlRefPath::new({
331 format_path(
332 cloud_location.scheme,
333 &cloud_location.bucket,
334 x.location.as_ref(),
335 )
336 })
337 });
338 Ok(out)
339 })
340 .try_collect::<Vec<_>>()
341 .await?;
342
343 Ok(out)
344 })
345 .await?;
346
347 let mut prefix = prefix.to_string();
350 if path_str.ends_with('/') && !prefix.ends_with('/') {
351 prefix.push('/')
352 };
353
354 paths.sort_unstable();
355
356 (
357 format_path(
358 cloud_location.scheme,
359 &cloud_location.bucket,
360 prefix.as_ref(),
361 )
362 .len(),
363 paths,
364 )
365 };
366
367 PolarsResult::Ok(out)
368}
369
370pub async fn expand_paths_hive(
374 paths: &[PlRefPath],
375 glob: bool,
376 hidden_file_prefix: &[PlSmallStr],
377 #[allow(unused_variables)] cloud_options: &mut Option<CloudOptions>,
378 check_directory_level: bool,
379) -> PolarsResult<(Buffer<PlRefPath>, usize)> {
380 let Some(first_path) = paths.first() else {
381 return Ok((vec![].into(), 0));
382 };
383
384 let first_path_has_scheme = first_path.has_scheme();
385
386 let is_hidden_file = move |path: &PlRefPath| {
387 path.file_name()
388 .and_then(|x| x.to_str())
389 .is_some_and(|file_name| {
390 hidden_file_prefix
391 .iter()
392 .any(|x| file_name.starts_with(x.as_str()))
393 })
394 };
395
396 let mut out_paths = OutPaths {
397 paths: vec![],
398 exts: [None, None],
399 is_hidden_file: &is_hidden_file,
400 };
401
402 let mut hive_idx_tracker = HiveIdxTracker {
403 idx: usize::MAX,
404 paths,
405 check_directory_level,
406 };
407
408 if first_path_has_scheme || {
409 cfg!(not(target_family = "windows")) && polars_config::config().force_async()
410 } {
411 #[cfg(feature = "cloud")]
412 {
413 if first_path.scheme() == Some(CloudScheme::Hf) {
414 let (expand_start_idx, paths) = hugging_face::expand_paths_hf(
415 paths,
416 check_directory_level,
417 cloud_options,
418 glob,
419 )
420 .await?;
421
422 return Ok((paths.into(), expand_start_idx));
423 }
424
425 for (path_idx, path) in paths.iter().enumerate() {
426 use std::borrow::Cow;
427
428 let mut path = Cow::Borrowed(path);
429
430 if matches!(path.scheme(), Some(CloudScheme::Http | CloudScheme::Https)) {
431 let mut rewrite_aws = false;
432
433 #[cfg(feature = "aws")]
434 if let Some(p) = (|| {
435 use crate::cloud::CloudConfig;
436
437 let after_scheme = path.strip_scheme();
440
441 let bucket_end = after_scheme.find(".s3.")?;
442 let offset = bucket_end + 4;
443 let region_end = offset + after_scheme[offset..].find(".amazonaws.com/")?;
445
446 if after_scheme[..region_end].contains('/') || after_scheme.contains('?') {
448 return None;
449 }
450
451 let bucket = &after_scheme[..bucket_end];
452 let region = &after_scheme[bucket_end + 4..region_end];
453 let key = &after_scheme[region_end + 15..];
454
455 if let CloudConfig::Aws(configs) = cloud_options
456 .get_or_insert_default()
457 .config
458 .get_or_insert_with(|| CloudConfig::Aws(Vec::with_capacity(1)))
459 {
460 use object_store::aws::AmazonS3ConfigKey;
461
462 if !matches!(configs.last(), Some((AmazonS3ConfigKey::Region, _))) {
463 configs.push((AmazonS3ConfigKey::Region, region.into()))
464 }
465 }
466
467 Some(format!("s3://{bucket}/{key}"))
468 })() {
469 path = Cow::Owned(PlRefPath::new(p));
470 rewrite_aws = true;
471 }
472
473 if !rewrite_aws {
474 out_paths.push(path.into_owned());
475 hive_idx_tracker.update(0, path_idx)?;
476 continue;
477 }
478 }
479
480 let sort_start_idx = out_paths.paths.len();
481
482 if glob && has_glob(path.as_bytes()) {
483 hive_idx_tracker.update(0, path_idx)?;
484
485 let iter = crate::async_glob(path.into_owned(), cloud_options.as_ref()).await?;
486
487 if first_path_has_scheme {
488 out_paths.extend(iter.into_iter().map(PlRefPath::new))
489 } else {
490 out_paths.extend(iter.iter().map(|x| &x[7..]).map(PlRefPath::new))
493 };
494 } else {
495 let (expand_start_idx, paths) = expand_path_cloud(
496 path.into_owned(),
497 cloud_options.as_ref(),
498 glob,
499 first_path_has_scheme,
500 )
501 .await?;
502 out_paths.extend_from_slice(&paths);
503 hive_idx_tracker.update(expand_start_idx, path_idx)?;
504 };
505
506 if let Some(mut_slice) = out_paths.paths.get_mut(sort_start_idx..) {
507 <[PlRefPath]>::sort_unstable(mut_slice);
508 }
509 }
510 }
511 #[cfg(not(feature = "cloud"))]
512 panic!("Feature `cloud` must be enabled to use globbing patterns with cloud urls.")
513 } else {
514 let mut stack: VecDeque<Cow<'_, Path>> = VecDeque::new();
515 let mut paths_scratch: Vec<PathBuf> = vec![];
516
517 for (path_idx, path) in paths.iter().enumerate() {
518 stack.clear();
519 let sort_start_idx = out_paths.paths.len();
520
521 if path.as_std_path().is_dir() {
522 let i = path.as_str().len();
523
524 hive_idx_tracker.update(i, path_idx)?;
525
526 stack.push_back(Cow::Borrowed(path.as_std_path()));
527
528 while let Some(dir) = stack.pop_front() {
529 let mut last_err = Ok(());
530
531 paths_scratch.clear();
532 paths_scratch.extend(std::fs::read_dir(dir)?.map_while(|x| {
533 match x.map(|x| x.path()) {
534 Ok(v) => Some(v),
535 Err(e) => {
536 last_err = Err(e);
537 None
538 },
539 }
540 }));
541
542 last_err?;
543
544 for path in paths_scratch.drain(..) {
545 let md = path.metadata()?;
546
547 if md.is_dir() {
548 stack.push_back(Cow::Owned(path));
549 } else if md.len() > 0 {
550 out_paths.push(PlRefPath::try_from_path(&path)?);
551 }
552 }
553 }
554 } else if glob && has_glob(path.as_bytes()) {
555 hive_idx_tracker.update(0, path_idx)?;
556
557 let Ok(paths) = glob::glob(path.as_str()) else {
558 polars_bail!(ComputeError: "invalid glob pattern given")
559 };
560
561 for path in paths {
562 let path = path.map_err(to_compute_err)?;
563 let md = path.metadata()?;
564 if !md.is_dir() && md.len() > 0 {
565 out_paths.push(PlRefPath::try_from_path(&path)?);
566 }
567 }
568 } else {
569 hive_idx_tracker.update(0, path_idx)?;
570 out_paths.push(path.clone());
571 };
572
573 if let Some(mut_slice) = out_paths.paths.get_mut(sort_start_idx..) {
574 <[PlRefPath]>::sort_unstable(mut_slice);
575 }
576 }
577 }
578
579 if expanded_from_single_directory(paths, out_paths.paths.as_slice()) {
580 if let [Some((_, p1)), Some((_, p2))] = out_paths.exts {
581 polars_bail!(
582 InvalidOperation: "directory contained paths with different file extensions: \
583 first path: {}, second path: {}. Please use a glob pattern to explicitly specify \
584 which files to read (e.g. 'dir/**/*', 'dir/**/*.parquet')",
585 &p1, &p2
586 )
587 }
588 }
589
590 return Ok((out_paths.paths.into(), hive_idx_tracker.idx));
591
592 struct OutPaths<'a, F: Fn(&PlRefPath) -> bool> {
595 paths: Vec<PlRefPath>,
596 exts: [Option<(PlSmallStr, PlRefPath)>; 2],
597 is_hidden_file: &'a F,
598 }
599
600 impl<F> OutPaths<'_, F>
601 where
602 F: Fn(&PlRefPath) -> bool,
603 {
604 fn push(&mut self, value: PlRefPath) {
605 if (self.is_hidden_file)(&value) {
606 return;
607 }
608
609 let exts = &mut self.exts;
610 Self::update_ext_status(exts, &value);
611
612 self.paths.push(value)
613 }
614
615 fn extend(&mut self, values: impl IntoIterator<Item = PlRefPath>) {
616 let exts = &mut self.exts;
617
618 self.paths.extend(
619 values
620 .into_iter()
621 .filter(|x| !(self.is_hidden_file)(x))
622 .inspect(|x| {
623 Self::update_ext_status(exts, x);
624 }),
625 )
626 }
627
628 fn extend_from_slice(&mut self, values: &[PlRefPath]) {
629 self.extend(values.iter().cloned())
630 }
631
632 fn update_ext_status(exts: &mut [Option<(PlSmallStr, PlRefPath)>; 2], value: &PlRefPath) {
633 let ext = value
634 .extension()
635 .map_or(PlSmallStr::EMPTY, PlSmallStr::from);
636
637 if exts[0].is_none() {
638 exts[0] = Some((ext, value.clone()));
639 } else if exts[1].is_none() && ext != exts[0].as_ref().unwrap().0 {
640 exts[1] = Some((ext, value.clone()));
641 }
642 }
643 }
644}
645
646#[cfg(feature = "file_cache")]
648pub(crate) fn ensure_directory_init(path: &Path) -> std::io::Result<()> {
649 let result = std::fs::create_dir_all(path);
650
651 if path.is_dir() { Ok(()) } else { result }
652}
653
654#[cfg(test)]
655mod tests {
656 use std::path::PathBuf;
657
658 use polars_core::runtime::ASYNC;
659 use polars_utils::pl_path::PlRefPath;
660
661 use super::resolve_homedir;
662
663 #[cfg(not(target_os = "windows"))]
664 #[test]
665 fn test_resolve_homedir() {
666 let paths: Vec<PathBuf> = vec![
667 "~/dir1/dir2/test.csv".into(),
668 "/abs/path/test.csv".into(),
669 "rel/path/test.csv".into(),
670 "/".into(),
671 "~".into(),
672 ];
673
674 let resolved: Vec<PathBuf> = paths
675 .iter()
676 .map(resolve_homedir)
677 .map(|x| x.into_owned())
678 .collect();
679
680 assert_eq!(resolved[0].file_name(), paths[0].file_name());
681 assert!(resolved[0].is_absolute());
682 assert_eq!(resolved[1], paths[1]);
683 assert_eq!(resolved[2], paths[2]);
684 assert_eq!(resolved[3], paths[3]);
685 assert!(resolved[4].is_absolute());
686 }
687
688 #[cfg(target_os = "windows")]
689 #[test]
690 fn test_resolve_homedir_windows() {
691 let paths: Vec<PathBuf> = vec![
692 r#"c:\Users\user1\test.csv"#.into(),
693 r#"~\user1\test.csv"#.into(),
694 "~".into(),
695 ];
696
697 let resolved: Vec<PathBuf> = paths
698 .iter()
699 .map(resolve_homedir)
700 .map(|x| x.into_owned())
701 .collect();
702
703 assert_eq!(resolved[0], paths[0]);
704 assert_eq!(resolved[1].file_name(), paths[1].file_name());
705 assert!(resolved[1].is_absolute());
706 assert!(resolved[2].is_absolute());
707 }
708
709 #[test]
710 fn test_http_path_with_query_parameters_is_not_expanded_as_glob() {
711 use super::expand_paths;
715
716 let path = "https://pola.rs/test.csv?token=bear";
717 let paths = &[PlRefPath::new(path)];
718 let out = ASYNC
719 .block_on(expand_paths(paths, true, &[], &mut None))
720 .unwrap();
721 assert_eq!(out.as_ref(), paths);
722 }
723}