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

JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git


The following commit(s) were added to refs/heads/master by this push:
     new bfc84c8758 [python] Fix OSS directory deletion with PyArrow 22+ 
(#10456)
bfc84c8758 is described below

commit bfc84c8758dec6d686919f6cdc456b592a7fb187
Author: XiaoHongbo <[email protected]>
AuthorDate: Sun Oct 11 22:57:11 2026 +0800

    [python] Fix OSS directory deletion with PyArrow 22+ (#10456)
---
 .github/workflows/ci-python.yml                    |  26 +
 .../pypaimon/filesystem/pyarrow_file_io.py         | 219 ++++-
 .../pypaimon/tests/s3_delete_compatibility_test.py | 913 +++++++++++++++++++++
 paimon-python/pyproject.toml                       |   1 +
 4 files changed, 1158 insertions(+), 1 deletion(-)

diff --git a/.github/workflows/ci-python.yml b/.github/workflows/ci-python.yml
index 952a1173a5..9596257545 100644
--- a/.github/workflows/ci-python.yml
+++ b/.github/workflows/ci-python.yml
@@ -145,6 +145,32 @@ jobs:
           chmod +x paimon-python/dev/lint-python.sh
           ./paimon-python/dev/lint-python.sh -e pytest_torch
 
+  s3-delete-compatibility:
+    name: S3 deletion / PyArrow ${{ matrix.pyarrow }}
+    runs-on: ubuntu-latest
+    timeout-minutes: 15
+    strategy:
+      fail-fast: false
+      matrix:
+        pyarrow: ['22.0.0', '23.0.0']
+    steps:
+      - uses: actions/checkout@v6
+      - uses: actions/setup-python@v5
+        with:
+          python-version: '3.11'
+      - name: Install test dependencies
+        run: |
+          python -m pip install --upgrade pip
+          python -m pip install -e ./paimon-python --group 
'./paimon-python/pyproject.toml:test'
+          # Exercise the compatibility path before widening the package's 
Arrow range.
+          python -m pip install --no-deps 'pyarrow==${{ matrix.pyarrow }}'
+      - name: Test S3 compatibility
+        working-directory: paimon-python
+        run: |
+          python -m pytest -q pypaimon/tests/s3_delete_compatibility_test.py \
+            pypaimon/tests/s3_client_compatibility_test.py 
pypaimon/tests/oss_legacy_mode_test.py \
+            pypaimon/tests/file_io_test.py 
pypaimon/tests/filesystem_catalog_test.py
+
   rust-plan:
     name: Native CI
     timeout-minutes: 90
diff --git a/paimon-python/pypaimon/filesystem/pyarrow_file_io.py 
b/paimon-python/pypaimon/filesystem/pyarrow_file_io.py
index 446bb64ed5..1a4362aedb 100644
--- a/paimon-python/pypaimon/filesystem/pyarrow_file_io.py
+++ b/paimon-python/pypaimon/filesystem/pyarrow_file_io.py
@@ -15,14 +15,20 @@
 # specific language governing permissions and limitations
 # under the License.
 
+import base64
+import hashlib
+import json
 import logging
 import os
 import re
 import subprocess
+import tempfile
 import threading
+import time
 from datetime import datetime, timezone
+from itertools import islice
 from pathlib import PurePosixPath
-from typing import Any, Dict, List, Optional
+from typing import Any, Dict, Iterable, List, Optional
 from urllib.parse import splitport, urlparse
 
 import pyarrow
@@ -46,6 +52,9 @@ def _pyarrow_lt_7():
     return parse(pyarrow.__version__) < parse("7.0.0")
 
 
+_S3_DELETE_TIMEOUT_SECONDS = 3600
+
+
 class LegacyOssDirectoryListingError(RuntimeError):
     """Raised when legacy PyArrow OSS cannot enumerate a directory."""
 
@@ -63,6 +72,7 @@ class PyArrowFileIO(FileIO):
             self._get_s3_property("endpoint", S3Options.S3_ENDPOINT.key())
             if self._is_s3 else None
         )
+        self._s3_delete_client = None
         self._oss_bucket = None
         _oss_impl = self.properties.get(OssOptions.OSS_IMPL)
         self._use_jindo = False
@@ -96,6 +106,7 @@ class PyArrowFileIO(FileIO):
             raise ValueError(f"Unrecognized filesystem type in URI: {scheme}")
 
     def _set_pyarrow_version(self):
+        self._pyarrow_gte_22 = parse(pyarrow.__version__) >= parse("22.0.0")
         self._pyarrow_gte_8 = parse(pyarrow.__version__) >= parse("8.0.0")
         # force_virtual_addressing landed in PyArrow 16; below it the OSS 
bucket
         # goes into endpoint_override, so keys must omit it (init + path share
@@ -107,6 +118,9 @@ class PyArrowFileIO(FileIO):
         return (not self._use_jindo
                 and (self._is_oss or bool(self._s3_endpoint)))
 
+    def _uses_s3_delete_fallback(self) -> bool:
+        return self._uses_s3_compatibility() and self._pyarrow_gte_22
+
     def _configure_s3_checksums(self):
         if parse(pyarrow.__version__) < parse("22.0.0"):
             return
@@ -119,6 +133,7 @@ class PyArrowFileIO(FileIO):
         # threading.Lock cannot be pickled; recreated in __setstate__.
         state.pop("_legacy_bucket_lock", None)
         state.pop("logger", None)
+        state.pop("_s3_delete_client", None)
         # Recreate S3-compatible clients with the worker's AWS SDK settings.
         if self._uses_s3_compatibility():
             state.pop("filesystem", None)
@@ -126,6 +141,7 @@ class PyArrowFileIO(FileIO):
 
     def __setstate__(self, state):
         self.__dict__.update(state)
+        self._s3_delete_client = None
         self.logger = logging.getLogger(__name__)
         self._set_pyarrow_version()
         if "_is_s3" not in state:
@@ -300,6 +316,25 @@ class PyArrowFileIO(FileIO):
 
         return pafs.S3FileSystem(**client_kwargs)
 
+    def _s3_connection_options(self):
+        return {
+            "access_key": self._get_property(
+                S3Options.S3_ACCESS_KEY_ID.key(),
+                *self._s3_key_variants("access-key", "access.key")),
+            "secret_key": self._get_property(
+                S3Options.S3_ACCESS_KEY_SECRET.key(),
+                *self._s3_key_variants("secret-key", "secret.key")),
+            "session_token": self._get_property(
+                S3Options.S3_SECURITY_TOKEN.key(),
+                *self._s3_key_variants(
+                    "session-token", "session.token",
+                    "security-token", "security.token")),
+            "region": self._get_s3_property("region", 
S3Options.S3_REGION.key()),
+            "path_style": (self._pyarrow_gte_16 and (
+                self._get_s3_boolean_property("path-style-access") or
+                self._get_s3_boolean_property("path.style.access"))),
+        }
+
     def _initialize_hdfs_fs(self, scheme: str, netloc: Optional[str]) -> 
FileSystem:
         if 'HADOOP_HOME' not in os.environ:
             raise RuntimeError("HADOOP_HOME environment variable is not set.")
@@ -486,13 +521,38 @@ class PyArrowFileIO(FileIO):
         }
 
     def delete(self, path: str, recursive: bool = False) -> bool:
+        use_fallback = self._uses_s3_delete_fallback()
+        if use_fallback and self._is_oss:
+            parsed = urlparse(path)
+            if parsed.scheme == "oss" and parsed.netloc:
+                bucket = self._extract_oss_bucket(path)
+                key = parsed.path.lstrip("/")
+                if "@" in parsed.netloc:
+                    _, _, key = key.partition("/")
+                path = "oss://" + bucket + "/" + key
         path_str = self.to_filesystem_path(path)
+        if use_fallback:
+            bucket, key = self._split_s3_path(path_str)
+            if not bucket or not key.strip("/"):
+                raise OSError(f"Refusing to delete bucket root: {path}")
         file_info = self._get_file_info(path_str)
 
         if file_info.type == pafs.FileType.NotFound:
             return False
 
         if file_info.type == pafs.FileType.Directory:
+            if use_fallback:
+                if recursive:
+                    return self._delete_s3_compatible_directory(path_str)
+                selector = pafs.FileSelector(
+                    path_str, recursive=False, allow_not_found=True)
+                if self.filesystem.get_file_info(selector):
+                    raise OSError(f"Directory {path} is not empty")
+                bucket, key = self._split_s3_path(path_str)
+                if key:
+                    self._get_s3_delete_client().delete_object(
+                        Bucket=bucket, Key=key.rstrip("/") + "/")
+                return True
             if not recursive:
                 selector = pafs.FileSelector(path_str, recursive=False, 
allow_not_found=True)
                 dir_contents = self.filesystem.get_file_info(selector)
@@ -507,6 +567,163 @@ class PyArrowFileIO(FileIO):
             self.filesystem.delete_file(path_str)
         return True
 
+    def _delete_s3_compatible_directory(self, path_str: str) -> bool:
+        client = self._get_s3_delete_client()
+        bucket, key = self._split_s3_path(path_str)
+        prefix = key.rstrip("/")
+        if prefix:
+            prefix += "/"
+        deadline = time.monotonic() + _S3_DELETE_TIMEOUT_SECONDS
+        # Finish listing before deleting: mutating a paginated listing may 
skip keys.
+        with tempfile.TemporaryFile(mode="w+t", encoding="utf-8") as listed:
+            for name in self._list_s3_keys(client, bucket, prefix, deadline, 
path_str):
+                listed.write(json.dumps(name) + "\n")
+            listed.seek(0)
+            self._delete_s3_objects(
+                client, bucket, (json.loads(line) for line in listed),
+                deadline, path_str)
+        if any(self._list_s3_keys(client, bucket, prefix, deadline, path_str)):
+            raise OSError(f"S3 directory {path_str} changed during deletion")
+        return True
+
+    def _list_s3_keys(self, client, bucket: str, prefix: str,
+                      deadline: float, path_str: str):
+        token = None
+        while True:
+            self._check_s3_delete_deadline(deadline, path_str)
+            params = {"Bucket": bucket, "Prefix": prefix, "MaxKeys": 1000}
+            if token is not None:
+                params["ContinuationToken"] = token
+            response = client.list_objects_v2(**params)
+            yield from self._listed_s3_keys(response, bucket, prefix)
+            if not response.get("IsTruncated"):
+                return
+            next_token = response.get("NextContinuationToken")
+            if not next_token or next_token == token:
+                raise OSError("S3 listing did not advance")
+            token = next_token
+
+    @staticmethod
+    def _listed_s3_keys(response, bucket: str, prefix: str):
+        if (response.get("Name", bucket) != bucket
+                or response.get("Prefix", prefix) != prefix):
+            raise OSError("S3 listing returned a different bucket or prefix")
+        keys = [item["Key"] for item in response.get("Contents", ())]
+        if any(not key.startswith(prefix) for key in keys):
+            raise OSError(f"S3 listing returned a key outside prefix {prefix}")
+        return keys
+
+    @staticmethod
+    def _check_s3_delete_deadline(deadline: float, path_str: str):
+        if time.monotonic() >= deadline:
+            raise TimeoutError(f"Timed out deleting S3 directory {path_str}")
+
+    @staticmethod
+    def _delete_s3_objects(
+            client, bucket: str, keys: Iterable[str],
+            deadline: float, path_str: str):
+        keys = iter(keys)
+        while True:
+            batch = list(islice(keys, 1000))
+            if not batch:
+                return
+            PyArrowFileIO._check_s3_delete_deadline(deadline, path_str)
+            ordinary = []
+            for key in batch:
+                if key.isprintable():
+                    ordinary.append(key)
+                else:
+                    # Non-printable keys may not round-trip through 
DeleteObjects XML.
+                    PyArrowFileIO._check_s3_delete_deadline(deadline, path_str)
+                    client.delete_object(Bucket=bucket, Key=key)
+            if not ordinary:
+                continue
+            PyArrowFileIO._check_s3_delete_deadline(deadline, path_str)
+            response = client.delete_objects(
+                Bucket=bucket,
+                Delete={"Objects": [{"Key": key} for key in ordinary],
+                        "Quiet": False})
+            deleted = [item["Key"] for item in response.get("Deleted", ())]
+            errors = response.get("Errors", ())
+            if errors:
+                examples = ", ".join(
+                    "{}: {!r}".format(item.get("Code"), item.get("Key"))
+                    for item in errors[:3])
+                raise OSError(
+                    f"S3 batch delete incomplete for {path_str}: {examples}")
+            if set(deleted) != set(ordinary) or len(deleted) != len(ordinary):
+                raise OSError(f"S3 batch delete incomplete for {path_str}")
+
+    @staticmethod
+    def _split_s3_path(path_str: str):
+        bucket, _, key = path_str.partition("/")
+        return bucket, key
+
+    def _get_s3_delete_client(self):
+        if self._s3_delete_client is not None:
+            return self._s3_delete_client
+
+        import boto3
+        from botocore.config import Config
+
+        if self._is_oss:
+            endpoint = self.properties.get(OssOptions.OSS_ENDPOINT)
+            access_key = self.properties.get(OssOptions.OSS_ACCESS_KEY_ID)
+            secret_key = self.properties.get(OssOptions.OSS_ACCESS_KEY_SECRET)
+            session_token = self.properties.get(OssOptions.OSS_SECURITY_TOKEN)
+            region = self.properties.get(OssOptions.OSS_REGION)
+            addressing_style = "virtual"
+        else:
+            endpoint = self._s3_endpoint
+            connection = self._s3_connection_options()
+            access_key = connection["access_key"]
+            secret_key = connection["secret_key"]
+            session_token = connection["session_token"]
+            region = connection["region"]
+            addressing_style = "path" if connection["path_style"] else 
"virtual"
+
+        region = region or self.filesystem.region
+        if endpoint and "://" not in endpoint:
+            endpoint = "https://"; + endpoint
+        config_args = {
+            "retries": {"max_attempts": 10, "mode": "standard"},
+            "s3": {"addressing_style": addressing_style},
+            "connect_timeout": 60,
+            "read_timeout": 60,
+        }
+        try:
+            config = Config(request_checksum_calculation="when_required",
+                            **config_args)
+            uses_new_checksums = True
+        except TypeError:
+            config = Config(**config_args)
+            uses_new_checksums = False
+
+        client = boto3.session.Session().client(
+            "s3",
+            endpoint_url=endpoint,
+            aws_access_key_id=access_key,
+            aws_secret_access_key=secret_key,
+            aws_session_token=session_token,
+            region_name=region,
+            config=config,
+        )
+        if uses_new_checksums:
+            # OSS requires Content-MD5; newer Botocore defaults to CRC32.
+            def use_content_md5(request, **kwargs):
+                request.headers["Content-MD5"] = base64.b64encode(
+                    hashlib.md5(request.body, usedforsecurity=False).digest()
+                ).decode("ascii")
+                for name in list(request.headers):
+                    if (name.lower().startswith("x-amz-checksum-") or
+                            name.lower() == "x-amz-sdk-checksum-algorithm"):
+                        del request.headers[name]
+
+            client.meta.events.register(
+                "before-sign.s3.DeleteObjects", use_content_md5)
+        self._s3_delete_client = client
+        return client
+
     def mkdirs(self, path: str) -> bool:
         path_str = self.to_filesystem_path(path)
         file_info = self._get_file_info(path_str)
diff --git a/paimon-python/pypaimon/tests/s3_delete_compatibility_test.py 
b/paimon-python/pypaimon/tests/s3_delete_compatibility_test.py
new file mode 100644
index 0000000000..f5dd6d08ec
--- /dev/null
+++ b/paimon-python/pypaimon/tests/s3_delete_compatibility_test.py
@@ -0,0 +1,913 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Unit tests for PyArrow-backed OSS and S3-compatible storage.
+
+No real OSS access is required.
+"""
+
+import base64
+import hashlib
+import os
+import socketserver
+import threading
+import unittest
+from http.server import BaseHTTPRequestHandler, HTTPServer
+from unittest import mock
+from urllib.parse import parse_qs, quote, unquote, urlsplit
+from xml.etree import ElementTree
+from xml.sax.saxutils import escape
+
+import pyarrow
+import pyarrow.fs as pafs
+from packaging.version import parse
+
+from pypaimon.common.options import Options
+from pypaimon.common.options.config import OssOptions, S3Options
+from pypaimon.filesystem.oss_file_io import OssFileIO
+from pypaimon.filesystem.pyarrow_file_io import (
+    PyArrowFileIO,
+)
+
+TABLE_PATH = "oss://test-bucket/db-uuid.db/tbl-uuid"
+
+
+def _file_info(path, file_type):
+    return pafs.FileInfo(path, file_type)
+
+
+def _set_listed_keys(file_io, *passes):
+    file_io._s3_delete_client.list_objects_v2.side_effect = [
+        {"Contents": [{"Key": key} for key in keys]}
+        for keys in passes
+    ] + [{"Contents": []}]
+
+
+def _all_deleted_keys(client):
+    keys = []
+    for call in client.method_calls:
+        if call[0] == "delete_objects":
+            keys.extend(item["Key"] for item in call[2]["Delete"]["Objects"])
+        elif call[0] == "delete_object":
+            keys.append(call[2]["Key"])
+    return keys
+
+
+class _ThreadingHTTPServer(socketserver.ThreadingMixIn, HTTPServer):
+    daemon_threads = True
+
+
+class _DeleteRequestHandler(BaseHTTPRequestHandler):
+    protocol_version = "HTTP/1.1"
+
+    def _target(self):
+        path = unquote(urlsplit(self.path).path).lstrip("/")
+        bucket, _, key = path.partition("/")
+        return bucket, key
+
+    def _respond(self, status, body=b""):
+        self.send_response(status)
+        self.send_header("Content-Type", "application/xml")
+        self.send_header("Content-Length", str(len(body)))
+        self.end_headers()
+        if self.command != "HEAD":
+            self.wfile.write(body)
+
+    def do_HEAD(self):
+        self.server.requests.append((self.command, self.path))
+        bucket, key = self._target()
+        objects = (self.server.bucket_objects.get(bucket, ())
+                   if hasattr(self.server, "bucket_objects") else 
self.server.objects)
+        exists = not key or key in objects
+        self._respond(200 if exists else 404)
+
+    def do_GET(self):
+        self.server.requests.append((self.command, self.path))
+        required_region = getattr(self.server, "required_region", None)
+        if required_region and "/{}/s3/aws4_request".format(required_region) 
not in \
+                self.headers.get("Authorization", ""):
+            return self._respond(
+                400, 
b"<Error><Code>AuthorizationHeaderMalformed</Code></Error>")
+        query = parse_qs(urlsplit(self.path).query)
+        prefix = query.get("prefix", [""])[0]
+        delimiter = query.get("delimiter", [""])[0]
+        max_keys = int(query.get("max-keys", ["1000"])[0])
+        bucket, _ = self._target()
+        objects = (
+            self.server.bucket_objects.get(bucket, ())
+            if hasattr(self.server, "bucket_objects")
+            else self.server.objects
+        )
+        keys = sorted(
+            key for key in objects if key.startswith(prefix)
+        )
+        if max_keys == 1000 and hasattr(self.server, "forced_list_keys"):
+            keys = self.server.forced_list_keys
+        contents = []
+        common_prefixes = set()
+        for key in keys:
+            suffix = key[len(prefix):]
+            if delimiter and delimiter in suffix:
+                common_prefixes.add(
+                    prefix + suffix.split(delimiter, 1)[0] + delimiter)
+            else:
+                contents.append(key)
+        contents = contents[:max_keys]
+        encode_keys = getattr(self.server, "encode_list_keys", False)
+        response_prefix = quote(prefix, safe="/") if encode_keys else prefix
+        response_delimiter = quote(delimiter, safe="/") if encode_keys else 
delimiter
+        response_contents = (
+            [quote(key, safe="/") for key in contents]
+            if encode_keys else contents)
+        response_common_prefixes = (
+            [quote(key, safe="/") for key in sorted(common_prefixes)]
+            if encode_keys else sorted(common_prefixes))
+        body = (
+            '<?xml version="1.0" encoding="UTF-8"?>'
+            '<ListBucketResult 
xmlns="http://s3.amazonaws.com/doc/2006-03-01/";>'
+            '<Name>{}</Name><Prefix>{}</Prefix><Delimiter>{}</Delimiter>'
+            '<KeyCount>{}</KeyCount><MaxKeys>{}</MaxKeys>{}'
+            '<IsTruncated>false</IsTruncated>{}{}</ListBucketResult>'.format(
+                escape(bucket), escape(response_prefix), 
escape(response_delimiter),
+                len(contents) + len(common_prefixes), max_keys,
+                '<EncodingType>url</EncodingType>' if encode_keys else '',
+                "".join(
+                    "<Contents><Key>{}</Key>"
+                    "<LastModified>2026-01-01T00:00:00Z</LastModified>"
+                    "<Size>{}</Size><StorageClass>STANDARD</StorageClass>"
+                    "</Contents>".format(
+                        escape(key), 0 if key.endswith("/") else 1)
+                    for key in response_contents),
+                "".join(
+                    
"<CommonPrefixes><Prefix>{}</Prefix></CommonPrefixes>".format(
+                        escape(child))
+                    for child in response_common_prefixes)))
+        if (not hasattr(self.server, "bucket_objects")
+                and keys == [self.server.prefix]
+                and not self.server.late_object_added):
+            self.server.objects.add(self.server.prefix + "late.parquet")
+            self.server.late_object_added = True
+        first = getattr(self.server, "prefix", "") + "first.parquet"
+        if (not hasattr(self.server, "bucket_objects")
+                and first in keys and not self.server.missing_object_removed):
+            self.server.objects.discard(first)
+            self.server.missing_object_removed = True
+        encoded = body.encode("utf-8")
+        self._respond(200, encoded)
+
+    def do_DELETE(self):
+        self.server.requests.append((self.command, self.path))
+        bucket, key = self._target()
+        if hasattr(self.server, "bucket_objects"):
+            self.server.bucket_objects.setdefault(bucket, set()).discard(key)
+        else:
+            self.server.objects.discard(key)
+            if key == getattr(self.server, "inject_late_after_delete", None):
+                self.server.objects.add(self.server.prefix + "late.parquet")
+        self._respond(204)
+
+    def do_PUT(self):
+        self.server.requests.append((self.command, self.path))
+        if hasattr(self.server, "put_headers"):
+            self.server.put_headers.append((self.path, dict(self.headers)))
+        bucket, key = self._target()
+        if hasattr(self.server, "bucket_objects"):
+            self.server.bucket_objects.setdefault(bucket, set()).add(key)
+        else:
+            self.server.objects.add(key)
+        self._respond(200)
+
+    def _unexpected(self):
+        self.server.requests.append((self.command, self.path))
+        self.send_response(501)
+        self.send_header("Content-Length", "0")
+        self.end_headers()
+
+    def do_POST(self):
+        self.server.requests.append((self.command, self.path))
+        if "delete" not in urlsplit(self.path).query:
+            return self._respond(501)
+        body = self.rfile.read(int(self.headers["Content-Length"]))
+        if any(name.lower().startswith("x-amz-checksum-")
+               for name in self.headers):
+            return self._respond(
+                400, b"<Error><Code>InvalidRequest</Code></Error>")
+        content_md5 = base64.b64encode(
+            hashlib.md5(body, usedforsecurity=False).digest()).decode()
+        if self.headers.get("Content-MD5") != content_md5:
+            return self._respond(
+                400, b"<Error><Code>MissingArgument</Code></Error>")
+        if b"\x01" in body:
+            return self._respond(400, 
b"<Error><Code>MalformedXML</Code></Error>")
+        bucket, _ = self._target()
+        keys = [item.text for item in ElementTree.fromstring(body).iter()
+                if item.tag.rsplit("}", 1)[-1] == "Key"]
+        objects = (self.server.bucket_objects.setdefault(bucket, set())
+                   if hasattr(self.server, "bucket_objects") else 
self.server.objects)
+        failed = getattr(self.server, "fail_delete_key_once", None)
+        if failed in keys:
+            self.server.fail_delete_key_once = None
+        deleted = []
+        for key in keys:
+            if key != failed:
+                objects.discard(key)
+                deleted.append(key)
+                if key == getattr(self.server, "inject_late_after_delete", 
None):
+                    objects.add(self.server.prefix + "late.parquet")
+        result = "<DeleteResult>{}{}</DeleteResult>".format(
+            "".join("<Deleted><Key>{}</Key></Deleted>".format(escape(key))
+                    for key in deleted),
+            "<Error><Key>{}</Key><Code>AccessDenied</Code></Error>".format(
+                escape(failed)) if failed in keys else "")
+        self._respond(200, result.encode())
+
+    def log_message(self, *args):
+        pass
+
+
+def _successful_batch_delete(**kwargs):
+    return {"Deleted": kwargs["Delete"]["Objects"]}
+
+
+class OssDeleteCompatibilityTest(unittest.TestCase):
+    def _new_file_io(self, legacy):
+        options = Options({
+            OssOptions.OSS_ACCESS_KEY_ID.key(): "ak",
+            OssOptions.OSS_ACCESS_KEY_SECRET.key(): "sk",
+            OssOptions.OSS_ENDPOINT.key(): "oss-cn-test.example.com",
+            OssOptions.OSS_REGION.key(): "cn-test",
+            OssOptions.OSS_IMPL.key(): "legacy",
+        })
+        with mock.patch.object(
+                OssFileIO, "_initialize_oss_fs", return_value=mock.Mock()):
+            file_io = OssFileIO("oss://test-bucket/", options)
+        # _legacy_oss_mode() keys off the bucket-in-endpoint flag (PyArrow < 
16).
+        file_io._oss_bucket_in_endpoint = legacy
+        file_io.filesystem = mock.Mock(spec=pafs.S3FileSystem)
+        file_io._s3_delete_client = mock.Mock()
+        file_io._s3_delete_client.delete_objects.side_effect = \
+            _successful_batch_delete
+        return file_io
+
+    def test_delete_normalizes_oss_uri_before_selecting_bucket(self):
+        for path in ("oss://other-bucket/table", 
"oss://other-bucket.oss-cn-test.example.com/table",
+                     "oss://AK:SK@endpoint/other-bucket/table"):
+            with self.subTest(path=path):
+                file_io = self._new_file_io(legacy=False)
+                file_io._pyarrow_gte_22 = True
+                file_io.filesystem.get_file_info.return_value = [
+                    _file_info("other-bucket/table", pafs.FileType.Directory)]
+                _set_listed_keys(file_io, ["table/data"], [])
+                self.assertTrue(file_io.delete(path, recursive=True))
+                
file_io.filesystem.get_file_info.assert_called_once_with(["other-bucket/table"])
+                client = file_io._s3_delete_client
+                self.assertTrue(all(call[1]["Bucket"] == "other-bucket"
+                                    for call in 
client.delete_objects.call_args_list))
+                client.delete_object.assert_not_called()
+
+    def test_pyarrow_22_recursive_delete_batches_objects(self):
+        file_io = self._new_file_io(legacy=False)
+        file_io._pyarrow_gte_22 = True
+        directory = file_io.to_filesystem_path(TABLE_PATH)
+        data_dir = directory.rstrip("/") + "/data"
+        data_file = directory.rstrip("/") + "/data/data.parquet"
+        file_io.filesystem.get_file_info.return_value = [
+            _file_info(directory, pafs.FileType.Directory)]
+        _set_listed_keys(
+            file_io, [data_dir.split("/", 1)[1] + "/", data_file.split("/", 
1)[1]], [])
+
+        self.assertTrue(file_io.delete(TABLE_PATH, recursive=True))
+
+        client = file_io._s3_delete_client
+        self.assertCountEqual([
+            "db-uuid.db/tbl-uuid/data/data.parquet",
+            "db-uuid.db/tbl-uuid/data/",
+        ], [item["Key"] for item in
+            client.delete_objects.call_args[1]["Delete"]["Objects"]])
+        client.delete_object.assert_not_called()
+        client.put_object.assert_not_called()
+        file_io.filesystem.delete_file.assert_not_called()
+        file_io.filesystem.delete_dir_contents.assert_not_called()
+        file_io.filesystem.delete_dir.assert_not_called()
+
+    def test_recursive_delete_lists_all_pages_before_deleting(self):
+        file_io = self._new_file_io(legacy=False)
+        file_io._pyarrow_gte_22 = True
+        directory = file_io.to_filesystem_path(TABLE_PATH)
+        file_io.filesystem.get_file_info.return_value = [
+            _file_info(directory, pafs.FileType.Directory)]
+        client = file_io._s3_delete_client
+
+        def list_page(**kwargs):
+            if kwargs.get("ContinuationToken") == "next":
+                self.assertEqual(0, client.delete_objects.call_count)
+                return {"Contents": [{"Key": "db-uuid.db/tbl-uuid/b"}]}
+            if client.delete_objects.call_count:
+                return {"Contents": []}
+            return {
+                "Contents": [{"Key": "db-uuid.db/tbl-uuid/a"}],
+                "IsTruncated": True,
+                "NextContinuationToken": "next",
+            }
+
+        client.list_objects_v2.side_effect = list_page
+        self.assertTrue(file_io.delete(TABLE_PATH, recursive=True))
+        self.assertCountEqual(
+            ["db-uuid.db/tbl-uuid/a", "db-uuid.db/tbl-uuid/b"],
+            _all_deleted_keys(client))
+        self.assertEqual("next", client.list_objects_v2.call_args_list[1][1][
+            "ContinuationToken"])
+
+    def test_recursive_delete_treats_schema_as_regular_key(self):
+        file_io = self._new_file_io(legacy=False)
+        file_io._pyarrow_gte_22 = True
+        directory = file_io.to_filesystem_path(TABLE_PATH)
+        file_io.filesystem.get_file_info.return_value = [
+            _file_info(directory, pafs.FileType.Directory)]
+        prefix = "db-uuid.db/tbl-uuid/"
+        _set_listed_keys(file_io, [
+            prefix + "schema/schema-0",
+            prefix + "snapshot/snapshot-1",
+            prefix + "schema/schema-1",
+            prefix + "data/file.parquet",
+        ], [])
+
+        self.assertTrue(file_io.delete(TABLE_PATH, recursive=True))
+
+        calls = _all_deleted_keys(file_io._s3_delete_client)
+        self.assertCountEqual([
+            prefix + "schema/schema-0",
+            prefix + "snapshot/snapshot-1",
+            prefix + "schema/schema-1",
+            prefix + "data/file.parquet",
+        ], calls)
+
+    def test_recursive_delete_times_out_when_directory_keeps_changing(self):
+        file_io = self._new_file_io(legacy=False)
+        file_io._pyarrow_gte_22 = True
+        directory = file_io.to_filesystem_path(TABLE_PATH)
+        file_io.filesystem.get_file_info.return_value = [
+            _file_info(directory, pafs.FileType.Directory)]
+        file_io._s3_delete_client.list_objects_v2.return_value = {
+            "Contents": [{"Key": "db-uuid.db/tbl-uuid/data.parquet"}],
+            "IsTruncated": True,
+            "NextContinuationToken": "next",
+        }
+        clock = mock.Mock()
+        clock.monotonic.side_effect = [0, 0, 2]
+
+        with mock.patch("pypaimon.filesystem.pyarrow_file_io.time", clock), \
+                mock.patch(
+                    
"pypaimon.filesystem.pyarrow_file_io._S3_DELETE_TIMEOUT_SECONDS", 1):
+            with self.assertRaisesRegex(TimeoutError, "deleting S3 directory"):
+                file_io.delete(TABLE_PATH, recursive=True)
+
+        file_io._s3_delete_client.list_objects_v2.assert_called_once()
+        file_io._s3_delete_client.delete_objects.assert_not_called()
+        file_io._s3_delete_client.delete_object.assert_not_called()
+        file_io._s3_delete_client.put_object.assert_not_called()
+
+    def test_recursive_delete_stops_before_next_batch_after_deadline(self):
+        file_io = self._new_file_io(legacy=False)
+        file_io._pyarrow_gte_22 = True
+        directory = file_io.to_filesystem_path(TABLE_PATH)
+        file_io.filesystem.get_file_info.return_value = [
+            _file_info(directory, pafs.FileType.Directory)]
+        prefix = "db-uuid.db/tbl-uuid/"
+        keys = [prefix + "data/file-{}".format(i) for i in range(1001)]
+        schema_zero = prefix + "schema/schema-0"
+        client = file_io._s3_delete_client
+        client.list_objects_v2.return_value = {
+            "Contents": [{"Key": key} for key in keys + [schema_zero]]}
+        clock = mock.Mock()
+        clock.monotonic.side_effect = \
+            lambda: 2 if client.delete_objects.call_count >= 1 else 0
+
+        with mock.patch("pypaimon.filesystem.pyarrow_file_io.time", clock), \
+                mock.patch(
+                    
"pypaimon.filesystem.pyarrow_file_io._S3_DELETE_TIMEOUT_SECONDS", 1):
+            with self.assertRaisesRegex(TimeoutError, "deleting S3 directory"):
+                file_io.delete(TABLE_PATH, recursive=True)
+
+        self.assertEqual(1, client.delete_objects.call_count)
+        self.assertEqual(1000, len(
+            client.delete_objects.call_args[1]["Delete"]["Objects"]))
+        client.delete_object.assert_not_called()
+        client.put_object.assert_not_called()
+
+    def test_mixed_delete_stops_before_batch_after_deadline(self):
+        client = mock.Mock()
+        ordinary = "db-uuid.db/tbl-uuid/data/file.parquet"
+        special = "db-uuid.db/tbl-uuid/data/part\rfile.parquet"
+        client.delete_objects.return_value = {"Deleted": [{"Key": ordinary}]}
+        clock = mock.Mock()
+        clock.monotonic.side_effect = \
+            lambda: 2 if client.delete_object.called else 0
+
+        with mock.patch("pypaimon.filesystem.pyarrow_file_io.time", clock):
+            with self.assertRaisesRegex(TimeoutError, "deleting S3 directory"):
+                PyArrowFileIO._delete_s3_objects(
+                    client, "test-bucket", [ordinary, special], 1,
+                    TABLE_PATH)
+
+        client.delete_object.assert_called_once_with(
+            Bucket="test-bucket", Key=special)
+        client.delete_objects.assert_not_called()
+
+    def test_recursive_delete_batches_at_most_1000_keys(self):
+        file_io = self._new_file_io(legacy=False)
+        file_io._pyarrow_gte_22 = True
+        directory = file_io.to_filesystem_path(TABLE_PATH)
+        file_io.filesystem.get_file_info.return_value = [
+            _file_info(directory, pafs.FileType.Directory)]
+        prefix = "db-uuid.db/tbl-uuid/"
+        keys = [prefix + "file-{}".format(i) for i in range(1001)]
+        _set_listed_keys(file_io, keys, [])
+
+        self.assertTrue(file_io.delete(TABLE_PATH, recursive=True))
+
+        calls = file_io._s3_delete_client.delete_objects.call_args_list
+        self.assertEqual([1000, 1], [
+            len(call[1]["Delete"]["Objects"]) for call in calls])
+        self.assertCountEqual(keys, [
+            item["Key"] for call in calls
+            for item in call[1]["Delete"]["Objects"]])
+
+    def test_pre_pyarrow_22_recursive_delete_keeps_native_batch(self):
+        file_io = self._new_file_io(legacy=False)
+        file_io._pyarrow_gte_22 = False
+        directory = file_io.to_filesystem_path(TABLE_PATH)
+        file_io.filesystem.get_file_info.side_effect = [
+            [_file_info(directory, pafs.FileType.Directory)],
+        ]
+
+        self.assertTrue(file_io.delete(TABLE_PATH, recursive=True))
+
+        
file_io.filesystem.delete_dir_contents.assert_called_once_with(directory)
+        file_io.filesystem.delete_dir.assert_called_once_with(directory)
+        file_io.filesystem.delete_file.assert_not_called()
+
+    def test_delete_rejects_bucket_root(self):
+        file_io = self._new_file_io(legacy=False)
+        file_io._pyarrow_gte_22 = True
+        for recursive in (False, True):
+            with self.subTest(recursive=recursive):
+                with self.assertRaisesRegex(OSError, "bucket root"):
+                    file_io.delete("oss://test-bucket/", recursive)
+
+        file_io.filesystem.get_file_info.assert_not_called()
+        file_io._s3_delete_client.delete_object.assert_not_called()
+
+    def test_old_pyarrow_delete_keeps_native_path(self):
+        file_io = self._new_file_io(legacy=False)
+        file_io._pyarrow_gte_22 = False
+        file_io.filesystem.get_file_info.return_value = [
+            _file_info("test-bucket/", pafs.FileType.NotFound)]
+
+        self.assertFalse(file_io.delete("oss://test-bucket/", recursive=True))
+        file_io.filesystem.get_file_info.assert_called_once()
+        file_io._s3_delete_client.delete_object.assert_not_called()
+
+    @unittest.skipUnless(
+        parse(pyarrow.__version__) >= parse("22.0.0"),
+        "requires PyArrow 22+ and boto3",
+    )
+    def test_delete_client_uses_pyarrow_region_with_fips_md5(self):
+        server = _ThreadingHTTPServer(
+            ("127.0.0.1", 0), _DeleteRequestHandler)
+        server.requests = []
+        server.bucket_objects = {
+            "test-bucket": {"table/", "table/data.parquet"}}
+        server.required_region = "eu-west-1"
+        server_thread = threading.Thread(target=server.serve_forever)
+        server_thread.start()
+        try:
+            options = Options({
+                S3Options.S3_ACCESS_KEY_ID.key(): "ak",
+                S3Options.S3_ACCESS_KEY_SECRET.key(): "sk",
+                S3Options.S3_ENDPOINT.key():
+                    "http://127.0.0.1:{}".format(server.server_port),
+                "fs.s3.path.style.access": "true",
+            })
+            with mock.patch.dict(os.environ, {
+                    "AWS_REGION": "eu-west-1",
+                    "NO_PROXY": "127.0.0.1,localhost",
+                    "no_proxy": "127.0.0.1,localhost",
+            }, clear=True):
+                file_io = PyArrowFileIO("s3://test-bucket/table", options)
+                self.assertEqual("eu-west-1", file_io.filesystem.region)
+                self.assertTrue(file_io.exists("s3://test-bucket/table"))
+                file_io._get_s3_delete_client()
+                real_md5 = hashlib.md5
+
+                def fips_md5(data=b"", *, usedforsecurity=True):
+                    if usedforsecurity:
+                        raise ValueError("md5 disabled for FIPS")
+                    return real_md5(data, usedforsecurity=False)
+
+                with mock.patch.object(hashlib, "md5", side_effect=fips_md5):
+                    self.assertTrue(file_io.delete(
+                        "s3://test-bucket/table", recursive=True))
+                file_io._s3_delete_client.close()
+            self.assertEqual(set(), server.bucket_objects["test-bucket"])
+            self.assertTrue(any(method == "POST" for method, _ in 
server.requests))
+        finally:
+            server.shutdown()
+            server.server_close()
+            server_thread.join()
+
+    def test_modern_non_recursive_delete_rejects_non_empty_directory(self):
+        file_io = self._new_file_io(legacy=False)
+        directory = file_io.to_filesystem_path(TABLE_PATH)
+        data_file = directory.rstrip("/") + "/data.parquet"
+        file_io.filesystem.get_file_info.side_effect = [
+            [_file_info(directory, pafs.FileType.Directory)],
+            [_file_info(data_file, pafs.FileType.File)],
+        ]
+
+        with self.assertRaisesRegex(OSError, "is not empty"):
+            file_io.delete(TABLE_PATH)
+
+        file_io.filesystem.delete_file.assert_not_called()
+
+
+class CustomS3EndpointTest(unittest.TestCase):
+    def _new_file_io(self, scheme="s3"):
+        options = Options({
+            S3Options.S3_ACCESS_KEY_ID.key(): "ak",
+            S3Options.S3_ACCESS_KEY_SECRET.key(): "sk",
+            S3Options.S3_ENDPOINT.key(): "http://minio:9000";,
+            S3Options.S3_REGION.key(): "us-east-1",
+        })
+        with mock.patch.object(
+                PyArrowFileIO, "_initialize_s3_fs", return_value=mock.Mock()):
+            file_io = PyArrowFileIO(
+                "{}://test-bucket/warehouse".format(scheme), options)
+        file_io.filesystem = mock.Mock(spec=pafs.S3FileSystem)
+        file_io._s3_delete_client = mock.Mock()
+        file_io._s3_delete_client.delete_objects.side_effect = \
+            _successful_batch_delete
+        return file_io
+
+    def test_pickle_discards_cached_delete_client(self):
+        file_io = self._new_file_io()
+        state = file_io.__getstate__()
+        self.assertNotIn("_s3_delete_client", state)
+        restored = object.__new__(PyArrowFileIO)
+        with mock.patch.object(PyArrowFileIO, "_initialize_s3_fs", 
return_value=mock.Mock()):
+            restored.__setstate__(state)
+        self.assertIsNone(restored._s3_delete_client)
+
+    def test_jindo_and_native_s3_do_not_use_delete_fallback(self):
+        file_io = self._new_file_io()
+        file_io._pyarrow_gte_22 = True
+        file_io._use_jindo = True
+        self.assertFalse(file_io._uses_s3_delete_fallback())
+        file_io._use_jindo = False
+        file_io._s3_endpoint = None
+        self.assertFalse(file_io._uses_s3_delete_fallback())
+
+    def test_recursive_delete_rejects_mismatched_list_scope(self):
+        for scope in ({"Name": "other-bucket"},
+                      {"Prefix": "table-other/"}):
+            file_io = self._new_file_io()
+            file_io._pyarrow_gte_22 = True
+            file_io.filesystem.get_file_info.return_value = [
+                _file_info("test-bucket/table", pafs.FileType.Directory)]
+            file_io._s3_delete_client.list_objects_v2.return_value = {
+                "Contents": [{"Key": "table/data.parquet"}], **scope}
+
+            with self.subTest(scope=scope):
+                with self.assertRaisesRegex(OSError, "different bucket or 
prefix"):
+                    file_io.delete("s3://test-bucket/table", recursive=True)
+
+            file_io._s3_delete_client.delete_object.assert_not_called()
+            file_io._s3_delete_client.put_object.assert_not_called()
+
+    def test_pyarrow_22_recursive_delete_uses_batch(self):
+        for scheme in ("s3", "s3a", "s3n"):
+            with self.subTest(scheme=scheme):
+                file_io = self._new_file_io(scheme)
+                file_io._pyarrow_gte_22 = True
+                path = "{}://test-bucket/table".format(scheme)
+                directory = file_io.to_filesystem_path(path)
+                data_file = directory + "/data.parquet"
+                file_io.filesystem.get_file_info.return_value = [
+                    _file_info(directory, pafs.FileType.Directory)]
+                _set_listed_keys(file_io, [data_file.split("/", 1)[1]], [])
+
+                self.assertTrue(file_io.delete(path, recursive=True))
+
+                file_io.filesystem.delete_file.assert_not_called()
+                file_io.filesystem.delete_dir_contents.assert_not_called()
+                
file_io._s3_delete_client.delete_objects.assert_called_once_with(
+                    Bucket="test-bucket", Delete={
+                        "Objects": [{"Key": "table/data.parquet"}],
+                        "Quiet": False})
+                file_io._s3_delete_client.delete_object.assert_not_called()
+
+    def test_pre_pyarrow_22_cross_bucket_delete_keeps_native_path(self):
+        file_io = self._new_file_io()
+        file_io._pyarrow_gte_22 = False
+        file_io.to_filesystem_path = mock.Mock(
+            return_value="target-bucket/table")
+        file_io.filesystem.get_file_info.return_value = [
+            _file_info("target-bucket/table", pafs.FileType.Directory)]
+
+        self.assertTrue(file_io.delete(
+            "s3://target-bucket/table", recursive=True))
+
+        file_io.filesystem.delete_dir_contents.assert_called_once_with(
+            "target-bucket/table")
+        file_io.filesystem.delete_dir.assert_called_once_with(
+            "target-bucket/table")
+        file_io._s3_delete_client.delete_object.assert_not_called()
+
+    @unittest.skipUnless(
+        parse(pyarrow.__version__) >= parse("22.0.0"),
+        "requires PyArrow 22+ and boto3",
+    )
+    def test_delete_uses_cross_bucket_list_status_path(self):
+        server = _ThreadingHTTPServer(
+            ("127.0.0.1", 0), _DeleteRequestHandler)
+        server.requests = []
+        server.put_headers = []
+        source_objects = {
+            "parent/child/", "parent/child/keep.parquet"}
+        server.bucket_objects = {
+            "source-bucket": set(source_objects),
+            "target-bucket": {
+                "parent/", "parent/child/", "parent/child/delete.parquet",
+                "parent-other/keep.parquet"},
+        }
+        server_thread = threading.Thread(target=server.serve_forever)
+        server_thread.start()
+        try:
+            options = Options({
+                S3Options.S3_ACCESS_KEY_ID.key(): "ak",
+                S3Options.S3_ACCESS_KEY_SECRET.key(): "sk",
+                S3Options.S3_ENDPOINT.key():
+                    "http://127.0.0.1:{}".format(server.server_port),
+                S3Options.S3_REGION.key(): "us-east-1",
+                "fs.s3.path.style.access": "true",
+            })
+            with mock.patch.dict(os.environ, {
+                    "AWS_REQUEST_CHECKSUM_CALCULATION": "WHEN_SUPPORTED",
+                    "NO_PROXY": "127.0.0.1,localhost",
+                    "no_proxy": "127.0.0.1,localhost",
+            }):
+                file_io = PyArrowFileIO(
+                    "s3://source-bucket/warehouse", options)
+                for bucket in ("source-bucket", "target-bucket"):
+                    with self.assertRaisesRegex(OSError, "bucket root"):
+                        file_io.delete(
+                            "s3://{}/".format(bucket), recursive=True)
+                self.assertFalse(any(
+                    method in ("DELETE", "PUT", "POST")
+                    for method, _ in server.requests))
+                before = {
+                    bucket: set(keys)
+                    for bucket, keys in server.bucket_objects.items()}
+                server.forced_list_keys = [
+                    "parent/child/delete.parquet",
+                    "parent-other/keep.parquet"]
+                with self.assertRaisesRegex(OSError, "outside prefix"):
+                    file_io.delete(
+                        "s3://target-bucket/parent/child", recursive=True)
+                self.assertEqual(before, server.bucket_objects)
+                self.assertFalse(any(
+                    method in ("DELETE", "PUT", "POST")
+                    for method, _ in server.requests))
+                del server.forced_list_keys
+                statuses = file_io.list_status(
+                    "s3://target-bucket/parent")
+                target = next(
+                    status for status in statuses
+                    if status.type == pafs.FileType.Directory)
+
+                self.assertEqual(
+                    "target-bucket/parent/child", target.path)
+                for path in (
+                        target.path,
+                        "s3://target-bucket/parent/child",
+                        "s3:/target-bucket/parent/child",
+                        "s3:target-bucket/parent/child"):
+                    for recursive in (False, True):
+                        with self.subTest(path=path, recursive=recursive):
+                            server.bucket_objects["target-bucket"] = {
+                                "parent/", "parent/child/"}
+                            if recursive:
+                                server.bucket_objects["target-bucket"].add(
+                                    "parent/child/delete.parquet")
+                            self.assertTrue(file_io.exists(path))
+                            self.assertTrue(file_io.delete(path, recursive))
+                            self.assertEqual(
+                                {"parent/"}, 
server.bucket_objects["target-bucket"])
+                            self.assertEqual(
+                                pafs.FileType.Directory,
+                                file_io.filesystem.get_file_info(
+                                    "target-bucket/parent").type)
+                            self.assertEqual(
+                                source_objects,
+                                server.bucket_objects["source-bucket"])
+                file_io._s3_delete_client.close()
+
+            self.assertEqual(
+                source_objects, server.bucket_objects["source-bucket"])
+            self.assertEqual({"parent/"}, 
server.bucket_objects["target-bucket"])
+            self.assertTrue(all(
+                urlsplit(path).path.startswith("/target-bucket/")
+                for method, path in server.requests
+                if method == "DELETE"))
+            self.assertEqual(
+                ["/target-bucket"] * 4,
+                [urlsplit(path).path for method, path in server.requests
+                 if method == "POST"])
+            self.assertEqual([], server.put_headers)
+        finally:
+            server.shutdown()
+            server.server_close()
+            server_thread.join()
+
+    @unittest.skipUnless(
+        parse(pyarrow.__version__) >= parse("22.0.0"),
+        "requires PyArrow 22+ and boto3",
+    )
+    def test_recursive_delete_control_character_keys(self):
+        server = _ThreadingHTTPServer(
+            ("127.0.0.1", 0), _DeleteRequestHandler)
+        server.requests = []
+        server.prefix = "parent/table/"
+        server.encode_list_keys = True
+        carriage_return = server.prefix + "data/part\rfile.parquet"
+        control = server.prefix + "data/part\x01file.parquet"
+        server.objects = {
+            "parent/", server.prefix, carriage_return, control,
+            server.prefix + "schema/schema-0",
+        }
+        server.late_object_added = False
+        server.missing_object_removed = False
+        server_thread = threading.Thread(target=server.serve_forever)
+        server_thread.start()
+        try:
+            options = Options({
+                S3Options.S3_ACCESS_KEY_ID.key(): "ak",
+                S3Options.S3_ACCESS_KEY_SECRET.key(): "sk",
+                S3Options.S3_ENDPOINT.key():
+                    "http://127.0.0.1:{}".format(server.server_port),
+                S3Options.S3_REGION.key(): "us-east-1",
+                "fs.s3.path.style.access": "true",
+            })
+            with mock.patch.object(
+                    PyArrowFileIO, "_initialize_s3_fs", 
return_value=mock.Mock()), \
+                    mock.patch.dict(os.environ, {
+                        "NO_PROXY": "127.0.0.1,localhost",
+                        "no_proxy": "127.0.0.1,localhost",
+                    }):
+                file_io = PyArrowFileIO("s3://test-bucket/parent/table", 
options)
+                file_io.filesystem = mock.Mock(spec=pafs.S3FileSystem)
+                file_io.filesystem.get_file_info.return_value = [
+                    _file_info("test-bucket/parent/table",
+                               pafs.FileType.Directory)]
+                self.assertTrue(file_io.delete(
+                    "s3://test-bucket/parent/table", recursive=True))
+                file_io._s3_delete_client.close()
+
+            self.assertEqual({"parent/"}, server.objects)
+            individual = [unquote(urlsplit(path).path)
+                          for method, path in server.requests
+                          if method == "DELETE"]
+            self.assertIn("/test-bucket/" + carriage_return, individual)
+            self.assertIn("/test-bucket/" + control, individual)
+            self.assertEqual(1, sum(
+                method == "POST" for method, _ in server.requests))
+        finally:
+            server.shutdown()
+            server.server_close()
+            server_thread.join()
+
+    @unittest.skipUnless(
+        parse(pyarrow.__version__) >= parse("22.0.0"),
+        "requires PyArrow 22+ and boto3",
+    )
+    def test_recursive_delete_retries_partial_batch_failure(self):
+        server = _ThreadingHTTPServer(
+            ("127.0.0.1", 0), _DeleteRequestHandler)
+        server.requests = []
+        server.prefix = "parent/table/"
+        deleted = server.prefix + "data/deleted.parquet"
+        failed = server.prefix + "data/failed.parquet"
+        schema_zero = server.prefix + "schema/schema-0"
+        server.objects = {"parent/", server.prefix, deleted, failed, 
schema_zero}
+        server.fail_delete_key_once = failed
+        server.late_object_added = False
+        server.missing_object_removed = False
+        server_thread = threading.Thread(target=server.serve_forever)
+        server_thread.start()
+        try:
+            options = Options({
+                S3Options.S3_ACCESS_KEY_ID.key(): "ak",
+                S3Options.S3_ACCESS_KEY_SECRET.key(): "sk",
+                S3Options.S3_ENDPOINT.key():
+                    "http://127.0.0.1:{}".format(server.server_port),
+                S3Options.S3_REGION.key(): "us-east-1",
+                "fs.s3.path.style.access": "true",
+            })
+            with mock.patch.object(
+                    PyArrowFileIO, "_initialize_s3_fs", 
return_value=mock.Mock()), \
+                    mock.patch.dict(os.environ, {
+                        "NO_PROXY": "127.0.0.1,localhost",
+                        "no_proxy": "127.0.0.1,localhost",
+                    }):
+                file_io = PyArrowFileIO("s3://test-bucket/parent/table", 
options)
+                file_io.filesystem = mock.Mock(spec=pafs.S3FileSystem)
+                file_io.filesystem.get_file_info.return_value = [
+                    _file_info("test-bucket/parent/table",
+                               pafs.FileType.Directory)]
+                with self.assertRaisesRegex(OSError, "AccessDenied") as error:
+                    file_io.delete(
+                        "s3://test-bucket/parent/table", recursive=True)
+                self.assertIn(failed, str(error.exception))
+                self.assertEqual({"parent/", failed}, server.objects)
+                self.assertTrue(file_io.delete(
+                    "s3://test-bucket/parent/table", recursive=True))
+                file_io._s3_delete_client.close()
+
+            self.assertEqual({"parent/"}, server.objects)
+            self.assertEqual(2, sum(
+                method == "POST" for method, _ in server.requests))
+        finally:
+            server.shutdown()
+            server.server_close()
+            server_thread.join()
+
+    @unittest.skipUnless(
+        parse(pyarrow.__version__) >= parse("22.0.0"),
+        "requires PyArrow 22+ and boto3",
+    )
+    def test_recursive_delete_reports_late_objects_during_races(self):
+        server = _ThreadingHTTPServer(
+            ("127.0.0.1", 0), _DeleteRequestHandler)
+        server.requests = []
+        server.prefix = "ta/ble/"
+        decoy = "ta/ble-other/keep.parquet"
+        server.objects = {
+            "ta/", server.prefix, server.prefix + "first.parquet", decoy}
+        server.inject_late_after_delete = server.prefix + "first.parquet"
+        server.late_object_added = False
+        server.missing_object_removed = False
+        server_thread = threading.Thread(target=server.serve_forever)
+        server_thread.start()
+        try:
+            options = Options({
+                S3Options.S3_ACCESS_KEY_ID.key(): "ak",
+                S3Options.S3_ACCESS_KEY_SECRET.key(): "sk",
+                S3Options.S3_ENDPOINT.key():
+                    "http://127.0.0.1:{}".format(server.server_port),
+                S3Options.S3_REGION.key(): "us-east-1",
+                "fs.s3.path.style.access": "true",
+            })
+            with mock.patch.object(
+                    PyArrowFileIO, "_initialize_s3_fs", 
return_value=mock.Mock()), \
+                    mock.patch.dict(os.environ, {
+                        "NO_PROXY": "127.0.0.1,localhost",
+                        "no_proxy": "127.0.0.1,localhost",
+                    }):
+                file_io = PyArrowFileIO(
+                    "s3://source-bucket/warehouse", options)
+                file_io.filesystem = mock.Mock(spec=pafs.S3FileSystem)
+                file_io.filesystem.get_file_info.return_value = [
+                    _file_info("/ta/ble", pafs.FileType.Directory)]
+
+                with self.assertRaisesRegex(OSError, "changed during 
deletion"):
+                    file_io.delete("s3://target-bucket/ta//ble", 
recursive=True)
+                file_io._s3_delete_client.close()
+
+            self.assertTrue(server.missing_object_removed)
+            self.assertEqual({decoy, "ta/", server.prefix + "late.parquet"},
+                             server.objects)
+            self.assertEqual(
+                {"GET", "POST"},
+                {method for method, _ in server.requests})
+            self.assertEqual(
+                ["/target-bucket"],
+                [urlsplit(path).path for method, path in server.requests
+                 if method == "POST"])
+        finally:
+            server.shutdown()
+            server.server_close()
+            server_thread.join()
diff --git a/paimon-python/pyproject.toml b/paimon-python/pyproject.toml
index d39be4f839..c4a4ecb893 100644
--- a/paimon-python/pyproject.toml
+++ b/paimon-python/pyproject.toml
@@ -169,6 +169,7 @@ hdfs = [
 # Tools are separated from optional integrations so old-Python core tests and
 # targeted compatibility jobs do not install unrelated native dependencies.
 test = [
+    "boto3>=1.34,<2; python_version >= '3.10'",
     "pytest>=6.2,<7; python_version < '3.7'",
     "pytest>=7,<8; python_version >= '3.7'",
     "parameterized>=0.8,<0.9; python_version < '3.7'",

Reply via email to