You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@opendal.apache.org by te...@apache.org on 2026/04/27 18:42:51 UTC

(opendal) 01/01: feat(bindings/python): expose if_not_exists option for copy

This is an automated email from the ASF dual-hosted git repository.

TennyZhuang pushed a commit to branch python-copy-if-not-exists
in repository https://gitbox.apache.org/repos/asf/opendal.git

commit 147dc99ce47e379f69faaa39476bfe528e02f6b0
Author: tianyizhuang <ti...@tianyizhuangs-Mac-mini.local>
AuthorDate: Tue Apr 28 02:42:19 2026 +0800

    feat(bindings/python): expose if_not_exists option for copy
    
    Rust core supports copy_with_if_not_exists, but Python bindings did
    not expose the option. This commit:
    
    - Adds copy_options to the blocking Rust operator
    - Adds if_not_exists keyword argument to sync and async Operator.copy
    - Exposes copy_with_if_not_exists in Python Capability
    - Updates operator.pyi stubs
    - Adds behavior tests for sync and async copy with if_not_exists
---
 bindings/python/python/opendal/capability.pyi |  2 ++
 bindings/python/python/opendal/operator.pyi   |  8 ++++++
 bindings/python/src/capability.rs             |  4 +++
 bindings/python/src/operator.rs               | 36 ++++++++++++++++++++++++---
 bindings/python/tests/test_async_copy.py      | 24 ++++++++++++++++++
 bindings/python/tests/test_sync_copy.py       | 23 +++++++++++++++++
 core/core/src/blocking/operator.rs            | 15 +++++++++++
 7 files changed, 109 insertions(+), 3 deletions(-)

diff --git a/bindings/python/python/opendal/capability.pyi b/bindings/python/python/opendal/capability.pyi
index 259de3ea8..8ac72b0f8 100644
--- a/bindings/python/python/opendal/capability.pyi
+++ b/bindings/python/python/opendal/capability.pyi
@@ -136,6 +136,8 @@ class Capability:
     @property
     def copy(self) -> builtins.bool:
         r"""If operator supports copy."""
+    def copy_with_if_not_exists(self) -> builtins.bool:
+        r"""If operator supports copy with if_not_exists."""
     @property
     def rename(self) -> builtins.bool:
         r"""If operator supports rename."""
diff --git a/bindings/python/python/opendal/operator.pyi b/bindings/python/python/opendal/operator.pyi
index 7136720b0..136d43fe8 100644
--- a/bindings/python/python/opendal/operator.pyi
+++ b/bindings/python/python/opendal/operator.pyi
@@ -249,6 +249,8 @@ class AsyncOperator:
         self,
         source: builtins.str | os.PathLike | pathlib.Path,
         target: builtins.str | os.PathLike | pathlib.Path,
+        *,
+        if_not_exists: builtins.bool | None = None,
     ) -> collections.abc.Awaitable[None]:
         r"""
         Copy a file from one path to another.
@@ -259,6 +261,8 @@ class AsyncOperator:
             The path to the source file.
         target : str
             The path to the target file.
+        if_not_exists : bool, optional
+            If True, the copy will only succeed if the target does not exist.
 
         Returns
         -------
@@ -2661,6 +2665,8 @@ class Operator:
         self,
         source: builtins.str | os.PathLike | pathlib.Path,
         target: builtins.str | os.PathLike | pathlib.Path,
+        *,
+        if_not_exists: builtins.bool | None = None,
     ) -> None:
         r"""
         Copy a file from one path to another.
@@ -2671,6 +2677,8 @@ class Operator:
             The path to the source file.
         target : str
             The path to the target file.
+        if_not_exists : bool, optional
+            If True, the copy will only succeed if the target does not exist.
         """
     def rename(
         self,
diff --git a/bindings/python/src/capability.rs b/bindings/python/src/capability.rs
index 39c10d75c..da7d6f79e 100644
--- a/bindings/python/src/capability.rs
+++ b/bindings/python/src/capability.rs
@@ -97,6 +97,9 @@ pub struct Capability {
     /// If operator supports delete.
     pub delete: bool,
 
+    /// If operator supports copy with if_not_exists.
+    pub copy_with_if_not_exists: bool,
+
     /// If operator supports copy.
     pub copy: bool,
 
@@ -160,6 +163,7 @@ impl Capability {
             write_with_user_metadata: capability.write_with_user_metadata,
             create_dir: capability.create_dir,
             delete: capability.delete,
+            copy_with_if_not_exists: capability.copy_with_if_not_exists,
             copy: capability.copy,
             rename: capability.rename,
             list: capability.list,
diff --git a/bindings/python/src/operator.rs b/bindings/python/src/operator.rs
index abf1bd71b..65330d9b1 100644
--- a/bindings/python/src/operator.rs
+++ b/bindings/python/src/operator.rs
@@ -457,10 +457,27 @@ impl Operator {
     ///     The path to the source file.
     /// target : str
     ///     The path to the target file.
-    pub fn copy(&self, source: PathBuf, target: PathBuf) -> PyResult<()> {
+    /// if_not_exists : bool, optional
+    ///     If True, the copy will only succeed if the target does not exist.
+    #[pyo3(signature = (source, target, *, if_not_exists=None))]
+    pub fn copy(
+        &self,
+        source: PathBuf,
+        target: PathBuf,
+        if_not_exists: Option<bool>,
+    ) -> 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)
+        if let Some(true) = if_not_exists {
+            let opts = ocore::options::CopyOptions {
+                if_not_exists: true,
+            };
+            self.core
+                .copy_options(&source, &target, opts)
+                .map_err(format_pyerr)
+        } else {
+            self.core.copy(&source, &target).map_err(format_pyerr)
+        }
     }
 
     /// Rename (move) a file from one path to another.
@@ -1155,6 +1172,8 @@ impl AsyncOperator {
     ///     The path to the source file.
     /// target : str
     ///     The path to the target file.
+    /// if_not_exists : bool, optional
+    ///     If True, the copy will only succeed if the target does not exist.
     ///
     /// Returns
     /// -------
@@ -1164,17 +1183,28 @@ impl AsyncOperator {
         type_repr="collections.abc.Awaitable[None]",
         imports=("collections.abc")
     ))]
+    #[pyo3(signature = (source, target, *, if_not_exists=None))]
     pub fn copy<'p>(
         &'p self,
         py: Python<'p>,
         source: PathBuf,
         target: PathBuf,
+        if_not_exists: Option<bool>,
     ) -> 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)
+            if let Some(true) = if_not_exists {
+                let opts = ocore::options::CopyOptions {
+                    if_not_exists: true,
+                };
+                this.copy_options(&source, &target, opts)
+                    .await
+                    .map_err(format_pyerr)
+            } else {
+                this.copy(&source, &target).await.map_err(format_pyerr)
+            }
         })
     }
 
diff --git a/bindings/python/tests/test_async_copy.py b/bindings/python/tests/test_async_copy.py
index dacf17214..a246d1e28 100644
--- a/bindings/python/tests/test_async_copy.py
+++ b/bindings/python/tests/test_async_copy.py
@@ -113,3 +113,27 @@ async def test_async_copy_overwrite(service_name, operator, async_operator):
     assert target_content == source_content
     await async_operator.delete(source_path)
     await async_operator.delete(target_path)
+
+
+@pytest.mark.asyncio
+@pytest.mark.need_capability("read", "write", "copy")
+async def test_async_copy_if_not_exists(service_name, operator, async_operator):
+    if not async_operator.capability().copy_with_if_not_exists:
+        pytest.skip("copy_with_if_not_exists not supported")
+
+    source_path = f"random_file_{str(uuid4())}"
+    target_path = f"random_file_{str(uuid4())}"
+    content = os.urandom(1024)
+    await async_operator.write(source_path, content)
+    await async_operator.copy(source_path, target_path, if_not_exists=True)
+    read_content = await async_operator.read(target_path)
+    assert read_content == content
+
+    # Second copy with if_not_exists should fail because target exists.
+    from opendal.exceptions import AlreadyExists
+
+    with pytest.raises(AlreadyExists):
+        await async_operator.copy(source_path, target_path, if_not_exists=True)
+
+    await async_operator.delete(source_path)
+    await async_operator.delete(target_path)
diff --git a/bindings/python/tests/test_sync_copy.py b/bindings/python/tests/test_sync_copy.py
index a68e9fd8c..9698ced3c 100644
--- a/bindings/python/tests/test_sync_copy.py
+++ b/bindings/python/tests/test_sync_copy.py
@@ -106,3 +106,26 @@ def test_sync_copy_overwrite(service_name, operator, async_operator):
     assert target_content == source_content
     operator.delete(source_path)
     operator.delete(target_path)
+
+
+@pytest.mark.need_capability("read", "write", "copy")
+def test_sync_copy_if_not_exists(service_name, operator, async_operator):
+    if not operator.capability().copy_with_if_not_exists:
+        pytest.skip("copy_with_if_not_exists not supported")
+
+    source_path = f"random_file_{str(uuid4())}"
+    target_path = f"random_file_{str(uuid4())}"
+    content = os.urandom(1024)
+    operator.write(source_path, content)
+    operator.copy(source_path, target_path, if_not_exists=True)
+    read_content = operator.read(target_path)
+    assert read_content == content
+
+    # Second copy with if_not_exists should fail because target exists.
+    from opendal.exceptions import AlreadyExists
+
+    with pytest.raises(AlreadyExists):
+        operator.copy(source_path, target_path, if_not_exists=True)
+
+    operator.delete(source_path)
+    operator.delete(target_path)
diff --git a/core/core/src/blocking/operator.rs b/core/core/src/blocking/operator.rs
index 0b2627243..4b8f06213 100644
--- a/core/core/src/blocking/operator.rs
+++ b/core/core/src/blocking/operator.rs
@@ -522,6 +522,21 @@ impl Operator {
         self.spawn_block(async move { op.copy(&from, &to).await })?
     }
 
+    /// Copy a file from `from` to `to` with options.
+    ///
+    /// # Notes
+    ///
+    /// - `from` and `to` must be a file.
+    /// - `to` will be overwritten if it exists.
+    /// - If `from` and `to` are the same, nothing will happen.
+    /// - `copy` is idempotent. For same `from` and `to` input, the result will be the same.
+    pub fn copy_options(&self, from: &str, to: &str, opts: options::CopyOptions) -> Result<()> {
+        let op = self.op.clone();
+        let from = from.to_string();
+        let to = to.to_string();
+        self.spawn_block(async move { op.copy_options(&from, &to, opts).await })?
+    }
+
     /// Rename a file from `from` to `to`.
     ///
     /// # Notes