Skip to main content

polars_io/path_utils/
mod.rs

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_core::runtime::ASYNC;
10use polars_utils::pl_path::{CloudScheme, PlRefPath};
11use polars_utils::pl_str::PlSmallStr;
12
13#[cfg(feature = "cloud")]
14mod hugging_face;
15
16use crate::cloud::CloudOptions;
17
18#[allow(clippy::bind_instead_of_map)]
19pub static POLARS_TEMP_DIR_BASE_PATH: LazyLock<Box<Path>> = LazyLock::new(|| {
20    (|| {
21        let verbose = config::verbose();
22
23        let path = if let Ok(v) = std::env::var("POLARS_TEMP_DIR").map(PathBuf::from) {
24            if verbose {
25                eprintln!("init_temp_dir: sourced from POLARS_TEMP_DIR")
26            }
27            v
28        } else if cfg!(target_family = "unix") {
29            let id = std::env::var("USER")
30                .inspect(|_| {
31                    if verbose {
32                        eprintln!("init_temp_dir: sourced $USER")
33                    }
34                })
35                .or_else(|_e| {
36                    // We shouldn't hit here, but we can fallback to hashing $HOME if blake3 is
37                    // available (it is available when file_cache is activated).
38                    #[cfg(feature = "file_cache")]
39                    {
40                        std::env::var("HOME")
41                            .inspect(|_| {
42                                if verbose {
43                                    eprintln!("init_temp_dir: sourced $HOME")
44                                }
45                            })
46                            .map(|x| blake3::hash(x.as_bytes()).to_hex()[..32].to_string())
47                    }
48                    #[cfg(not(feature = "file_cache"))]
49                    {
50                        Err(_e)
51                    }
52                });
53
54            if let Ok(v) = id {
55                std::env::temp_dir().join(format!("polars-{v}/"))
56            } else {
57                return Err(std::io::Error::other(
58                    "could not load $USER or $HOME environment variables",
59                ));
60            }
61        } else if cfg!(target_family = "windows") {
62            // Setting permissions on Windows is not as easy compared to Unix, but fortunately
63            // the default temporary directory location is underneath the user profile, so we
64            // shouldn't need to do anything.
65            std::env::temp_dir().join("polars/")
66        } else {
67            std::env::temp_dir().join("polars/")
68        }
69        .into_boxed_path();
70
71        let perm_result = create_dir_owner_only(path.as_ref());
72
73        if std::env::var("POLARS_ALLOW_UNSECURED_TEMP_DIR").as_deref() != Ok("1") {
74            perm_result?;
75        }
76
77        std::io::Result::Ok(path)
78    })()
79    .map_err(|e| {
80        std::io::Error::new(
81            e.kind(),
82            format!(
83                "error initializing temporary directory: {e} \
84                 consider explicitly setting POLARS_TEMP_DIR"
85            ),
86        )
87    })
88    .unwrap()
89});
90
91/// Create a directory (and parents) with owner-only permissions (0o700) on Unix.
92pub fn create_dir_owner_only(path: &Path) -> std::io::Result<()> {
93    std::fs::create_dir_all(path)?;
94
95    #[cfg(target_family = "unix")]
96    {
97        use std::os::unix::fs::PermissionsExt;
98
99        std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))?;
100        let perms = std::fs::metadata(path)?.permissions();
101
102        if (perms.mode() % 0o1000) != 0o700 {
103            return Err(std::io::Error::other(format!(
104                "error setting directory permissions: permission mismatch: {perms:?} (path = {path:?})"
105            )));
106        }
107    }
108
109    Ok(())
110}
111
112/// Replaces a "~" in the Path with the home directory.
113pub fn resolve_homedir<'a, S: AsRef<Path> + ?Sized>(path: &'a S) -> Cow<'a, Path> {
114    return inner(path.as_ref());
115
116    fn inner(path: &Path) -> Cow<'_, Path> {
117        if path.starts_with("~") {
118            // home crate does not compile on wasm https://github.com/rust-lang/cargo/issues/12297
119            #[cfg(not(target_family = "wasm"))]
120            if let Some(homedir) = home::home_dir() {
121                return Cow::Owned(homedir.join(path.strip_prefix("~").unwrap()));
122            }
123        }
124
125        Cow::Borrowed(path)
126    }
127}
128
129fn has_glob(path: &[u8]) -> bool {
130    return get_glob_start_idx(path).is_some();
131
132    /// Get the index of the first occurrence of a glob symbol.
133    fn get_glob_start_idx(path: &[u8]) -> Option<usize> {
134        memchr::memchr3(b'*', b'?', b'[', path)
135    }
136}
137
138/// Returns `true` if `expanded_paths` were expanded from a single directory
139pub fn expanded_from_single_directory(paths: &[PlRefPath], expanded_paths: &[PlRefPath]) -> bool {
140    // Single input that isn't a glob
141    paths.len() == 1 && !has_glob(paths[0].strip_scheme().as_bytes())
142    // And isn't a file
143    && {
144        (
145            // For local paths, we can just use `is_dir`
146            !paths[0].has_scheme() && paths[0].as_std_path().is_dir()
147        )
148        || (
149            // For cloud paths, we determine that the input path isn't a file by checking that the
150            // output path differs.
151            expanded_paths.is_empty() || (paths[0] != expanded_paths[0])
152        )
153    }
154}
155
156/// Recursively traverses directories and expands globs if `glob` is `true`.
157pub async fn expand_paths(
158    paths: &[PlRefPath],
159    glob: bool,
160    hidden_file_prefix: &[PlSmallStr],
161    #[allow(unused_variables)] cloud_options: &mut Option<CloudOptions>,
162) -> PolarsResult<Buffer<PlRefPath>> {
163    expand_paths_hive(paths, glob, hidden_file_prefix, cloud_options, false)
164        .await
165        .map(|x| x.0)
166}
167
168struct HiveIdxTracker<'a> {
169    idx: usize,
170    paths: &'a [PlRefPath],
171    check_directory_level: bool,
172}
173
174impl HiveIdxTracker<'_> {
175    fn update(&mut self, i: usize, path_idx: usize) -> PolarsResult<()> {
176        let check_directory_level = self.check_directory_level;
177        let paths = self.paths;
178
179        if check_directory_level
180            && ![usize::MAX, i].contains(&self.idx)
181            // They could still be the same directory level, just with different name length
182            && (path_idx > 0 && paths[path_idx].parent() != paths[path_idx - 1].parent())
183        {
184            polars_bail!(
185                InvalidOperation:
186                "attempted to read from different directory levels with hive partitioning enabled: \
187                first path: {}, second path: {}",
188                &paths[path_idx - 1],
189                &paths[path_idx],
190            )
191        } else {
192            self.idx = std::cmp::min(self.idx, i);
193            Ok(())
194        }
195    }
196}
197
198#[cfg(feature = "cloud")]
199async fn expand_path_cloud(
200    path: PlRefPath,
201    cloud_options: Option<&CloudOptions>,
202    glob: bool,
203    first_path_has_scheme: bool,
204) -> PolarsResult<(usize, Vec<PlRefPath>)> {
205    let format_path = |scheme: &str, bucket: &str, location: &str| {
206        if first_path_has_scheme {
207            format!("{scheme}://{bucket}/{location}")
208        } else {
209            format!("/{location}")
210        }
211    };
212
213    use polars_utils::_limit_path_len_io_err;
214
215    use crate::cloud::object_path_from_str;
216    let path_str = path.as_str();
217
218    let (cloud_location, store) =
219        crate::cloud::build_object_store(path.clone(), cloud_options, glob).await?;
220    let prefix = object_path_from_str(&cloud_location.prefix)?;
221
222    let out = if !path_str.ends_with("/") && (!glob || cloud_location.expansion.is_none()) && {
223        // We need to check if it is a directory for local paths (we can be here due
224        // to FORCE_ASYNC). For cloud paths the convention is that the user must add
225        // a trailing slash `/` to scan directories. We don't infer it as that would
226        // mean sending one network request per path serially (very slow).
227        path.has_scheme() || path.as_std_path().is_file()
228    } {
229        (
230            0,
231            vec![PlRefPath::new(format_path(
232                cloud_location.scheme,
233                &cloud_location.bucket,
234                prefix.as_ref(),
235            ))],
236        )
237    } else {
238        use futures::TryStreamExt;
239
240        if !path.has_scheme() {
241            // FORCE_ASYNC in the test suite wants us to raise a proper error message
242            // for non-existent file paths. Note we can't do this for cloud paths as
243            // there is no concept of a "directory" - a non-existent path is
244            // indistinguishable from an empty directory.
245            path.as_std_path()
246                .metadata()
247                .map_err(|err| _limit_path_len_io_err(path.as_std_path(), err))?;
248        }
249
250        let cloud_location = &cloud_location;
251        let prefix_ref = &prefix;
252
253        let mut paths = store
254            .exec_with_rebuild_retry_on_err(|s| async move {
255                let out = s
256                    .list(Some(prefix_ref))
257                    .try_filter_map(|x| async move {
258                        let out = (x.size > 0).then(|| {
259                            PlRefPath::new({
260                                format_path(
261                                    cloud_location.scheme,
262                                    &cloud_location.bucket,
263                                    x.location.as_ref(),
264                                )
265                            })
266                        });
267                        Ok(out)
268                    })
269                    .try_collect::<Vec<_>>()
270                    .await?;
271
272                Ok(out)
273            })
274            .await?;
275
276        // Since Path::parse() removes any trailing slash ('/'), we may need to restore it
277        // to calculate the right byte offset
278        let mut prefix = prefix.to_string();
279        if path_str.ends_with('/') && !prefix.ends_with('/') {
280            prefix.push('/')
281        };
282
283        paths.sort_unstable();
284
285        (
286            format_path(
287                cloud_location.scheme,
288                &cloud_location.bucket,
289                prefix.as_ref(),
290            )
291            .len(),
292            paths,
293        )
294    };
295
296    PolarsResult::Ok(out)
297}
298
299/// Recursively traverses directories and expands globs if `glob` is `true`.
300/// Returns the expanded paths and the index at which to start parsing hive
301/// partitions from the path.
302pub async fn expand_paths_hive(
303    paths: &[PlRefPath],
304    glob: bool,
305    hidden_file_prefix: &[PlSmallStr],
306    #[allow(unused_variables)] cloud_options: &mut Option<CloudOptions>,
307    check_directory_level: bool,
308) -> PolarsResult<(Buffer<PlRefPath>, usize)> {
309    let Some(first_path) = paths.first() else {
310        return Ok((vec![].into(), 0));
311    };
312
313    let first_path_has_scheme = first_path.has_scheme();
314
315    let is_hidden_file = move |path: &PlRefPath| {
316        path.file_name()
317            .and_then(|x| x.to_str())
318            .is_some_and(|file_name| {
319                hidden_file_prefix
320                    .iter()
321                    .any(|x| file_name.starts_with(x.as_str()))
322            })
323    };
324
325    let mut out_paths = OutPaths {
326        paths: vec![],
327        exts: [None, None],
328        is_hidden_file: &is_hidden_file,
329    };
330
331    let mut hive_idx_tracker = HiveIdxTracker {
332        idx: usize::MAX,
333        paths,
334        check_directory_level,
335    };
336
337    if first_path_has_scheme || {
338        cfg!(not(target_family = "windows")) && polars_config::config().force_async()
339    } {
340        #[cfg(feature = "cloud")]
341        {
342            if first_path.scheme() == Some(CloudScheme::Hf) {
343                let (expand_start_idx, paths) = hugging_face::expand_paths_hf(
344                    paths,
345                    check_directory_level,
346                    cloud_options,
347                    glob,
348                )
349                .await?;
350
351                return Ok((paths.into(), expand_start_idx));
352            }
353
354            for (path_idx, path) in paths.iter().enumerate() {
355                use std::borrow::Cow;
356
357                let mut path = Cow::Borrowed(path);
358
359                if matches!(path.scheme(), Some(CloudScheme::Http | CloudScheme::Https)) {
360                    let mut rewrite_aws = false;
361
362                    #[cfg(feature = "aws")]
363                    if let Some(p) = (|| {
364                        use crate::cloud::CloudConfig;
365
366                        // See https://docs.aws.amazon.com/AmazonS3/latest/userguide/VirtualHosting.html#virtual-hosted-style-access
367                        // Path format: https://bucket-name.s3.region-code.amazonaws.com/key-name
368                        let after_scheme = path.strip_scheme();
369
370                        let bucket_end = after_scheme.find(".s3.")?;
371                        let offset = bucket_end + 4;
372                        // Search after offset to prevent matching `.s3.amazonaws.com` (legacy global endpoint URL without region).
373                        let region_end = offset + after_scheme[offset..].find(".amazonaws.com/")?;
374
375                        // Do not convert if '?' (this can be query parameters for AWS presigned URLs).
376                        if after_scheme[..region_end].contains('/') || after_scheme.contains('?') {
377                            return None;
378                        }
379
380                        let bucket = &after_scheme[..bucket_end];
381                        let region = &after_scheme[bucket_end + 4..region_end];
382                        let key = &after_scheme[region_end + 15..];
383
384                        if let CloudConfig::Aws(configs) = cloud_options
385                            .get_or_insert_default()
386                            .config
387                            .get_or_insert_with(|| CloudConfig::Aws(Vec::with_capacity(1)))
388                        {
389                            use object_store::aws::AmazonS3ConfigKey;
390
391                            if !matches!(configs.last(), Some((AmazonS3ConfigKey::Region, _))) {
392                                configs.push((AmazonS3ConfigKey::Region, region.into()))
393                            }
394                        }
395
396                        Some(format!("s3://{bucket}/{key}"))
397                    })() {
398                        path = Cow::Owned(PlRefPath::new(p));
399                        rewrite_aws = true;
400                    }
401
402                    if !rewrite_aws {
403                        out_paths.push(path.into_owned());
404                        hive_idx_tracker.update(0, path_idx)?;
405                        continue;
406                    }
407                }
408
409                let sort_start_idx = out_paths.paths.len();
410
411                if glob && has_glob(path.as_bytes()) {
412                    hive_idx_tracker.update(0, path_idx)?;
413
414                    let iter = ASYNC.block_in_place_on(crate::async_glob(
415                        path.into_owned(),
416                        cloud_options.as_ref(),
417                    ))?;
418
419                    if first_path_has_scheme {
420                        out_paths.extend(iter.into_iter().map(PlRefPath::new))
421                    } else {
422                        // FORCE_ASYNC, remove leading file:// as the caller may not be expecting a
423                        // URI result.
424                        out_paths.extend(iter.iter().map(|x| &x[7..]).map(PlRefPath::new))
425                    };
426                } else {
427                    let (expand_start_idx, paths) = expand_path_cloud(
428                        path.into_owned(),
429                        cloud_options.as_ref(),
430                        glob,
431                        first_path_has_scheme,
432                    )
433                    .await?;
434                    out_paths.extend_from_slice(&paths);
435                    hive_idx_tracker.update(expand_start_idx, path_idx)?;
436                };
437
438                if let Some(mut_slice) = out_paths.paths.get_mut(sort_start_idx..) {
439                    <[PlRefPath]>::sort_unstable(mut_slice);
440                }
441            }
442        }
443        #[cfg(not(feature = "cloud"))]
444        panic!("Feature `cloud` must be enabled to use globbing patterns with cloud urls.")
445    } else {
446        let mut stack: VecDeque<Cow<'_, Path>> = VecDeque::new();
447        let mut paths_scratch: Vec<PathBuf> = vec![];
448
449        for (path_idx, path) in paths.iter().enumerate() {
450            stack.clear();
451            let sort_start_idx = out_paths.paths.len();
452
453            if path.as_std_path().is_dir() {
454                let i = path.as_str().len();
455
456                hive_idx_tracker.update(i, path_idx)?;
457
458                stack.push_back(Cow::Borrowed(path.as_std_path()));
459
460                while let Some(dir) = stack.pop_front() {
461                    let mut last_err = Ok(());
462
463                    paths_scratch.clear();
464                    paths_scratch.extend(std::fs::read_dir(dir)?.map_while(|x| {
465                        match x.map(|x| x.path()) {
466                            Ok(v) => Some(v),
467                            Err(e) => {
468                                last_err = Err(e);
469                                None
470                            },
471                        }
472                    }));
473
474                    last_err?;
475
476                    for path in paths_scratch.drain(..) {
477                        let md = path.metadata()?;
478
479                        if md.is_dir() {
480                            stack.push_back(Cow::Owned(path));
481                        } else if md.len() > 0 {
482                            out_paths.push(PlRefPath::try_from_path(&path)?);
483                        }
484                    }
485                }
486            } else if glob && has_glob(path.as_bytes()) {
487                hive_idx_tracker.update(0, path_idx)?;
488
489                let Ok(paths) = glob::glob(path.as_str()) else {
490                    polars_bail!(ComputeError: "invalid glob pattern given")
491                };
492
493                for path in paths {
494                    let path = path.map_err(to_compute_err)?;
495                    let md = path.metadata()?;
496                    if !md.is_dir() && md.len() > 0 {
497                        out_paths.push(PlRefPath::try_from_path(&path)?);
498                    }
499                }
500            } else {
501                hive_idx_tracker.update(0, path_idx)?;
502                out_paths.push(path.clone());
503            };
504
505            if let Some(mut_slice) = out_paths.paths.get_mut(sort_start_idx..) {
506                <[PlRefPath]>::sort_unstable(mut_slice);
507            }
508        }
509    }
510
511    if expanded_from_single_directory(paths, out_paths.paths.as_slice()) {
512        if let [Some((_, p1)), Some((_, p2))] = out_paths.exts {
513            polars_bail!(
514                InvalidOperation: "directory contained paths with different file extensions: \
515                first path: {}, second path: {}. Please use a glob pattern to explicitly specify \
516                which files to read (e.g. 'dir/**/*', 'dir/**/*.parquet')",
517                &p1, &p2
518            )
519        }
520    }
521
522    return Ok((out_paths.paths.into(), hive_idx_tracker.idx));
523
524    /// Wrapper around `Vec<PathBuf>` that also tracks file extensions, so that
525    /// we don't have to traverse the entire list again to validate extensions.
526    struct OutPaths<'a, F: Fn(&PlRefPath) -> bool> {
527        paths: Vec<PlRefPath>,
528        exts: [Option<(PlSmallStr, PlRefPath)>; 2],
529        is_hidden_file: &'a F,
530    }
531
532    impl<F> OutPaths<'_, F>
533    where
534        F: Fn(&PlRefPath) -> bool,
535    {
536        fn push(&mut self, value: PlRefPath) {
537            if (self.is_hidden_file)(&value) {
538                return;
539            }
540
541            let exts = &mut self.exts;
542            Self::update_ext_status(exts, &value);
543
544            self.paths.push(value)
545        }
546
547        fn extend(&mut self, values: impl IntoIterator<Item = PlRefPath>) {
548            let exts = &mut self.exts;
549
550            self.paths.extend(
551                values
552                    .into_iter()
553                    .filter(|x| !(self.is_hidden_file)(x))
554                    .inspect(|x| {
555                        Self::update_ext_status(exts, x);
556                    }),
557            )
558        }
559
560        fn extend_from_slice(&mut self, values: &[PlRefPath]) {
561            self.extend(values.iter().cloned())
562        }
563
564        fn update_ext_status(exts: &mut [Option<(PlSmallStr, PlRefPath)>; 2], value: &PlRefPath) {
565            let ext = value
566                .extension()
567                .map_or(PlSmallStr::EMPTY, PlSmallStr::from);
568
569            if exts[0].is_none() {
570                exts[0] = Some((ext, value.clone()));
571            } else if exts[1].is_none() && ext != exts[0].as_ref().unwrap().0 {
572                exts[1] = Some((ext, value.clone()));
573            }
574        }
575    }
576}
577
578/// Ignores errors from `std::fs::create_dir_all` if the directory exists.
579#[cfg(feature = "file_cache")]
580pub(crate) fn ensure_directory_init(path: &Path) -> std::io::Result<()> {
581    let result = std::fs::create_dir_all(path);
582
583    if path.is_dir() { Ok(()) } else { result }
584}
585
586#[cfg(test)]
587mod tests {
588    use std::path::PathBuf;
589
590    use polars_core::runtime::ASYNC;
591    use polars_utils::pl_path::PlRefPath;
592
593    use super::resolve_homedir;
594
595    #[cfg(not(target_os = "windows"))]
596    #[test]
597    fn test_resolve_homedir() {
598        let paths: Vec<PathBuf> = vec![
599            "~/dir1/dir2/test.csv".into(),
600            "/abs/path/test.csv".into(),
601            "rel/path/test.csv".into(),
602            "/".into(),
603            "~".into(),
604        ];
605
606        let resolved: Vec<PathBuf> = paths
607            .iter()
608            .map(resolve_homedir)
609            .map(|x| x.into_owned())
610            .collect();
611
612        assert_eq!(resolved[0].file_name(), paths[0].file_name());
613        assert!(resolved[0].is_absolute());
614        assert_eq!(resolved[1], paths[1]);
615        assert_eq!(resolved[2], paths[2]);
616        assert_eq!(resolved[3], paths[3]);
617        assert!(resolved[4].is_absolute());
618    }
619
620    #[cfg(target_os = "windows")]
621    #[test]
622    fn test_resolve_homedir_windows() {
623        let paths: Vec<PathBuf> = vec![
624            r#"c:\Users\user1\test.csv"#.into(),
625            r#"~\user1\test.csv"#.into(),
626            "~".into(),
627        ];
628
629        let resolved: Vec<PathBuf> = paths
630            .iter()
631            .map(resolve_homedir)
632            .map(|x| x.into_owned())
633            .collect();
634
635        assert_eq!(resolved[0], paths[0]);
636        assert_eq!(resolved[1].file_name(), paths[1].file_name());
637        assert!(resolved[1].is_absolute());
638        assert!(resolved[2].is_absolute());
639    }
640
641    #[test]
642    fn test_http_path_with_query_parameters_is_not_expanded_as_glob() {
643        // Don't confuse HTTP URL's with query parameters for globs.
644        // See https://github.com/pola-rs/polars/pull/17774
645
646        use super::expand_paths;
647
648        let path = "https://pola.rs/test.csv?token=bear";
649        let paths = &[PlRefPath::new(path)];
650        let out = ASYNC
651            .block_on(expand_paths(paths, true, &[], &mut None))
652            .unwrap();
653        assert_eq!(out.as_ref(), paths);
654    }
655}