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