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_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                    // We shouldn't hit here, but we can fallback to hashing $HOME if blake3 is
36                    // available (it is available when file_cache is activated).
37                    #[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            // Setting permissions on Windows is not as easy compared to Unix, but fortunately
62            // the default temporary directory location is underneath the user profile, so we
63            // shouldn't need to do anything.
64            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
90/// Create a directory (and parents) with owner-only permissions (0o700) on Unix.
91pub 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
111/// Replaces a "~" in the Path with the home directory.
112pub 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            // home crate does not compile on wasm https://github.com/rust-lang/cargo/issues/12297
118            #[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    /// Get the index of the first occurrence of a glob symbol.
132    fn get_glob_start_idx(path: &[u8]) -> Option<usize> {
133        memchr::memchr3(b'*', b'?', b'[', path)
134    }
135}
136
137/// Returns `true` for a `file://` URI whose path component carries a percent-escape.
138fn is_file_uri_with_escape(path: &PlRefPath) -> bool {
139    path.scheme().is_some_and(|s| s.is_file()) && path.strip_scheme().contains('%')
140}
141
142/// Decode `%` escapes in `file://` paths and return them as plain local paths
143/// (e.g. `file:///x/foo%3Dbar` -> `/x/foo=bar`).
144///
145/// The `file://` is dropped before decoding, so the result is a plain path, not a URI.
146/// That keeps decoded `?`/`#` as literal filename characters; kept as a URI they'd be read
147/// as a query or fragment and cut the path short.
148///
149/// When `glob` is set, a percent-encoded path is treated as a literal: every glob
150/// metacharacter in the decoded result is escaped (`?` -> `[?]`), so a decoded `?`/`*`
151/// matches the literal character instead of acting as a wildcard. A path with no `%` is
152/// never decoded (see below), so a plain `file:///x/*.parquet` still globs as usual.
153///
154/// Everything else is returned unchanged: `file://` paths with no `%`, plain paths, and
155/// cloud keys (`s3://`, ...), which are taken literally.
156pub fn decode_file_uri_paths(paths: &[PlRefPath], glob: bool) -> Cow<'_, [PlRefPath]> {
157    // Nothing to decode: borrow the input untouched.
158    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                    // Not an encoded file URI, or the escape isn't valid UTF-8: leave the
172                    // path literal and let the downstream open surface any not-found error.
173                    path.clone()
174                }
175            })
176            .collect(),
177    )
178}
179
180/// Percent-decode a path component. When `glob` is set the decoded result is glob-escaped
181/// (via `glob::Pattern::escape`), so a decoded `?`/`*`/`[`/`]` matches the literal character
182/// instead of acting as a wildcard. Returns `None` if the decoded bytes are not valid UTF-8.
183fn 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
195/// `strip_scheme` leaves a Windows `file:///C:/x` URI as `/C:/x`; drop the leading slash before
196/// the drive letter so it is a valid local path (`C:/x`). The inverse of the extra slash
197/// `format_file_uri` adds on Windows. A no-op on other platforms (a leading `/` is the root).
198fn 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
209/// Returns `true` if `expanded_paths` were expanded from a single directory
210pub fn expanded_from_single_directory(paths: &[PlRefPath], expanded_paths: &[PlRefPath]) -> bool {
211    // Single input that isn't a glob
212    paths.len() == 1 && !has_glob(paths[0].strip_scheme().as_bytes())
213    // And isn't a file
214    && {
215        (
216            // For local paths, we can just use `is_dir`
217            !paths[0].has_scheme() && paths[0].as_std_path().is_dir()
218        )
219        || (
220            // For cloud paths, we determine that the input path isn't a file by checking that the
221            // output path differs.
222            expanded_paths.is_empty() || (paths[0] != expanded_paths[0])
223        )
224    }
225}
226
227/// Recursively traverses directories and expands globs if `glob` is `true`.
228pub 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            // They could still be the same directory level, just with different name length
253            && (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        // We need to check if it is a directory for local paths (we can be here due
295        // to FORCE_ASYNC). For cloud paths the convention is that the user must add
296        // a trailing slash `/` to scan directories. We don't infer it as that would
297        // mean sending one network request per path serially (very slow).
298        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            // FORCE_ASYNC in the test suite wants us to raise a proper error message
313            // for non-existent file paths. Note we can't do this for cloud paths as
314            // there is no concept of a "directory" - a non-existent path is
315            // indistinguishable from an empty directory.
316            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        // Since Path::parse() removes any trailing slash ('/'), we may need to restore it
348        // to calculate the right byte offset
349        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
370/// Recursively traverses directories and expands globs if `glob` is `true`.
371/// Returns the expanded paths and the index at which to start parsing hive
372/// partitions from the path.
373pub 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                        // See https://docs.aws.amazon.com/AmazonS3/latest/userguide/VirtualHosting.html#virtual-hosted-style-access
438                        // Path format: https://bucket-name.s3.region-code.amazonaws.com/key-name
439                        let after_scheme = path.strip_scheme();
440
441                        let bucket_end = after_scheme.find(".s3.")?;
442                        let offset = bucket_end + 4;
443                        // Search after offset to prevent matching `.s3.amazonaws.com` (legacy global endpoint URL without region).
444                        let region_end = offset + after_scheme[offset..].find(".amazonaws.com/")?;
445
446                        // Do not convert if '?' (this can be query parameters for AWS presigned URLs).
447                        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                        // FORCE_ASYNC, remove leading file:// as the caller may not be expecting a
491                        // URI result.
492                        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    /// Wrapper around `Vec<PathBuf>` that also tracks file extensions, so that
593    /// we don't have to traverse the entire list again to validate extensions.
594    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/// Ignores errors from `std::fs::create_dir_all` if the directory exists.
647#[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        // Don't confuse HTTP URL's with query parameters for globs.
712        // See https://github.com/pola-rs/polars/pull/17774
713
714        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}