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 33a8cd34d3 [python] Support newer PyArrow versions (#9821)
33a8cd34d3 is described below
commit 33a8cd34d3fc23c994cbbe0b1644f61035ee01e6
Author: XiaoHongbo <[email protected]>
AuthorDate: Wed Oct 21 15:44:32 2026 +0800
[python] Support newer PyArrow versions (#9821)
---
.github/workflows/ci-python.yml | 11 ++--
docs/docs/pypaimon/catalogs.mdx | 2 +-
paimon-python/dev/lint-python.sh | 2 +-
.../execution/python/write/table_update.py | 29 +++++++++
.../pypaimon/filesystem/pyarrow_file_io.py | 35 +++++++++--
.../pypaimon/tests/s3_delete_compatibility_test.py | 39 ++++++++----
.../tests/table_update_arrow_compatibility_test.py | 73 ++++++++++++++++++++++
paimon-python/pypaimon/tests/table_update_test.py | 21 +++++++
paimon-python/pyproject.toml | 9 ++-
9 files changed, 195 insertions(+), 26 deletions(-)
diff --git a/.github/workflows/ci-python.yml b/.github/workflows/ci-python.yml
index 54d541a2bc..b14c3d5424 100644
--- a/.github/workflows/ci-python.yml
+++ b/.github/workflows/ci-python.yml
@@ -165,13 +165,13 @@ jobs:
./paimon-python/dev/lint-python.sh -e pytest_torch
s3-delete-compatibility:
- name: S3 deletion / PyArrow ${{ matrix.pyarrow }}
+ name: Compatibility / PyArrow ${{ matrix.pyarrow }}
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
- pyarrow: ['22.0.0', '23.0.0']
+ pyarrow: ['16.1.0', '20.0.0', '21.0.0', '22.0.0']
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v5
@@ -181,14 +181,15 @@ jobs:
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.
+ # Exercise each supported PyArrow version.
python -m pip install --no-deps 'pyarrow==${{ matrix.pyarrow }}'
- - name: Test S3 compatibility
+ - name: Test Arrow 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
+ pypaimon/tests/file_io_test.py
pypaimon/tests/filesystem_catalog_test.py \
+ pypaimon/tests/table_update_arrow_compatibility_test.py
rust-plan:
name: Native CI
diff --git a/docs/docs/pypaimon/catalogs.mdx b/docs/docs/pypaimon/catalogs.mdx
index e5354c7696..47f491a06f 100644
--- a/docs/docs/pypaimon/catalogs.mdx
+++ b/docs/docs/pypaimon/catalogs.mdx
@@ -48,7 +48,7 @@ catalog_options = {
catalog = CatalogFactory.create(catalog_options)
```
-For an S3 warehouse, pass S3 authentication options with the filesystem
catalog options:
+For an S3 warehouse, install `pypaimon[s3]` and pass S3 authentication options
with the filesystem catalog options:
```python
from pypaimon import CatalogFactory
diff --git a/paimon-python/dev/lint-python.sh b/paimon-python/dev/lint-python.sh
index 86d77a6782..8baf8ab0d4 100755
--- a/paimon-python/dev/lint-python.sh
+++ b/paimon-python/dev/lint-python.sh
@@ -198,7 +198,7 @@ function pytest_check() {
# 3.6/3.7 run a curated core subset (their dep ceiling rules out the
# vector/index/multimodal/blob suites); 3.10+ run the full suite.
if [ "$PYTHON_VERSION" = "3.6" ] || [ "$PYTHON_VERSION" = "3.7" ]; then
- TEST_DIR="pypaimon/tests/py36 pypaimon/tests/file_io_test.py
pypaimon/tests/arrow_schema_test.py"
+ TEST_DIR="pypaimon/tests/py36 pypaimon/tests/file_io_test.py
pypaimon/tests/arrow_schema_test.py
pypaimon/tests/table_update_arrow_compatibility_test.py"
echo "Running core test subset for Python $PYTHON_VERSION: $TEST_DIR"
else
TEST_DIR="pypaimon/tests pypaimon/acceptance
--ignore=pypaimon/tests/py36 --ignore=pypaimon/tests/e2e
--ignore=pypaimon/tests/torch_read_test.py"
diff --git a/paimon-python/pypaimon/execution/python/write/table_update.py
b/paimon-python/pypaimon/execution/python/write/table_update.py
index 3bde203300..a6b1d2b8c3 100644
--- a/paimon-python/pypaimon/execution/python/write/table_update.py
+++ b/paimon-python/pypaimon/execution/python/write/table_update.py
@@ -253,6 +253,8 @@ class PythonTableUpdateMixin:
f"{len(array)} != {row_count}."
)
if array.type != data_type:
+ PythonTableUpdateMixin._validate_assignment_struct_fields(
+ array.type, data_type)
if isinstance(array, pa.ChunkedArray):
array = pa.chunked_array(
[chunk.cast(data_type) for chunk in array.chunks],
@@ -262,6 +264,33 @@ class PythonTableUpdateMixin:
array = array.cast(data_type)
return array
+ @staticmethod
+ def _validate_assignment_struct_fields(source, target):
+ if pa.types.is_struct(source) and pa.types.is_struct(target):
+ source_fields = {field.name: field for field in source}
+ target_names = {field.name for field in target}
+ shared_names = source_fields.keys() & target_names
+ if ([field.name for field in source if field.name in shared_names]
+ != [field.name for field in target if field.name in
shared_names]):
+ raise ValueError("Assignment struct fields must match in
order.")
+ for target_field in target:
+ source_field = source_fields.get(target_field.name)
+ if source_field is not None:
+ PythonTableUpdateMixin._validate_assignment_struct_fields(
+ source_field.type, target_field.type)
+ elif pa.types.is_map(source) and pa.types.is_map(target):
+ PythonTableUpdateMixin._validate_assignment_struct_fields(
+ source.key_type, target.key_type)
+ PythonTableUpdateMixin._validate_assignment_struct_fields(
+ source.item_type, target.item_type)
+ else:
+ list_types = (pa.types.is_list, pa.types.is_large_list,
+ pa.types.is_fixed_size_list)
+ if (any(check(source) for check in list_types)
+ and any(check(target) for check in list_types)):
+ PythonTableUpdateMixin._validate_assignment_struct_fields(
+ source.value_type, target.value_type)
+
def _delete_by_partition_filter(
self, partition_filter: Predicate) -> List[CommitMessage]:
_authorize_update(self.table)
diff --git a/paimon-python/pypaimon/filesystem/pyarrow_file_io.py
b/paimon-python/pypaimon/filesystem/pyarrow_file_io.py
index 3d9bc985ee..60042cbf0c 100644
--- a/paimon-python/pypaimon/filesystem/pyarrow_file_io.py
+++ b/paimon-python/pypaimon/filesystem/pyarrow_file_io.py
@@ -557,6 +557,7 @@ class PyArrowFileIO(FileIO):
raise OSError(f"Directory {path} is not empty")
bucket, key = self._split_s3_path(path_str)
if key:
+
self._ensure_s3_parent_exists(self._get_s3_delete_client(), bucket, key)
self._get_s3_delete_client().delete_object(
Bucket=bucket, Key=key.rstrip("/") + "/")
return True
@@ -581,18 +582,35 @@ class PyArrowFileIO(FileIO):
if prefix:
prefix += "/"
deadline = time.monotonic() + _S3_DELETE_TIMEOUT_SECONDS
- # Finish listing before deleting: mutating a paginated listing may
skip keys.
+ root_schema = prefix + "schema/schema-0"
+ has_root_schema = False
+ # Finish listing before deleting; retain only the root schema-0.
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")
+ if name == root_schema:
+ has_root_schema = True
+ else:
+ 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")
+ for name in self._list_s3_keys(client, bucket, prefix, deadline,
path_str):
+ if not has_root_schema or name != root_schema:
+ raise OSError(f"S3 directory {path_str} changed during
deletion")
+ self._check_s3_delete_deadline(deadline, path_str)
+ self._ensure_s3_parent_exists(client, bucket, key)
+ if has_root_schema:
+ self._delete_s3_objects(client, bucket, [root_schema], deadline,
path_str)
return True
+ @staticmethod
+ def _ensure_s3_parent_exists(client, bucket: str, key: str):
+ parent, _, _ = key.rstrip("/").rpartition("/")
+ if parent:
+ client.put_object(Bucket=bucket, Key=parent + "/", Body=b"",
+ ContentType="application/x-directory")
+
def _list_s3_keys(self, client, bucket: str, prefix: str,
deadline: float, path_str: str):
token = None
@@ -670,7 +688,14 @@ class PyArrowFileIO(FileIO):
if self._s3_delete_client is not None:
return self._s3_delete_client
- import boto3
+ try:
+ import boto3
+ except ModuleNotFoundError as error:
+ if error.name != "boto3":
+ raise
+ raise ImportError(
+ "S3/OSS batch deletion requires pypaimon[s3] or pypaimon[oss]."
+ ) from error
from botocore.config import Config
if self._is_oss:
diff --git a/paimon-python/pypaimon/tests/s3_delete_compatibility_test.py
b/paimon-python/pypaimon/tests/s3_delete_compatibility_test.py
index f5dd6d08ec..1f7b59e1aa 100644
--- a/paimon-python/pypaimon/tests/s3_delete_compatibility_test.py
+++ b/paimon-python/pypaimon/tests/s3_delete_compatibility_test.py
@@ -24,6 +24,7 @@ import base64
import hashlib
import os
import socketserver
+import sys
import threading
import unittest
from http.server import BaseHTTPRequestHandler, HTTPServer
@@ -299,7 +300,9 @@ class OssDeleteCompatibilityTest(unittest.TestCase):
], [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()
+ client.put_object.assert_called_once_with(
+ Bucket="test-bucket", Key="db-uuid.db/", Body=b"",
+ ContentType="application/x-directory")
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()
@@ -332,7 +335,7 @@ class OssDeleteCompatibilityTest(unittest.TestCase):
self.assertEqual("next", client.list_objects_v2.call_args_list[1][1][
"ContinuationToken"])
- def test_recursive_delete_treats_schema_as_regular_key(self):
+ def test_recursive_delete_retains_schema_zero_until_validation(self):
file_io = self._new_file_io(legacy=False)
file_io._pyarrow_gte_22 = True
directory = file_io.to_filesystem_path(TABLE_PATH)
@@ -344,7 +347,7 @@ class OssDeleteCompatibilityTest(unittest.TestCase):
prefix + "snapshot/snapshot-1",
prefix + "schema/schema-1",
prefix + "data/file.parquet",
- ], [])
+ ], [prefix + "schema/schema-0"])
self.assertTrue(file_io.delete(TABLE_PATH, recursive=True))
@@ -356,6 +359,10 @@ class OssDeleteCompatibilityTest(unittest.TestCase):
prefix + "data/file.parquet",
], calls)
+ self.assertEqual(
+ [{"Key": prefix + "schema/schema-0"}],
+
file_io._s3_delete_client.delete_objects.call_args[1]["Delete"]["Objects"])
+
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
@@ -572,6 +579,13 @@ class CustomS3EndpointTest(unittest.TestCase):
restored.__setstate__(state)
self.assertIsNone(restored._s3_delete_client)
+ def test_missing_boto3_reports_optional_extra(self):
+ file_io = self._new_file_io()
+ file_io._s3_delete_client = None
+ with mock.patch.dict(sys.modules, {"boto3": None}):
+ with self.assertRaisesRegex(ImportError, r"pypaimon\[s3\]"):
+ file_io._get_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
@@ -709,7 +723,7 @@ class CustomS3EndpointTest(unittest.TestCase):
for recursive in (False, True):
with self.subTest(path=path, recursive=recursive):
server.bucket_objects["target-bucket"] = {
- "parent/", "parent/child/"}
+ "parent/child/"}
if recursive:
server.bucket_objects["target-bucket"].add(
"parent/child/delete.parquet")
@@ -737,7 +751,7 @@ class CustomS3EndpointTest(unittest.TestCase):
["/target-bucket"] * 4,
[urlsplit(path).path for method, path in server.requests
if method == "POST"])
- self.assertEqual([], server.put_headers)
+ self.assertEqual(8, len(server.put_headers))
finally:
server.shutdown()
server.server_close()
@@ -793,7 +807,7 @@ class CustomS3EndpointTest(unittest.TestCase):
if method == "DELETE"]
self.assertIn("/test-bucket/" + carriage_return, individual)
self.assertIn("/test-bucket/" + control, individual)
- self.assertEqual(1, sum(
+ self.assertEqual(2, sum(
method == "POST" for method, _ in server.requests))
finally:
server.shutdown()
@@ -810,7 +824,7 @@ class CustomS3EndpointTest(unittest.TestCase):
server.requests = []
server.prefix = "parent/table/"
deleted = server.prefix + "data/deleted.parquet"
- failed = server.prefix + "data/failed.parquet"
+ failed = server.prefix + "branch/branch-dev/schema/schema-0"
schema_zero = server.prefix + "schema/schema-0"
server.objects = {"parent/", server.prefix, deleted, failed,
schema_zero}
server.fail_delete_key_once = failed
@@ -842,13 +856,14 @@ class CustomS3EndpointTest(unittest.TestCase):
file_io.delete(
"s3://test-bucket/parent/table", recursive=True)
self.assertIn(failed, str(error.exception))
- self.assertEqual({"parent/", failed}, server.objects)
+ self.assertEqual({"parent/", failed,
"parent/table/schema/schema-0"},
+ 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(
+ self.assertEqual(3, sum(
method == "POST" for method, _ in server.requests))
finally:
server.shutdown()
@@ -866,7 +881,8 @@ class CustomS3EndpointTest(unittest.TestCase):
server.prefix = "ta/ble/"
decoy = "ta/ble-other/keep.parquet"
server.objects = {
- "ta/", server.prefix, server.prefix + "first.parquet", decoy}
+ "ta/", server.prefix, server.prefix + "first.parquet",
+ server.prefix + "schema/schema-0", decoy}
server.inject_late_after_delete = server.prefix + "first.parquet"
server.late_object_added = False
server.missing_object_removed = False
@@ -898,7 +914,8 @@ class CustomS3EndpointTest(unittest.TestCase):
file_io._s3_delete_client.close()
self.assertTrue(server.missing_object_removed)
- self.assertEqual({decoy, "ta/", server.prefix + "late.parquet"},
+ self.assertEqual({decoy, "ta/", server.prefix + "late.parquet",
+ server.prefix + "schema/schema-0"},
server.objects)
self.assertEqual(
{"GET", "POST"},
diff --git
a/paimon-python/pypaimon/tests/table_update_arrow_compatibility_test.py
b/paimon-python/pypaimon/tests/table_update_arrow_compatibility_test.py
new file mode 100644
index 0000000000..8e24201cc0
--- /dev/null
+++ b/paimon-python/pypaimon/tests/table_update_arrow_compatibility_test.py
@@ -0,0 +1,73 @@
+# 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.
+
+import pyarrow as pa
+import pytest
+
+from pypaimon.write.table_update import BatchTableUpdate
+
+
+WRAPPERS = [
+ pytest.param(lambda t: t, lambda v: v, id="struct"),
+ pytest.param(lambda t: pa.struct([('outer', t)]),
+ lambda v: {'outer': v}, id="nested-struct"),
+ pytest.param(pa.list_, lambda v: [v], id="list"),
+ pytest.param(pa.large_list, lambda v: [v], id="large-list"),
+ pytest.param(lambda t: pa.list_(t, 1), lambda v: [v], id="fixed-list"),
+ pytest.param(lambda t: pa.map_(pa.string(), t),
+ lambda v: [('key', v)], id="map-value"),
+ pytest.param(lambda t: pa.list_(pa.map_(pa.string(), t)),
+ lambda v: [[('key', v)]], id="list-map-value"),
+]
+
+
[email protected]("wrap_type,wrap_value", WRAPPERS)
[email protected]("chunked", [False, True])
[email protected]("projection", [
+ "same", "subset-first", "subset-last", "add-field", "reordered",
+])
+def test_predicate_assignment_struct_cast(wrap_type, wrap_value, chunked,
projection):
+ source = wrap_type(pa.struct([('a', pa.int32()), ('b', pa.int32())]))
+ fields = [('a', pa.int64()), ('b', pa.int64())]
+ if projection == "subset-first":
+ fields = fields[:1]
+ elif projection == "subset-last":
+ fields = fields[1:]
+ elif projection == "add-field":
+ fields.append(('c', pa.int64()))
+ elif projection == "reordered":
+ fields.reverse()
+ target = wrap_type(pa.struct(fields))
+ value = wrap_value({'a': 1, 'b': 2})
+ values = pa.array([value, None], type=source)
+ if chunked:
+ values = pa.chunked_array([values.slice(0, 1), values.slice(1)])
+
+ if projection == "reordered":
+ with pytest.raises(ValueError, match="struct fields must match in
order"):
+ BatchTableUpdate._assignment_to_array(values, target, 2)
+ else:
+ try:
+ expected = values.cast(target).to_pylist()
+ except pa.ArrowNotImplementedError:
+ # Older Arrow versions do not implement STRUCT casts.
+ with pytest.raises(pa.ArrowNotImplementedError):
+ BatchTableUpdate._assignment_to_array(values, target, 2)
+ return
+ result = BatchTableUpdate._assignment_to_array(values, target, 2)
+ assert result.type == target
+ assert result.to_pylist() == expected
diff --git a/paimon-python/pypaimon/tests/table_update_test.py
b/paimon-python/pypaimon/tests/table_update_test.py
index ddbc4d9a82..4f832bdb82 100644
--- a/paimon-python/pypaimon/tests/table_update_test.py
+++ b/paimon-python/pypaimon/tests/table_update_test.py
@@ -555,6 +555,27 @@ class _TableUpdateTestBase(DataEvolutionTestBase):
rows,
)
+ def test_update_by_predicate_accepts_struct_subset_assignment(self):
+ schema = pa.schema([
+ ('id', pa.int32()),
+ ('payload', pa.struct([('a', pa.int64())])),
+ ])
+ source_type = pa.struct([('a', pa.int32()), ('b', pa.int32())])
+ for chunked in (False, True):
+ with self.subTest(chunked=chunked):
+ table = self._create_table(pa_schema=schema)
+ self._write_arrow(table, pa.Table.from_pylist(
+ [{'id': 1, 'payload': {'a': 0}}], schema=schema))
+ values = pa.array([{'a': 7, 'b': 99}], type=source_type)
+ if chunked:
+ values = pa.chunked_array([values])
+ predicate =
table.new_read_builder().new_predicate_builder().equal('id', 1)
+
+ self._do_update_by_predicate(table, predicate, {'payload':
values})
+
+ self.assertEqual(
+ [{'a': 7}], self._read_all(table)['payload'].to_pylist())
+
def test_update_by_predicate_accepts_callable_assignments(self):
table = self._create_seeded_table()
pb = table.new_read_builder().new_predicate_builder()
diff --git a/paimon-python/pyproject.toml b/paimon-python/pyproject.toml
index c8761d5856..ded227052f 100644
--- a/paimon-python/pyproject.toml
+++ b/paimon-python/pyproject.toml
@@ -63,7 +63,7 @@ dependencies = [
"polars>=1.32,<2; python_version >= '3.9'",
"pyarrow>=6,<7; python_version < '3.7'",
"pyarrow>=7,<13; python_version >= '3.7' and python_version < '3.8'",
- "pyarrow>=16,!=19.0.0,<20; python_version >= '3.8'",
+ "pyarrow>=16,!=19.0.0,<23; python_version >= '3.8'",
"pyroaring<=0.3.3; python_version < '3.7'",
"pyroaring<=0.4.5; python_version == '3.7'",
"pyroaring>=1.0.0,<=1.0.3; python_version == '3.8'",
@@ -97,10 +97,9 @@ hdf5 = [
rosbag = [
"rosbags>=0.11.5,<0.12; python_version >= '3.10'",
]
-# datasets 4.1+ may require PyArrow 21+, outside the supported core range.
# Pandas 2.2.2+ supports NumPy 2 selected by the media dependencies.
lerobot = [
- "datasets>=4,<4.1; python_version >= '3.10'",
+ "datasets>=4,<5; python_version >= '3.10'",
"pandas>=2.2.2,<3; python_version >= '3.10'",
"torch>=2.3; python_version >= '3.10'",
"lerobot>=0.4.4,<0.5; python_version >= '3.10'",
@@ -121,10 +120,14 @@ daft = [
"daft>=0.7.6; python_version >= '3.10'",
]
oss = [
+ "boto3>=1.34,<2; python_version >= '3.10'",
"oss2>=2.18,<3",
"ossfs>=2021.8; python_version < '3.8'",
"ossfs>=2023; python_version >= '3.8'",
]
+s3 = [
+ "boto3>=1.34,<2; python_version >= '3.10'",
+]
jindo = [
"oss2>=2.18,<3",
"pyjindosdk>=6.10.4",