From ff3d554f6a9e987c251a334b840b2365df4d9fce Mon Sep 17 00:00:00 2001 From: TennyZhuang Date: Thu, 16 Oct 2025 17:58:00 +0800 Subject: [PATCH 1/2] feat(python): add delete_many helpers --- bindings/python/python/opendal/__init__.pyi | 24 ++++++++++++++++ bindings/python/src/operator.rs | 31 +++++++++++++++++++++ bindings/python/tests/test_async_delete.py | 15 ++++++++++ bindings/python/tests/test_sync_delete.py | 14 ++++++++++ 4 files changed, 84 insertions(+) diff --git a/bindings/python/python/opendal/__init__.pyi b/bindings/python/python/opendal/__init__.pyi index cd7e24c80065..e87d80493f3f 100644 --- a/bindings/python/python/opendal/__init__.pyi +++ b/bindings/python/python/opendal/__init__.pyi @@ -152,6 +152,18 @@ class Operator(_Base): ------- None """ + def delete_many(self, paths: Iterable[PathBuf]) -> None: + """Delete multiple objects in a single request. + + Args: + paths (Iterable[str | Path]): Collection of object paths to delete. + Each element is treated the same as calling :py:meth:`delete` + individually. + + Notes + ----- + Missing objects are ignored by default. + """ def stat(self, path: PathBuf, **kwargs) -> Metadata: """Get the metadata of the object at the given path. @@ -381,6 +393,18 @@ class AsyncOperator(_Base): ------- None """ + async def delete_many(self, paths: Iterable[PathBuf]) -> None: + """Delete multiple objects in a single request. + + Args: + paths (Iterable[str | Path]): Collection of object paths to delete. + Each element is treated the same as calling :py:meth:`delete` + individually. + + Notes + ----- + Missing objects are ignored by default. + """ async def stat(self, path: PathBuf, **kwargs) -> Metadata: """Get the metadata of the object at the given path. diff --git a/bindings/python/src/operator.rs b/bindings/python/src/operator.rs index 5300256fa16e..a46c46d5f8ef 100644 --- a/bindings/python/src/operator.rs +++ b/bindings/python/src/operator.rs @@ -232,6 +232,18 @@ impl Operator { self.core.delete(&path).map_err(format_pyerr) } + /// Delete multiple paths in a single call. + /// + /// Accepts any iterable of path-like objects. Paths that do not exist are ignored. + pub fn delete_many(&self, paths: Vec) -> PyResult<()> { + let paths: Vec = paths + .into_iter() + .map(|path| path.to_string_lossy().to_string()) + .collect(); + + self.core.delete_iter(paths).map_err(format_pyerr) + } + /// Checks if the given path exists. /// /// # Notes @@ -577,6 +589,25 @@ impl AsyncOperator { ) } + /// Delete multiple paths in a single call. + /// + /// Accepts any iterable of path-like objects. Paths that do not exist are ignored. + pub fn delete_many<'p>( + &'p self, + py: Python<'p>, + paths: Vec, + ) -> PyResult> { + let this = self.core.clone(); + let paths: Vec = paths + .into_iter() + .map(|path| path.to_string_lossy().to_string()) + .collect(); + + future_into_py(py, async move { + this.delete_iter(paths).await.map_err(format_pyerr) + }) + } + /// Check given path is exists. /// /// # Notes diff --git a/bindings/python/tests/test_async_delete.py b/bindings/python/tests/test_async_delete.py index ff3988f74200..2ee8507f4666 100644 --- a/bindings/python/tests/test_async_delete.py +++ b/bindings/python/tests/test_async_delete.py @@ -47,3 +47,18 @@ async def test_async_remove_all(service_name, operator, async_operator): with pytest.raises(NotFound): await async_operator.read(f"{parent}/{path}") await async_operator.remove_all(f"{parent}/") + + +@pytest.mark.asyncio +@pytest.mark.need_capability("read", "write", "delete") +async def test_async_delete_many(service_name, operator, async_operator): + parent = f"delete_many_{str(uuid4())}" + targets = [f"{parent}/file_{idx}.txt" for idx in range(3)] + + for path in targets: + await async_operator.write(path, os.urandom(16)) + + await async_operator.delete_many(targets) + + for path in targets: + assert not await async_operator.exists(path) diff --git a/bindings/python/tests/test_sync_delete.py b/bindings/python/tests/test_sync_delete.py index 0260bf826a55..e6589e0c1014 100644 --- a/bindings/python/tests/test_sync_delete.py +++ b/bindings/python/tests/test_sync_delete.py @@ -46,3 +46,17 @@ def test_sync_remove_all(service_name, operator, async_operator): with pytest.raises(NotFound): operator.read(f"{parent}/{path}") operator.remove_all(f"{parent}/") + + +@pytest.mark.need_capability("read", "write", "delete") +def test_sync_delete_many(service_name, operator, async_operator): + parent = f"delete_many_{str(uuid4())}" + targets = [f"{parent}/file_{idx}.txt" for idx in range(3)] + + for path in targets: + operator.write(path, os.urandom(16)) + + operator.delete_many(targets) + + for path in targets: + assert not operator.exists(path) From a869b76d5373138059e370593bea7a59dddb2fdb Mon Sep 17 00:00:00 2001 From: TennyZhuang Date: Thu, 16 Oct 2025 20:32:04 +0800 Subject: [PATCH 2/2] fix(python): allow delete to accept iterables --- bindings/python/python/opendal/__init__.py | 52 +++++++++++++++++++++ bindings/python/python/opendal/__init__.pyi | 38 ++++----------- bindings/python/tests/test_async_delete.py | 2 +- bindings/python/tests/test_sync_delete.py | 2 +- 4 files changed, 62 insertions(+), 32 deletions(-) diff --git a/bindings/python/python/opendal/__init__.py b/bindings/python/python/opendal/__init__.py index 54a118e1df82..0e248cdae015 100644 --- a/bindings/python/python/opendal/__init__.py +++ b/bindings/python/python/opendal/__init__.py @@ -15,6 +15,58 @@ # specific language governing permissions and limitations # under the License. +from collections.abc import Iterable +import os +from typing import Optional + from ._opendal import * __all__ = _opendal.__all__ + +_PATH_TYPES = (str, bytes, os.PathLike) + + +def _coerce_many_paths(path: object) -> Optional[list[str]]: + if isinstance(path, _PATH_TYPES): + return None + + try: + iterator = iter(path) # type: ignore[arg-type] + except TypeError: + return None + + paths = list(iterator) + if any(not isinstance(p, _PATH_TYPES) for p in paths): + raise TypeError("all paths must be str, bytes, or os.PathLike objects") + return paths + + +_blocking_delete = Operator.delete + + +def _operator_delete(self: Operator, path: object) -> None: + paths = _coerce_many_paths(path) + if paths is None: + _blocking_delete(self, path) + return + if not paths: + return + self.delete_many(paths) + + +Operator.delete = _operator_delete # type: ignore[assignment] + +_async_delete = AsyncOperator.delete + + +async def _async_operator_delete(self: AsyncOperator, path: object) -> None: + paths = _coerce_many_paths(path) + if paths is None: + await _async_delete(self, path) # type: ignore[arg-type] + return + if not paths: + return + await self.delete_many(paths) + + +AsyncOperator.delete = _async_operator_delete # type: ignore[assignment] diff --git a/bindings/python/python/opendal/__init__.pyi b/bindings/python/python/opendal/__init__.pyi index e87d80493f3f..ec4949e4c123 100644 --- a/bindings/python/python/opendal/__init__.pyi +++ b/bindings/python/python/opendal/__init__.pyi @@ -152,18 +152,6 @@ class Operator(_Base): ------- None """ - def delete_many(self, paths: Iterable[PathBuf]) -> None: - """Delete multiple objects in a single request. - - Args: - paths (Iterable[str | Path]): Collection of object paths to delete. - Each element is treated the same as calling :py:meth:`delete` - individually. - - Notes - ----- - Missing objects are ignored by default. - """ def stat(self, path: PathBuf, **kwargs) -> Metadata: """Get the metadata of the object at the given path. @@ -196,11 +184,12 @@ class Operator(_Base): Args: path (str|Path): The path to the directory. """ - def delete(self, path: PathBuf) -> None: - """Delete the object at the given path. + def delete(self, path: PathBuf | Iterable[PathBuf]) -> None: + """Delete one object or multiple objects. Args: - path (str|Path): The path to the object. + path (str | Path | Iterable[str | Path]): Either a single path or + an iterable of paths to delete. """ def exists(self, path: PathBuf) -> bool: """Check if the object at the given path exists. @@ -393,18 +382,6 @@ class AsyncOperator(_Base): ------- None """ - async def delete_many(self, paths: Iterable[PathBuf]) -> None: - """Delete multiple objects in a single request. - - Args: - paths (Iterable[str | Path]): Collection of object paths to delete. - Each element is treated the same as calling :py:meth:`delete` - individually. - - Notes - ----- - Missing objects are ignored by default. - """ async def stat(self, path: PathBuf, **kwargs) -> Metadata: """Get the metadata of the object at the given path. @@ -437,11 +414,12 @@ class AsyncOperator(_Base): Args: path (str|Path): The path to the directory. """ - async def delete(self, path: PathBuf) -> None: - """Delete the object at the given path. + async def delete(self, path: PathBuf | Iterable[PathBuf]) -> None: + """Delete one object or multiple objects. Args: - path (str|Path): The path to the object. + path (str | Path | Iterable[str | Path]): Either a single path or + an iterable of paths to delete. """ async def exists(self, path: PathBuf) -> bool: """Check if the object at the given path exists. diff --git a/bindings/python/tests/test_async_delete.py b/bindings/python/tests/test_async_delete.py index 2ee8507f4666..c079ad3a1794 100644 --- a/bindings/python/tests/test_async_delete.py +++ b/bindings/python/tests/test_async_delete.py @@ -58,7 +58,7 @@ async def test_async_delete_many(service_name, operator, async_operator): for path in targets: await async_operator.write(path, os.urandom(16)) - await async_operator.delete_many(targets) + await async_operator.delete(targets) for path in targets: assert not await async_operator.exists(path) diff --git a/bindings/python/tests/test_sync_delete.py b/bindings/python/tests/test_sync_delete.py index e6589e0c1014..739617d0dc5b 100644 --- a/bindings/python/tests/test_sync_delete.py +++ b/bindings/python/tests/test_sync_delete.py @@ -56,7 +56,7 @@ def test_sync_delete_many(service_name, operator, async_operator): for path in targets: operator.write(path, os.urandom(16)) - operator.delete_many(targets) + operator.delete(targets) for path in targets: assert not operator.exists(path)