Repository: apache/opendal (language: rust)

Issue:
new feature:  read，write maybe need support pathlib Path
### Feature Description

read， write path support pathlib.Path type, not only Python string

### Problem and Solution

When using pathlib.Path to concatenate file paths, read or write files, or handle S3 directories, you can obtain the PosixPath instead of a Python string. I believe users should not need to worry about whether the path is of type Path or str.

### Additional Context

_No response_

### Are you willing to contribute to the development of this feature?

- [ ] Yes, I am willing to contribute to the development of this feature.


Discussion / hints:
SGTM, I will take a look at this during this weekend
ref <https://stackoverflow.com/questions/53418046/how-do-i-type-hint-a-variable-that-can-be-passed-as-the-first-argument-to-open>

seems that either use `PathBuf`, `Path` or `PyString`

<https://pyo3.rs/v0.23.3/conversions/tables.html#using-rust-library-types-vs-python-native-types>

Hi, @asukaminato0721, are you interested in implementing this?

Fix patch:
diff --git a/bindings/python/python/opendal/__init__.pyi b/bindings/python/python/opendal/__init__.pyi
index 663cbc677c20..557f71dc9d77 100644
--- a/bindings/python/python/opendal/__init__.pyi
+++ b/bindings/python/python/opendal/__init__.pyi
@@ -17,20 +17,23 @@
 
 from typing import AsyncIterable, Iterable, Optional, final, Union, Type
 from types import TracebackType
+import os
 
 from opendal import exceptions as exceptions
 from opendal import layers as layers
 from opendal.layers import Layer
 from opendal.__base import _Base
 
+PathBuf = Union[str, os.PathLike]
+
 @final
 class Operator(_Base):
     def layer(self, layer: Layer) -> "Operator": ...
-    def open(self, path: str, mode: str) -> File: ...
-    def read(self, path: str) -> bytes: ...
+    def open(self, path: PathBuf, mode: str) -> File: ...
+    def read(self, path: PathBuf) -> bytes: ...
     def write(
         self,
-        path: str,
+        path: PathBuf,
         bs: bytes,
         *,
         append: bool = ...,
@@ -39,25 +42,25 @@ class Operator(_Base):
         content_disposition: str = ...,
         cache_control: str = ...,
     ) -> None: ...
-    def stat(self, path: str) -> Metadata: ...
-    def create_dir(self, path: str) -> None: ...
-    def delete(self, path: str) -> None: ...
-    def list(self, path: str) -> Iterable[Entry]: ...
-    def scan(self, path: str) -> Iterable[Entry]: ...
+    def stat(self, path: PathBuf) -> Metadata: ...
+    def create_dir(self, path: PathBuf) -> None: ...
+    def delete(self, path: PathBuf) -> None: ...
+    def list(self, path: PathBuf) -> Iterable[Entry]: ...
+    def scan(self, path: PathBuf) -> Iterable[Entry]: ...
     def capability(self) -> Capability: ...
-    def copy(self, source: str, target: str) -> None: ...
-    def rename(self, source: str, target: str) -> None: ...
-    def remove_all(self, path: str) -> None: ...
+    def copy(self, source: PathBuf, target: PathBuf) -> None: ...
+    def rename(self, source: PathBuf, target: PathBuf) -> None: ...
+    def remove_all(self, path: PathBuf) -> None: ...
     def to_async_operator(self) -> AsyncOperator: ...
 
 @final
 class AsyncOperator(_Base):
     def layer(self, layer: Layer) -> "AsyncOperator": ...
-    async def open(self, path: str, mode: str) -> AsyncFile: ...
-    async def read(self, path: str) -> bytes: ...
+    async def open(self, path: PathBuf, mode: str) -> AsyncFile: ...
+    async def read(self, path: PathBuf) -> bytes: ...
     async def write(
         self,
-        path: str,
+        path: PathBuf,
         bs: bytes,
         *,
         append: bool = ...,
@@ -66,20 +69,24 @@ class AsyncOperator(_Base):
         content_disposition: str = ...,
         cache_control: str = ...,
     ) -> None: ...
-    async def stat(self, path: str) -> Metadata: ...
-    async def create_dir(self, path: str) -> None: ...
-    async def delete(self, path: str) -> None: ...
-    async def list(self, path: str) -> AsyncIterable[Entry]: ...
-    async def scan(self, path: str) -> AsyncIterable[Entry]: ...
-    async def presign_stat(self, path: str, expire_second: int) -> PresignedRequest: ...
-    async def presign_read(self, path: str, expire_second: int) -> PresignedRequest: ...
+    async def stat(self, path: PathBuf) -> Metadata: ...
+    async def create_dir(self, path: PathBuf) -> None: ...
+    async def delete(self, path: PathBuf) -> None: ...
+    async def list(self, path: PathBuf) -> AsyncIterable[Entry]: ...
+    async def scan(self, path: PathBuf) -> AsyncIterable[Entry]: ...
+    async def presign_stat(
+        self, path: PathBuf, expire_second: int
+    ) -> PresignedRequest: ...
+    async def presign_read(
+        self, path: PathBuf, expire_second: int
+    ) -> PresignedRequest: ...
     async def presign_write(
-        self, path: str, expire_second: int
+        self, path: PathBuf, expire_second: int
     ) -> PresignedRequest: ...
     def capability(self) -> Capability: ...
-    async def copy(self, source: str, target: str) -> None: ...
-    async def rename(self, source: str, target: str) -> None: ...
-    async def remove_all(self, path: str) -> None: ...
+    async def copy(self, source: PathBuf, target: PathBuf) -> None: ...
+    async def rename(self, source: PathBuf, target: PathBuf) -> None: ...
+    async def remove_all(self, path: PathBuf) -> None: ...
     def to_operator(self) -> Operator: ...
 
 @final
diff --git a/bindings/python/src/operator.rs b/bindings/python/src/operator.rs
index b3c14b7c5d0a..8636e8013e1d 100644
--- a/bindings/python/src/operator.rs
+++ b/bindings/python/src/operator.rs
@@ -16,6 +16,7 @@
 // under the License.
 
 use std::collections::HashMap;
+use std::path::PathBuf;
 use std::str::FromStr;
 use std::time::Duration;
 
@@ -89,7 +90,8 @@ impl Operator {
     }
 
     /// Open a file-like reader for the given path.
-    pub fn open(&self, path: String, mode: String) -> PyResult<File> {
+    pub fn open(&self, path: PathBuf, mode: String) -> PyResult<File> {
+        let path = path.to_string_lossy().to_string();
         let this = self.core.clone();
         if mode == "rb" {
             let r = this
@@ -109,18 +111,20 @@ impl Operator {
     }
 
     /// Read the whole path into bytes.
-    pub fn read<'p>(&'p self, py: Python<'p>, path: &str) -> PyResult<Bound<'p, PyAny>> {
-        let buffer = self.core.read(path).map_err(format_pyerr)?.to_vec();
+    pub fn read<'p>(&'p self, py: Python<'p>, path: PathBuf) -> PyResult<Bound<'p, PyAny>> {
+        let path = path.to_string_lossy().to_string();
+        let buffer = self.core.read(&path).map_err(format_pyerr)?.to_vec();
         Buffer::new(buffer).into_bytes_ref(py)
     }
 
     /// Write bytes into given path.
     #[pyo3(signature = (path, bs, **kwargs))]
-    pub fn write(&self, path: &str, bs: Vec<u8>, kwargs: Option<WriteOptions>) -> PyResult<()> {
+    pub fn write(&self, path: PathBuf, bs: Vec<u8>, kwargs: Option<WriteOptions>) -> PyResult<()> {
+        let path = path.to_string_lossy().to_string();
         let kwargs = kwargs.unwrap_or_default();
         let mut write = self
             .core
-            .write_with(path, bs)
+            .write_with(&path, bs)
             .append(kwargs.append.unwrap_or(false));
         if let Some(chunk) = kwargs.chunk {
             write = write.chunk(chunk);
@@ -139,26 +143,32 @@ impl Operator {
     }
 
     /// Get current path's metadata **without cache** directly.
-    pub fn stat(&self, path: &str) -> PyResult<Metadata> {
+    pub fn stat(&self, path: PathBuf) -> PyResult<Metadata> {
+        let path = path.to_string_lossy().to_string();
         self.core
-            .stat(path)
+            .stat(&path)
             .map_err(format_pyerr)
             .map(Metadata::new)
     }
 
     /// Copy source to target.
-    pub fn copy(&self, source: &str, target: &str) -> PyResult<()> {
-        self.core.copy(source, target).map_err(format_pyerr)
+    pub fn copy(&self, source: PathBuf, target: PathBuf) -> PyResult<()> {
+        let source = source.to_string_lossy().to_string();
+        let target = target.to_string_lossy().to_string();
+        self.core.copy(&source, &target).map_err(format_pyerr)
     }
 
     /// Rename filename.
-    pub fn rename(&self, source: &str, target: &str) -> PyResult<()> {
-        self.core.rename(source, target).map_err(format_pyerr)
+    pub fn rename(&self, source: PathBuf, target: PathBuf) -> PyResult<()> {
+        let source = source.to_string_lossy().to_string();
+        let target = target.to_string_lossy().to_string();
+        self.core.rename(&source, &target).map_err(format_pyerr)
     }
 
     /// Remove all file
-    pub fn remove_all(&self, path: &str) -> PyResult<()> {
-        self.core.remove_all(path).map_err(format_pyerr)
+    pub fn remove_all(&self, path: PathBuf) -> PyResult<()> {
+        let path = path.to_string_lossy().to_string();
+        self.core.remove_all(&path).map_err(format_pyerr)
     }
 
     /// Create a dir at given path.
@@ -173,8 +183,9 @@ impl Operator {
     ///
     /// - Create on existing dir will succeed.
     /// - Create dir is always recursive, works like `mkdir -p`
-    pub fn create_dir(&self, path: &str) -> PyResult<()> {
-        self.core.create_dir(path).map_err(format_pyerr)
+    pub fn create_dir(&self, path: PathBuf) -> PyResult<()> {
+        let path = path.to_string_lossy().to_string();
+        self.core.create_dir(&path).map_err(format_pyerr)
     }
 
     /// Delete given path.
@@ -182,21 +193,24 @@ impl Operator {
     /// # Notes
     ///
     /// - Delete not existing error won't return errors.
-    pub fn delete(&self, path: &str) -> PyResult<()> {
-        self.core.delete(path).map_err(format_pyerr)
+    pub fn delete(&self, path: PathBuf) -> PyResult<()> {
+        let path = path.to_string_lossy().to_string();
+        self.core.delete(&path).map_err(format_pyerr)
     }
 
     /// List current dir path.
-    pub fn list(&self, path: &str) -> PyResult<BlockingLister> {
-        let l = self.core.lister(path).map_err(format_pyerr)?;
+    pub fn list(&self, path: PathBuf) -> PyResult<BlockingLister> {
+        let path = path.to_string_lossy().to_string();
+        let l = self.core.lister(&path).map_err(format_pyerr)?;
         Ok(BlockingLister::new(l))
     }
 
     /// List dir in flat way.
-    pub fn scan(&self, path: &str) -> PyResult<BlockingLister> {
+    pub fn scan(&self, path: PathBuf) -> PyResult<BlockingLister> {
+        let path = path.to_string_lossy().to_string();
         let l = self
             .core
-            .lister_with(path)
+            .lister_with(&path)
             .recursive(true)
             .call()
             .map_err(format_pyerr)?;
@@ -288,10 +302,11 @@ impl AsyncOperator {
     pub fn open<'p>(
         &'p self,
         py: Python<'p>,
-        path: String,
+        path: PathBuf,
         mode: String,
     ) -> PyResult<Bound<'p, PyAny>> {
         let this = self.core.clone();
+        let path = path.to_string_lossy().to_string();
 
         future_into_py(py, async move {
             if mode == "rb" {
@@ -315,8 +330,9 @@ impl AsyncOperator {
     }
 
     /// Read the whole path into bytes.
-    pub fn read<'p>(&'p self, py: Python<'p>, path: String) -> PyResult<Bound<'p, PyAny>> {
+    pub fn read<'p>(&'p self, py: Python<'p>, path: PathBuf) -> PyResult<Bound<'p, PyAny>> {
         let this = self.core.clone();
+        let path = path.to_string_lossy().to_string();
         future_into_py(py, async move {
             let res: Vec<u8> = this.read(&path).await.map_err(format_pyerr)?.to_vec();
             Python::with_gil(|py| Buffer::new(res).into_bytes(py))
@@ -328,13 +344,14 @@ impl AsyncOperator {
     pub fn write<'p>(
         &'p self,
         py: Python<'p>,
-        path: String,
+        path: PathBuf,
         bs: &Bound<PyBytes>,
         kwargs: Option<WriteOptions>,
     ) -> PyResult<Bound<'p, PyAny>> {
         let kwargs = kwargs.unwrap_or_default();
         let this = self.core.clone();
         let bs = bs.as_bytes().to_vec();
+        let path = path.to_string_lossy().to_string();
         future_into_py(py, async move {
             let mut write = this
                 .write_with(&path, bs)
@@ -356,8 +373,9 @@ impl AsyncOperator {
     }
 
     /// Get current path's metadata **without cache** directly.
-    pub fn stat<'p>(&'p self, py: Python<'p>, path: String) -> PyResult<Bound<'p, PyAny>> {
+    pub fn stat<'p>(&'p self, py: Python<'p>, path: PathBuf) -> PyResult<Bound<'p, PyAny>> {
         let this = self.core.clone();
+        let path = path.to_string_lossy().to_string();
         future_into_py(py, async move {
             let res: Metadata = this
                 .stat(&path)
@@ -373,10 +391,12 @@ impl AsyncOperator {
     pub fn copy<'p>(
         &'p self,
         py: Python<'p>,
-        source: String,
-        target: String,
+        source: PathBuf,
+        target: PathBuf,
     ) -> PyResult<Bound<'p, PyAny>> {
         let this = self.core.clone();
+        let source = source.to_string_lossy().to_string();
+        let target = target.to_string_lossy().to_string();
         future_into_py(py, async move {
             this.copy(&source, &target).await.map_err(format_pyerr)
         })
@@ -386,18 +406,21 @@ impl AsyncOperator {
     pub fn rename<'p>(
         &'p self,
         py: Python<'p>,
-        source: String,
-        target: String,
+        source: PathBuf,
+        target: PathBuf,
     ) -> PyResult<Bound<'p, PyAny>> {
         let this = self.core.clone();
+        let source = source.to_string_lossy().to_string();
+        let target = target.to_string_lossy().to_string();
         future_into_py(py, async move {
             this.rename(&source, &target).await.map_err(format_pyerr)
         })
     }
 
     /// Remove all file
-    pub fn remove_all<'p>(&'p self, py: Python<'p>, path: String) -> PyResult<Bound<'p, PyAny>> {
+    pub fn remove_all<'p>(&'p self, py: Python<'p>, path: PathBuf) -> PyResult<Bound<'p, PyAny>> {
         let this = self.core.clone();
+        let path = path.to_string_lossy().to_string();
         future_into_py(py, async move {
             this.remove_all(&path).await.map_err(format_pyerr)
         })
@@ -415,8 +438,9 @@ impl AsyncOperator {
     ///
     /// - Create on existing dir will succeed.
     /// - Create dir is always recursive, works like `mkdir -p`
-    pub fn create_dir<'p>(&'p self, py: Python<'p>, path: String) -> PyResult<Bound<'p, PyAny>> {
+    pub fn create_dir<'p>(&'p self, py: Python<'p>, path: PathBuf) -> PyResult<Bound<'p, PyAny>> {
         let this = self.core.clone();
+        let path = path.to_string_lossy().to_string();
         future_into_py(py, async move {
             this.create_dir(&path).await.map_err(format_pyerr)
         })
@@ -427,8 +451,9 @@ impl AsyncOperator {
     /// # Notes
     ///
     /// - Delete not existing error won't return errors.
-    pub fn delete<'p>(&'p self, py: Python<'p>, path: String) -> PyResult<Bound<'p, PyAny>> {
+    pub fn delete<'p>(&'p self, py: Python<'p>, path: PathBuf) -> PyResult<Bound<'p, PyAny>> {
         let this = self.core.clone();
+        let path = path.to_string_lossy().to_string();
         future_into_py(
             py,
             async move { this.delete(&path).await.map_err(format_pyerr) },
@@ -436,8 +461,9 @@ impl AsyncOperator {
     }
 
     /// List current dir path.
-    pub fn list<'p>(&'p self, py: Python<'p>, path: String) -> PyResult<Bound<'p, PyAny>> {
+    pub fn list<'p>(&'p self, py: Python<'p>, path: PathBuf) -> PyResult<Bound<'p, PyAny>> {
         let this = self.core.clone();
+        let path = path.to_string_lossy().to_string();
         future_into_py(py, async move {
             let lister = this.lister(&path).await.map_err(format_pyerr)?;
             let pylister = Python::with_gil(|py| AsyncLister::new(lister).into_py_any(py))?;
@@ -447,8 +473,9 @@ impl AsyncOperator {
     }
 
     /// List dir in flat way.
-    pub fn scan<'p>(&'p self, py: Python<'p>, path: String) -> PyResult<Bound<'p, PyAny>> {
+    pub fn scan<'p>(&'p self, py: Python<'p>, path: PathBuf) -> PyResult<Bound<'p, PyAny>> {
         let this = self.core.clone();
+        let path = path.to_string_lossy().to_string();
         future_into_py(py, async move {
             let lister = this
                 .lister_with(&path)
@@ -465,10 +492,11 @@ impl AsyncOperator {
     pub fn presign_stat<'p>(
         &'p self,
         py: Python<'p>,
-        path: String,
+        path: PathBuf,
         expire_second: u64,
     ) -> PyResult<Bound<'p, PyAny>> {
         let this = self.core.clone();
+        let path = path.to_string_lossy().to_string();
         future_into_py(py, async move {
             let res = this
                 .presign_stat(&path, Duration::from_secs(expire_second))
@@ -484,10 +512,11 @@ impl AsyncOperator {
     pub fn presign_read<'p>(
         &'p self,
         py: Python<'p>,
-        path: String,
+        path: PathBuf,
         expire_second: u64,
     ) -> PyResult<Bound<'p, PyAny>> {
         let this = self.core.clone();
+        let path = path.to_string_lossy().to_string();
         future_into_py(py, async move {
             let res = this
                 .presign_read(&path, Duration::from_secs(expire_second))
@@ -503,10 +532,11 @@ impl AsyncOperator {
     pub fn presign_write<'p>(
         &'p self,
         py: Python<'p>,
-        path: String,
+        path: PathBuf,
         expire_second: u64,
     ) -> PyResult<Bound<'p, PyAny>> {
         let this = self.core.clone();
+        let path = path.to_string_lossy().to_string();
         future_into_py(py, async move {
             let res = this
                 .presign_write(&path, Duration::from_secs(expire_second))


Test patch:
diff --git a/bindings/python/tests/test_read.py b/bindings/python/tests/test_read.py
index 73ccd04f7d5c..03d27050eaee 100644
--- a/bindings/python/tests/test_read.py
+++ b/bindings/python/tests/test_read.py
@@ -19,6 +19,7 @@
 import io
 from random import randint, choices
 from uuid import uuid4
+from pathlib import Path
 
 import pytest
 from opendal.exceptions import NotFound
@@ -116,6 +117,21 @@ async def test_async_read(service_name, operator, async_operator):
     await async_operator.delete(filename)
 
 
+@pytest.mark.asyncio
+@pytest.mark.need_capability("read", "write", "delete")
+async def test_async_read_path(service_name, operator, async_operator):
+    size = randint(1, 1024)
+    filename = Path(f"random_file_{str(uuid4())}")
+    content = os.urandom(size)
+    await async_operator.write(filename, content)
+
+    read_content = await async_operator.read(filename)
+    assert read_content is not None
+    assert read_content == content
+
+    await async_operator.delete(filename)
+
+
 @pytest.mark.asyncio
 @pytest.mark.need_capability("read", "write", "delete")
 async def test_async_reader(service_name, operator, async_operator):
diff --git a/bindings/python/tests/test_write.py b/bindings/python/tests/test_write.py
index 69d448ba01c4..b80059791dbb 100644
--- a/bindings/python/tests/test_write.py
+++ b/bindings/python/tests/test_write.py
@@ -18,6 +18,7 @@
 import os
 from random import randint
 from uuid import uuid4
+from pathlib import Path
 
 import pytest
 from opendal.exceptions import NotFound
@@ -38,6 +39,21 @@ def test_sync_write(service_name, operator, async_operator):
     operator.delete(filename)
 
 
+@pytest.mark.need_capability("write", "delete", "stat")
+def test_sync_write_path(service_name, operator, async_operator):
+    size = randint(1, 1024)
+    filename = Path(f"test_file_{str(uuid4())}.txt")
+    content = os.urandom(size)
+    size = len(content)
+    operator.write(filename, content, content_type="text/plain")
+    metadata = operator.stat(filename)
+    assert metadata is not None
+    assert metadata.mode.is_file()
+    assert metadata.content_length == size
+
+    operator.delete(filename)
+
+
 @pytest.mark.asyncio
 @pytest.mark.need_capability("write", "delete", "stat")
 async def test_async_write(service_name, operator, async_operator):
@@ -54,6 +70,22 @@ async def test_async_write(service_name, operator, async_operator):
     await async_operator.delete(filename)
 
 
+@pytest.mark.asyncio
+@pytest.mark.need_capability("write", "delete", "stat")
+async def test_async_write_path(service_name, operator, async_operator):
+    size = randint(1, 1024)
+    filename = Path(f"test_file_{str(uuid4())}.txt")
+    content = os.urandom(size)
+    size = len(content)
+    await async_operator.write(filename, content)
+    metadata = await async_operator.stat(filename)
+    assert metadata is not None
+    assert metadata.mode.is_file()
+    assert metadata.content_length == size
+
+    await async_operator.delete(filename)
+
+
 @pytest.mark.need_capability("create_dir", "stat")
 def test_sync_create_dir(service_name, operator, async_operator):
     path = f"test_dir_{str(uuid4())}/"