Skip to main content

polars_python/
file.rs

1use std::borrow::Cow;
2#[cfg(target_family = "unix")]
3use std::fs;
4use std::fs::File;
5use std::io;
6use std::io::{Cursor, ErrorKind, Read, Seek, SeekFrom, Write};
7#[cfg(target_family = "unix")]
8use std::os::fd::{FromRawFd, RawFd};
9use std::path::PathBuf;
10
11use polars::io::mmap::MmapBytesReader;
12use polars::prelude::PlRefPath;
13use polars::prelude::file::{Writable, WritableTrait};
14use polars_buffer::{Buffer, SharedStorage};
15use polars_error::polars_err;
16use pyo3::IntoPyObjectExt;
17use pyo3::exceptions::PyTypeError;
18use pyo3::prelude::*;
19use pyo3::types::{PyBytes, PyString, PyStringMethods};
20
21use crate::error::PyPolarsErr;
22use crate::prelude::resolve_homedir;
23use crate::utils::to_py_err;
24
25pub(crate) struct PyFileLikeObject {
26    inner: Py<PyAny>,
27    /// The object expects a string instead of a bytes for `write`.
28    expects_str: bool,
29    /// The object has a flush method.
30    has_flush: bool,
31}
32
33impl WritableTrait for PyFileLikeObject {
34    fn close(&mut self) -> io::Result<()> {
35        Ok(())
36    }
37
38    fn sync_all(&self) -> std::io::Result<()> {
39        self.flush()
40    }
41
42    fn sync_data(&self) -> std::io::Result<()> {
43        self.flush()
44    }
45}
46
47impl Clone for PyFileLikeObject {
48    fn clone(&self) -> Self {
49        Python::attach(|py| Self {
50            inner: self.inner.clone_ref(py),
51            expects_str: self.expects_str,
52            has_flush: self.has_flush,
53        })
54    }
55}
56
57/// Wraps a `PyObject`, and implements read, seek, and write for it.
58impl PyFileLikeObject {
59    /// Creates an instance of a `PyFileLikeObject` from a `PyObject`.
60    /// To assert the object has the required methods,
61    /// instantiate it with `PyFileLikeObject::require`
62    pub(crate) fn new(object: Py<PyAny>, expects_str: bool, has_flush: bool) -> Self {
63        PyFileLikeObject {
64            inner: object,
65            expects_str,
66            has_flush,
67        }
68    }
69
70    pub(crate) fn to_buffer(&self) -> Buffer<u8> {
71        Python::attach(|py| {
72            let bytes = self
73                .inner
74                .call_method(py, "read", (), None)
75                .expect("no read method found");
76
77            if let Ok(b) = bytes.cast_bound::<PyBytes>(py) {
78                // SAFETY: we keep the underlying python object alive.
79                let slice = b.as_bytes();
80                let owner = bytes.clone_ref(py);
81                let ss = unsafe { SharedStorage::from_slice_with_owner(slice, owner) };
82                return Buffer::from_storage(ss);
83            }
84
85            if let Ok(b) = bytes.cast_bound::<PyString>(py) {
86                return match b.to_cow().expect("PyString is not valid UTF-8") {
87                    Cow::Borrowed(v) => {
88                        // SAFETY: we keep the underlying python object alive.
89                        let slice = v.as_bytes();
90                        let owner = bytes.clone_ref(py);
91                        let ss = unsafe { SharedStorage::from_slice_with_owner(slice, owner) };
92                        return Buffer::from_storage(ss);
93                    },
94                    Cow::Owned(v) => Buffer::from_vec(v.into_bytes()),
95                };
96            }
97
98            panic!("Expecting to be able to downcast into bytes from read result.");
99        })
100    }
101
102    /// Validates that the underlying
103    /// python object has a `read`, `write`, and `seek` methods in respect to parameters.
104    /// Will return a `TypeError` if object does not have `read`, `seek`, and `write` methods.
105    pub(crate) fn ensure_requirements(
106        object: &Bound<PyAny>,
107        read: bool,
108        write: bool,
109        seek: bool,
110    ) -> PyResult<()> {
111        if read && object.getattr("read").is_err() {
112            return Err(PyErr::new::<PyTypeError, _>(
113                "Object does not have a .read() method.",
114            ));
115        }
116
117        if seek && object.getattr("seek").is_err() {
118            return Err(PyErr::new::<PyTypeError, _>(
119                "Object does not have a .seek() method.",
120            ));
121        }
122
123        if write && object.getattr("write").is_err() {
124            return Err(PyErr::new::<PyTypeError, _>(
125                "Object does not have a .write() method.",
126            ));
127        }
128
129        Ok(())
130    }
131
132    pub fn flush(&self) -> std::io::Result<()> {
133        if self.has_flush {
134            Python::attach(|py| {
135                self.inner
136                    .call_method(py, "flush", (), None)
137                    .map_err(pyerr_to_io_err)
138            })?;
139        }
140
141        Ok(())
142    }
143}
144
145/// Extracts a string repr from, and returns an IO error to send back to rust.
146fn pyerr_to_io_err(e: PyErr) -> io::Error {
147    Python::attach(|py| {
148        let e_as_object: Py<PyAny> = e.into_py_any(py).unwrap();
149
150        match e_as_object.call_method(py, "__str__", (), None) {
151            Ok(repr) => match repr.extract::<String>(py) {
152                Ok(s) => io::Error::other(s),
153                Err(_e) => io::Error::other("An unknown error has occurred"),
154            },
155            Err(_) => io::Error::other("Err doesn't have __str__"),
156        }
157    })
158}
159
160impl Read for PyFileLikeObject {
161    fn read(&mut self, mut buf: &mut [u8]) -> Result<usize, io::Error> {
162        Python::attach(|py| {
163            let bytes = self
164                .inner
165                .call_method(py, "read", (buf.len(),), None)
166                .map_err(pyerr_to_io_err)?;
167
168            let opt_bytes = bytes.cast_bound::<PyBytes>(py);
169
170            if let Ok(bytes) = opt_bytes {
171                buf.write_all(bytes.as_bytes())?;
172
173                bytes.len().map_err(pyerr_to_io_err)
174            } else if let Ok(s) = bytes.cast_bound::<PyString>(py) {
175                let s = s.to_cow().map_err(pyerr_to_io_err)?;
176                buf.write_all(s.as_bytes())?;
177                Ok(s.len())
178            } else {
179                Err(io::Error::new(
180                    ErrorKind::InvalidInput,
181                    polars_err!(InvalidOperation: "could not read from input"),
182                ))
183            }
184        })
185    }
186}
187
188impl Write for PyFileLikeObject {
189    fn write(&mut self, buf: &[u8]) -> Result<usize, io::Error> {
190        // Note on the .extract() method:
191        // In case of a PyString object, it returns the number of chars,
192        // so we need to take extra steps if the underlying string is not all ASCII.
193        // In case of a ByBytes object, it returns the number of bytes.
194        let expects_str = self.expects_str;
195        let expects_str_and_is_ascii = expects_str && buf.is_ascii();
196
197        Python::attach(|py| {
198            let n_bytes = if expects_str_and_is_ascii {
199                let number_chars_written = unsafe {
200                    self.inner.call_method(
201                        py,
202                        "write",
203                        (PyString::new(py, std::str::from_utf8_unchecked(buf)),),
204                        None,
205                    )
206                }
207                .map_err(pyerr_to_io_err)?;
208                number_chars_written.extract(py).map_err(pyerr_to_io_err)?
209            } else if expects_str {
210                let number_chars_written = self
211                    .inner
212                    .call_method(
213                        py,
214                        "write",
215                        (PyString::new(
216                            py,
217                            std::str::from_utf8(buf).map_err(io::Error::other)?,
218                        ),),
219                        None,
220                    )
221                    .map_err(pyerr_to_io_err)?;
222                let n_chars: usize = number_chars_written.extract(py).map_err(pyerr_to_io_err)?;
223                // calculate n_bytes
224                if n_chars > 0 {
225                    std::str::from_utf8(buf)
226                        .map(|str| {
227                            str.char_indices()
228                                .nth(n_chars - 1)
229                                .map(|(i, ch)| i + ch.len_utf8())
230                                .unwrap()
231                        })
232                        .expect("unable to parse buffer as utf-8")
233                } else {
234                    0
235                }
236            } else {
237                let number_bytes_written = self
238                    .inner
239                    .call_method(py, "write", (PyBytes::new(py, buf),), None)
240                    .map_err(pyerr_to_io_err)?;
241                number_bytes_written.extract(py).map_err(pyerr_to_io_err)?
242            };
243            Ok(n_bytes)
244        })
245    }
246
247    fn flush(&mut self) -> Result<(), io::Error> {
248        Self::flush(self)
249    }
250}
251
252impl Seek for PyFileLikeObject {
253    fn seek(&mut self, pos: SeekFrom) -> Result<u64, io::Error> {
254        Python::attach(|py| {
255            let (whence, offset) = match pos {
256                SeekFrom::Start(i) => (0, i as i64),
257                SeekFrom::Current(i) => (1, i),
258                SeekFrom::End(i) => (2, i),
259            };
260
261            let new_position = self
262                .inner
263                .call_method(py, "seek", (offset, whence), None)
264                .map_err(pyerr_to_io_err)?;
265
266            new_position.extract(py).map_err(pyerr_to_io_err)
267        })
268    }
269}
270
271pub(crate) trait FileLike: Read + Write + Seek + Sync + Send {}
272
273impl FileLike for File {}
274impl FileLike for PyFileLikeObject {}
275impl MmapBytesReader for PyFileLikeObject {}
276
277pub(crate) enum EitherRustPythonFile {
278    Py(PyFileLikeObject),
279    Rust(std::fs::File),
280}
281
282impl EitherRustPythonFile {
283    pub(crate) fn into_dyn(self) -> Box<dyn FileLike> {
284        match self {
285            EitherRustPythonFile::Py(f) => Box::new(f),
286            EitherRustPythonFile::Rust(f) => Box::new(f),
287        }
288    }
289
290    fn into_scan_source_input(self) -> PythonScanSourceInput {
291        match self {
292            EitherRustPythonFile::Py(f) => PythonScanSourceInput::Buffer(f.to_buffer()),
293            EitherRustPythonFile::Rust(f) => PythonScanSourceInput::File(f),
294        }
295    }
296
297    pub(crate) fn into_writable(self) -> Writable {
298        match self {
299            Self::Py(f) => Writable::Dyn(Box::new(f)),
300            Self::Rust(f) => Writable::Local(f),
301        }
302    }
303}
304
305pub(crate) enum PythonScanSourceInput {
306    Buffer(Buffer<u8>),
307    Path(PlRefPath),
308    File(std::fs::File),
309}
310
311pub(crate) fn try_get_pyfile(
312    py: Python<'_>,
313    py_f: Bound<'_, PyAny>,
314    write: bool,
315) -> PyResult<(EitherRustPythonFile, Option<PathBuf>)> {
316    let io = py.import("io")?;
317    let is_utf8_encoding = |py_f: &Bound<PyAny>| -> PyResult<bool> {
318        let encoding = py_f.getattr("encoding")?;
319        let encoding = encoding.extract::<Cow<str>>()?;
320        Ok(encoding.eq_ignore_ascii_case("utf-8") || encoding.eq_ignore_ascii_case("utf8"))
321    };
322
323    #[cfg(target_family = "unix")]
324    if let Some(fd) = (py_f.is_exact_instance(&io.getattr("FileIO").unwrap())
325        || (py_f.is_exact_instance(&io.getattr("BufferedReader").unwrap())
326            || py_f.is_exact_instance(&io.getattr("BufferedWriter").unwrap())
327            || py_f.is_exact_instance(&io.getattr("BufferedRandom").unwrap())
328            || py_f.is_exact_instance(&io.getattr("BufferedRWPair").unwrap())
329            || (py_f.is_exact_instance(&io.getattr("TextIOWrapper").unwrap())
330                && is_utf8_encoding(&py_f)?))
331            && if write {
332                // invalidate read buffer
333                py_f.call_method0("flush").is_ok()
334            } else {
335                // flush write buffer
336                py_f.call_method1("seek", (0, 1)).is_ok()
337            })
338    .then(|| {
339        py_f.getattr("fileno")
340            .and_then(|fileno| fileno.call0())
341            .and_then(|fileno| fileno.extract::<libc::c_int>())
342            .ok()
343    })
344    .flatten()
345    .map(|fileno| unsafe {
346        // `File::from_raw_fd()` takes the ownership of the file descriptor.
347        // When the File is dropped, it closes the file descriptor.
348        // This is undesired - the Python file object will become invalid.
349        // Therefore, we duplicate the file descriptor here.
350        // Closing the duplicated file descriptor will not close
351        // the original file descriptor;
352        // and the status, e.g. stream position, is still shared with
353        // the original file descriptor.
354        // We use `F_DUPFD_CLOEXEC` here instead of `dup()`
355        // because it also sets the `O_CLOEXEC` flag on the duplicated file descriptor,
356        // which `dup()` clears.
357        // `open()` in both Rust and Python automatically set `O_CLOEXEC` flag;
358        // it prevents leaking file descriptors across processes,
359        // and we want to be consistent with them.
360        // `F_DUPFD_CLOEXEC` is defined in POSIX.1-2008
361        // and is present on all alive UNIX(-like) systems.
362        libc::fcntl(fileno, libc::F_DUPFD_CLOEXEC, 0)
363    })
364    .filter(|fileno| *fileno != -1)
365    .map(|fileno| fileno as RawFd)
366    {
367        return Ok((
368            EitherRustPythonFile::Rust(unsafe { File::from_raw_fd(fd).into() }),
369            // This works on Linux and BSD with procfs mounted,
370            // otherwise it fails silently.
371            fs::canonicalize(format!("/proc/self/fd/{fd}")).ok(),
372        ));
373    }
374
375    // Unwrap TextIOWrapper
376    // Allow subclasses to allow things like pytest.capture.CaptureIO
377    let py_f = if py_f
378        .is_instance(&io.getattr("TextIOWrapper").unwrap())
379        .unwrap_or_default()
380    {
381        if !is_utf8_encoding(&py_f)? {
382            return Err(PyPolarsErr::from(
383                polars_err!(InvalidOperation: "file encoding is not UTF-8"),
384            )
385            .into());
386        }
387        // XXX: we have to clear buffer here.
388        // Is there a better solution?
389        if write {
390            py_f.call_method0("flush")?;
391        } else {
392            py_f.call_method1("seek", (0, 1))?;
393        }
394        py_f.getattr("buffer")?
395    } else {
396        py_f
397    };
398    PyFileLikeObject::ensure_requirements(&py_f, !write, write, !write)?;
399    let expects_str = py_f.is_instance(&io.getattr("TextIOBase").unwrap())?;
400    let has_flush = py_f
401        .getattr_opt("flush")?
402        .is_some_and(|flush| flush.is_callable());
403    let f = PyFileLikeObject::new(py_f.unbind(), expects_str, has_flush);
404    Ok((EitherRustPythonFile::Py(f), None))
405}
406
407pub(crate) fn get_python_scan_source_input(
408    py_f: Py<PyAny>,
409    write: bool,
410) -> PyResult<PythonScanSourceInput> {
411    Python::attach(|py| {
412        let py_f = py_f.into_bound(py);
413
414        // CPython has some internal tricks that means much of the time
415        // BytesIO.getvalue() involves no memory copying, unlike
416        // BytesIO.read(). So we want to handle BytesIO specially in order
417        // to save memory.
418        let py_f = read_if_bytesio(py_f);
419
420        // If the pyobject is a `bytes` class
421        if let Ok(b) = py_f.cast::<PyBytes>() {
422            // SAFETY: we keep the underlying python object alive.
423            let slice = b.as_bytes();
424            let owner = b.clone().unbind();
425            let ss = unsafe { SharedStorage::from_slice_with_owner(slice, owner) };
426            let buffer = Buffer::from_storage(ss);
427            return Ok(PythonScanSourceInput::Buffer(buffer));
428        }
429
430        if let Ok(s) = py_f.extract::<Cow<str>>() {
431            let file_path = PlRefPath::try_from_path(resolve_homedir(s.as_ref()).as_ref())
432                .map_err(to_py_err)?;
433
434            Ok(PythonScanSourceInput::Path(file_path))
435        } else {
436            Ok(try_get_pyfile(py, py_f, write)?.0.into_scan_source_input())
437        }
438    })
439}
440
441fn get_either_buffer_or_path(
442    py_f: Py<PyAny>,
443    write: bool,
444) -> PyResult<(EitherRustPythonFile, Option<PathBuf>)> {
445    Python::attach(|py| {
446        let py_f = py_f.into_bound(py);
447        if let Ok(s) = py_f.extract::<Cow<str>>() {
448            let file_path = resolve_homedir(s.as_ref());
449            let f = if write {
450                polars_utils::io::create_file(&file_path).map_err(PyPolarsErr::from)?
451            } else {
452                polars_utils::io::open_file(&file_path).map_err(PyPolarsErr::from)?
453            };
454            Ok((
455                EitherRustPythonFile::Rust(f.into()),
456                Some(file_path.into_owned()),
457            ))
458        } else {
459            try_get_pyfile(py, py_f, write)
460        }
461    })
462}
463
464///
465/// # Arguments
466/// * `write` - open for writing; will truncate existing file and create new file if not.
467pub(crate) fn get_either_file(py_f: Py<PyAny>, write: bool) -> PyResult<EitherRustPythonFile> {
468    Ok(get_either_buffer_or_path(py_f, write)?.0)
469}
470
471pub(crate) fn get_file_like(f: Py<PyAny>, truncate: bool) -> PyResult<Box<dyn FileLike>> {
472    Ok(get_either_file(f, truncate)?.into_dyn())
473}
474
475/// If the give file-like is a BytesIO, read its contents in a memory-efficient
476/// way.
477fn read_if_bytesio(py_f: Bound<PyAny>) -> Bound<PyAny> {
478    let bytes_io = py_f.py().import("io").unwrap().getattr("BytesIO").unwrap();
479    if py_f.is_instance(&bytes_io).unwrap() {
480        // Note that BytesIO has some memory optimizations ensuring that much of
481        // the time getvalue() doesn't need to copy the underlying data:
482        let Ok(bytes) = py_f.call_method0("getvalue") else {
483            return py_f;
484        };
485        return bytes;
486    }
487    py_f
488}
489
490/// Create reader from PyBytes or a file-like object.
491pub(crate) fn get_mmap_bytes_reader(py_f: &Bound<PyAny>) -> PyResult<Box<dyn MmapBytesReader>> {
492    get_mmap_bytes_reader_and_path(py_f).map(|t| t.0)
493}
494
495pub(crate) fn get_mmap_bytes_reader_and_path(
496    py_f: &Bound<PyAny>,
497) -> PyResult<(Box<dyn MmapBytesReader>, Option<PathBuf>)> {
498    let py_f = read_if_bytesio(py_f.clone());
499
500    // bytes object
501    if let Ok(bytes) = py_f.cast::<PyBytes>() {
502        // SAFETY: we keep the underlying python object alive.
503        let slice = bytes.as_bytes();
504        let owner = bytes.clone().unbind();
505        let ss = unsafe { SharedStorage::from_slice_with_owner(slice, owner) };
506        Ok((Box::new(Cursor::new(Buffer::from_storage(ss))), None))
507    }
508    // string so read file
509    else {
510        match get_either_buffer_or_path(py_f.to_owned().unbind(), false)? {
511            (EitherRustPythonFile::Rust(f), path) => Ok((Box::new(f), path)),
512            (EitherRustPythonFile::Py(f), path) => Ok((Box::new(Cursor::new(f.to_buffer())), path)),
513        }
514    }
515}