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 0c24e922d7 [python] Keep standalone helpers shared and row key
factories internal (#10781)
0c24e922d7 is described below
commit 0c24e922d73decaf2131b7246c51dada19e1250e
Author: Jingsong Lee <[email protected]>
AuthorDate: Fri Oct 23 22:33:38 2026 +0800
[python] Keep standalone helpers shared and row key factories internal
(#10781)
---
paimon-python/dev/test_packaging.py | 9 ++--
paimon-python/pypaimon/catalog/table_query_auth.py | 6 +--
.../arrow_predicate.py} | 2 +-
.../write => common}/external_path_provider.py | 2 +-
paimon-python/pypaimon/execution/README.md | 30 ++++++-----
.../python/read/reader/auth_masking_reader.py | 2 +-
.../execution/python/write/merge_condition.py | 62 ----------------------
.../execution/python/write/merge_transform.py | 6 +--
.../execution/python/write/row_key_extractor.py | 20 +++++++
.../pypaimon/execution/python/write/table_write.py | 5 +-
.../execution/python/write/writer/data_writer.py | 2 +-
paimon-python/pypaimon/ray/merge_condition.py | 6 +--
paimon-python/pypaimon/read/read_dispatch.py | 5 --
paimon-python/pypaimon/table/file_store_table.py | 11 +---
.../pypaimon/tests/execution_layout_test.py | 9 ++++
.../pypaimon/tests/external_paths_test.py | 2 +-
.../tests/multimodal_temporal_boundary_test.py | 2 +-
.../pypaimon/tests/native_cast_completion_test.py | 2 +-
.../pypaimon/tests/native_write_type_test.py | 5 +-
.../pypaimon/tests/predicate_json_parser_test.py | 2 +-
.../public_execution_factory_boundary_test.py | 39 +++++++++-----
.../tests/query_auth_execution_boundary_test.py | 23 +++++++-
.../pypaimon/tests/test_aggregation_e2e.py | 2 +-
.../pypaimon/tests/test_sequence_field_write.py | 2 +-
.../pypaimon/tests/write/table_write_test.py | 3 +-
.../pypaimon/utils/file_store_path_factory.py | 6 +--
paimon-python/pypaimon/write/backend_dispatch.py | 42 ---------------
paimon-python/pypaimon/write/merge_condition.py | 39 ++++++++++++++
28 files changed, 168 insertions(+), 178 deletions(-)
diff --git a/paimon-python/dev/test_packaging.py
b/paimon-python/dev/test_packaging.py
index ddf4877ec2..56e1725533 100644
--- a/paimon-python/dev/test_packaging.py
+++ b/paimon-python/dev/test_packaging.py
@@ -131,14 +131,13 @@ class PackagingTest(unittest.TestCase):
"pypaimon/execution/python/read/search/distributed_full_text_search.py",
"pypaimon/execution/python/map_blob_key_serializer.py",
"pypaimon/execution/python/globalindex/ray_index_build.py",
- "pypaimon/execution/python/write/external_path_provider.py",
- "pypaimon/execution/python/read/predicate_json_parser.py",
+ "pypaimon/common/external_path_provider.py",
+ "pypaimon/common/arrow_predicate.py",
"pypaimon/execution/python/read/blob_view_lookup.py",
"pypaimon/execution/python/read/interval_partition.py",
"pypaimon/execution/python/read/map_shared_shredding.py",
"pypaimon/execution/python/read/variant_shredding.py",
"pypaimon/execution/python/write/dynamic_bucket.py",
- "pypaimon/execution/python/write/merge_condition.py",
"pypaimon/execution/python/write/merge_transform.py",
"pypaimon/schema/data_type_casts.py",
"pypaimon/utils/numeric_utils.py",
@@ -221,7 +220,9 @@ class PackagingTest(unittest.TestCase):
if name.startswith("pypaimon/execution/") and name.endswith(".py"):
self.assertIn(name.split("/")[2], ("python", "native",
"__init__.py"))
internal_aliases = (
- "pypaimon/common/external_path_provider.py",
+ "pypaimon/execution/python/write/external_path_provider.py",
+ "pypaimon/execution/python/read/predicate_json_parser.py",
+ "pypaimon/execution/python/write/merge_condition.py",
"pypaimon/common/map_blob_key_serializer.py",
"pypaimon/utils/blob_view_lookup.py",
"pypaimon/read/interval_partition.py",
diff --git a/paimon-python/pypaimon/catalog/table_query_auth.py
b/paimon-python/pypaimon/catalog/table_query_auth.py
index 4a486cb6d0..8803475853 100644
--- a/paimon-python/pypaimon/catalog/table_query_auth.py
+++ b/paimon-python/pypaimon/catalog/table_query_auth.py
@@ -136,12 +136,12 @@ class TableQueryAuthResult:
def extract_row_filter(self) -> Optional[Callable[[pa.RecordBatch],
pa.Array]]:
if not self.filter:
return None
- from pypaimon.read.read_dispatch import create_python_row_filter
+ from pypaimon.common.arrow_predicate import
parse_predicate_to_batch_filter
if len(self.filter) == 1:
- return create_python_row_filter(self.filter[0])
+ return parse_predicate_to_batch_filter(self.filter[0])
# Java PredicateBuilder.and evaluates entries in their original order.
- return create_python_row_filter(json.dumps({
+ return parse_predicate_to_batch_filter(json.dumps({
'kind': 'COMPOUND', 'function': 'AND',
'children': [json.loads(rule) for rule in self.filter],
}))
diff --git
a/paimon-python/pypaimon/execution/python/read/predicate_json_parser.py
b/paimon-python/pypaimon/common/arrow_predicate.py
similarity index 99%
rename from
paimon-python/pypaimon/execution/python/read/predicate_json_parser.py
rename to paimon-python/pypaimon/common/arrow_predicate.py
index f3bde3c2e9..916a849232 100644
--- a/paimon-python/pypaimon/execution/python/read/predicate_json_parser.py
+++ b/paimon-python/pypaimon/common/arrow_predicate.py
@@ -16,7 +16,7 @@
# limitations under the License.
################################################################################
-"""Execute REST row filters and column-mask transforms on Python Arrow
batches."""
+"""Compile REST predicates and column transforms for standalone Arrow
batches."""
import json
import re
diff --git
a/paimon-python/pypaimon/execution/python/write/external_path_provider.py
b/paimon-python/pypaimon/common/external_path_provider.py
similarity index 99%
rename from
paimon-python/pypaimon/execution/python/write/external_path_provider.py
rename to paimon-python/pypaimon/common/external_path_provider.py
index 8ded4dba5d..8457850113 100644
--- a/paimon-python/pypaimon/execution/python/write/external_path_provider.py
+++ b/paimon-python/pypaimon/common/external_path_provider.py
@@ -15,7 +15,7 @@
# specific language governing permissions and limitations
# under the License.
-"""Python writer strategies for selecting the next external file location."""
+"""Shared strategies for selecting the next external file location."""
import bisect
import ctypes
diff --git a/paimon-python/pypaimon/execution/README.md
b/paimon-python/pypaimon/execution/README.md
index 88845fe0aa..7f40826489 100644
--- a/paimon-python/pypaimon/execution/README.md
+++ b/paimon-python/pypaimon/execution/README.md
@@ -41,6 +41,9 @@ Keep this boundary when adding or moving code:
```text
pypaimon/
+ common/
+ arrow_predicate.py Standalone Arrow predicate and transform
helpers
+ external_path_provider.py Shared external file placement strategies
read/ Public read/scan APIs, Arrow output and explain
read_dispatch.py Read/scan selection across the two backends
read_support.py Shared output, schema and reader ownership
@@ -51,7 +54,7 @@ pypaimon/
write_dispatch.py Writer coordination and safe pre-write
switching
update_dispatch.py Update/upsert/delete/MERGE coordination
input_adapter.py Pandas transport and distributed input
conversion
- merge_condition.py Shared MERGE condition parsing and validation
+ merge_condition.py Shared MERGE condition parsing and Arrow batch
filtering
merge_transform.py Shared Arrow assignments and callable inputs
ray_datasink.py Ray worker writes and coordinator commits
globalindex/ Public index contracts and backend coordination
@@ -71,12 +74,10 @@ pypaimon/
search/ Vector/text/hybrid search and Python factories
blob_view_lookup.py Upstream Blob descriptor planning, loading and
resolution
interval_partition.py Primary-key sorted-run grouping
- predicate_json_parser.py Authorization row-filter and masking execution
map_shared_shredding.py Physical MAP decoding and selected-column
pruning
variant_shredding.py Physical VARIANT reconstruction and shredding
deletionvectors/ DV bitmap codecs, file I/O and deleted-row
filtering
write/ File writers, committers and update execution
- external_path_provider.py Per-writer external file placement strategies
globalindex/ Index file codecs, readers, writers and build
algorithms
index/ Primary-key index source selection and
sorted-index grouping
merge_engine_dispatch.py Python merge-function selection
@@ -149,9 +150,9 @@ executor; Native commits pass the spec to Rust.
Physical MAP/VARIANT codecs and Blob View resolution belong to Python
execution. Public value objects, schema/spec parsing and selected-key metadata
-stay in `data`. MERGE condition evaluation and first-matching-clause selection
-also belong to Python execution; callable transport, SQL rewriting and shared
-Arrow assignment adapters stay in `write`.
+stay in `data`. Table MERGE action selection belongs to the selected backend.
+Standalone Arrow condition filtering, SQL rewriting, callable transport and
+shared Arrow assignment adapters stay in `write`.
Dynamic bucket HASH index publication and writer-owned pending state belong
to the Python writer. The shared input bucket assigner remains in `index`:
@@ -159,18 +160,19 @@ distributed producers use it to route inputs before
either backend writes.
`input_routing` and `bucket_planning` similarly serve both backends' input
protocols without retaining a file writer or publishing data.
-External-path round-robin, weighted selection and entropy directories belong
-to Python write execution. Shared path factories retain configuration and
-existing-file resolution, loading a provider only when Python allocates a new
-file. MAP BLOB key serializers implement the physical file format and are
+External-path round-robin, weighted selection and entropy directories are
shared
+path utilities in `common`. Shared path factories retain configuration and
+existing-file resolution, loading a provider when a caller allocates a new
file.
+MAP BLOB key serializers implement the physical file format and are
shared within the Python backend. Generic URI/FileIO contracts, resource
caches and integer codecs stay in `common`; Native Blob inputs also use the
URI reader contract.
-Authorization row-filter and column-mask evaluation belongs to Python read
-execution. JSON field-reference extraction and rule/schema validation remain
-shared. The public authorization result creates a Python filter lazily when
-explicitly requested; Native reads pass the original rules to Rust.
+Standalone Arrow predicate and transform helpers live in
`common/arrow_predicate`.
+JSON field-reference extraction and rule/schema validation remain independent
+of that Arrow evaluator. The public authorization result creates an Arrow
+filter lazily when explicitly requested without selecting either backend.
+Python readers use the shared evaluator; Native reads pass the original rules
to Rust.
Framework adapters keep their actual implementation in their original modules,
including their public class identities, serialization and worker closures.
diff --git
a/paimon-python/pypaimon/execution/python/read/reader/auth_masking_reader.py
b/paimon-python/pypaimon/execution/python/read/reader/auth_masking_reader.py
index 3c8e12b7d8..6ad5df55f0 100644
--- a/paimon-python/pypaimon/execution/python/read/reader/auth_masking_reader.py
+++ b/paimon-python/pypaimon/execution/python/read/reader/auth_masking_reader.py
@@ -22,7 +22,7 @@ from typing import Callable, Dict, List, Optional
import pyarrow as pa
from pypaimon.common.predicate_json_parser import
_collect_all_field_refs_from_transform
-from pypaimon.execution.python.read.predicate_json_parser import
_apply_predicate_transform
+from pypaimon.common.arrow_predicate import _apply_predicate_transform
from pypaimon.read.reader.iface.record_batch_reader import RecordBatchReader
from pypaimon.read.reader.iface.record_iterator import RecordIterator
from pypaimon.read.reader.iface.record_reader import RecordReader
diff --git a/paimon-python/pypaimon/execution/python/write/merge_condition.py
b/paimon-python/pypaimon/execution/python/write/merge_condition.py
deleted file mode 100644
index f0c66cea11..0000000000
--- a/paimon-python/pypaimon/execution/python/write/merge_condition.py
+++ /dev/null
@@ -1,62 +0,0 @@
-################################################################################
-# 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.
-################################################################################
-
-"""Evaluate MERGE conditions with the Python DataFusion engine."""
-
-import pyarrow as pa
-
-
-def filter_batch(
- batch: pa.Table, condition: str, _pre_rewritten: bool = False,
-) -> pa.Table:
- if batch.num_rows == 0:
- return batch
- from pypaimon.write.merge_condition import _load_datafusion,
rewrite_condition
-
- datafusion = _load_datafusion()
- rewritten = condition if _pre_rewritten else rewrite_condition(condition)
- config = datafusion.SessionConfig().set(
- "datafusion.optimizer.enable_round_robin_repartition", "false"
- )
- ctx = datafusion.SessionContext(config)
- input_batches = batch.to_batches()
- # Use one batch per partition and rebuild from partitioned batches so
- # DataFusion neither concatenates 32-bit offsets nor reorders the input.
- ctx.register_record_batches(
- "_batch", [[record_batch] for record_batch in input_batches]
- )
- result = ctx.sql(
- f'SELECT * FROM _batch WHERE {rewritten}'
- )
- output_batches = [
- record_batch
- for partition in result.collect_partitioned()
- for record_batch in partition
- ]
- if not output_batches:
- return batch.schema.empty_table()
- return pa.Table.from_batches(output_batches)
-
-
-def apply_condition(
- batch: pa.Table, rewritten: str, empty_schema: pa.Schema,
-) -> pa.Table:
- batch = filter_batch(batch, rewritten, _pre_rewritten=True)
- if batch.num_rows == 0:
- return empty_schema.empty_table()
- return batch
diff --git a/paimon-python/pypaimon/execution/python/write/merge_transform.py
b/paimon-python/pypaimon/execution/python/write/merge_transform.py
index d009eccc1b..6819b41126 100644
--- a/paimon-python/pypaimon/execution/python/write/merge_transform.py
+++ b/paimon-python/pypaimon/execution/python/write/merge_transform.py
@@ -56,7 +56,7 @@ def _build_matched_transform(
_filter_batch = None
if any(r is not None for _, r, _ in prepared_clauses):
_load_datafusion()
- from pypaimon.execution.python.write.merge_condition import
filter_batch as _filter_batch
+ from pypaimon.write.merge_condition import filter_batch as
_filter_batch
def _transform(batch: pa.Table) -> pa.Table:
remaining = batch
@@ -123,7 +123,7 @@ def _build_matched_delete_transform(
_filter_batch = None
if any(r is not None for r, _ in prepared_clauses):
_load_datafusion()
- from pypaimon.execution.python.write.merge_condition import
filter_batch as _filter_batch
+ from pypaimon.write.merge_condition import filter_batch as
_filter_batch
def _transform(batch: pa.Table) -> pa.Table:
remaining = batch
@@ -174,7 +174,7 @@ def _build_insert_transform(
_filter_batch = None
if any(r is not None for _, r in prepared_clauses):
_load_datafusion()
- from pypaimon.execution.python.write.merge_condition import
filter_batch as _filter_batch
+ from pypaimon.write.merge_condition import filter_batch as
_filter_batch
def _transform(batch: pa.Table) -> pa.Table:
remaining = batch
diff --git a/paimon-python/pypaimon/execution/python/write/row_key_extractor.py
b/paimon-python/pypaimon/execution/python/write/row_key_extractor.py
index 81589e0926..eb34e3304e 100644
--- a/paimon-python/pypaimon/execution/python/write/row_key_extractor.py
+++ b/paimon-python/pypaimon/execution/python/write/row_key_extractor.py
@@ -520,3 +520,23 @@ class PostponeBucketRowKeyExtractor(RowKeyExtractor):
class PostponeFixedBucketRowKeyExtractor(PostponeFixedBucketInputRouter,
RowKeyExtractor):
"""Route Python file writes with the shared distributed input router."""
+
+
+def create_row_key_extractor(table, ignore_existing=False):
+ """Construct a per-row extractor for Python file writing."""
+ bucket_mode = table.bucket_mode()
+ if bucket_mode == BucketMode.HASH_FIXED:
+ return FixedBucketRowKeyExtractor(table.table_schema)
+ elif bucket_mode == BucketMode.BUCKET_UNAWARE:
+ return UnawareBucketRowKeyExtractor(table.table_schema)
+ elif bucket_mode == BucketMode.POSTPONE_MODE:
+ return PostponeBucketRowKeyExtractor(table.table_schema)
+ elif bucket_mode == BucketMode.HASH_DYNAMIC:
+ return DynamicBucketRowKeyExtractor(
+ table.table_schema, table=table, ignore_existing=ignore_existing)
+ elif bucket_mode == BucketMode.CROSS_PARTITION:
+ raise ValueError(
+ "CROSS_PARTITION primary-key writes require a persistent "
+ "global primary-key index, which PyPaimon does not yet support"
+ )
+ raise ValueError(f"Unsupported bucket mode: {bucket_mode}")
diff --git a/paimon-python/pypaimon/execution/python/write/table_write.py
b/paimon-python/pypaimon/execution/python/write/table_write.py
index ac0d892000..61a53562fd 100644
--- a/paimon-python/pypaimon/execution/python/write/table_write.py
+++ b/paimon-python/pypaimon/execution/python/write/table_write.py
@@ -78,9 +78,8 @@ class PythonTableWrite:
writer.table.table_schema, table=writer.table,
ignore_existing=static_partition is not None,
base_snapshot_id=writer.restore_snapshot_id)
- return writer.table.create_row_key_extractor(
- ignore_existing=static_partition is not None
- )
+ from pypaimon.execution.python.write.row_key_extractor import
create_row_key_extractor
+ return create_row_key_extractor(writer.table,
ignore_existing=static_partition is not None)
def write_arrow(self, data, bucket: Optional[int] = None):
"""Write Arrow data, optionally to a bucket assigned upstream."""
diff --git
a/paimon-python/pypaimon/execution/python/write/writer/data_writer.py
b/paimon-python/pypaimon/execution/python/write/writer/data_writer.py
index 548743d3be..98a3136abc 100644
--- a/paimon-python/pypaimon/execution/python/write/writer/data_writer.py
+++ b/paimon-python/pypaimon/execution/python/write/writer/data_writer.py
@@ -22,7 +22,7 @@ from abc import ABC, abstractmethod
from typing import Dict, List, Optional, Tuple
from pypaimon.common.options.core_options import CoreOptions, ChangelogProducer
-from pypaimon.execution.python.write.external_path_provider import
ExternalPathProvider
+from pypaimon.common.external_path_provider import ExternalPathProvider
from pypaimon.data.timestamp import Timestamp
from pypaimon.manifest.schema.data_file_meta import DataFileMeta
from pypaimon.manifest.schema.simple_stats import SimpleStats
diff --git a/paimon-python/pypaimon/ray/merge_condition.py
b/paimon-python/pypaimon/ray/merge_condition.py
index 7afcda4c7a..0985557668 100644
--- a/paimon-python/pypaimon/ray/merge_condition.py
+++ b/paimon-python/pypaimon/ray/merge_condition.py
@@ -17,7 +17,7 @@
################################################################################
-"""Public MERGE condition helpers retain their shared and Python delegates."""
+"""Public MERGE condition helpers delegate to the shared Arrow utilities."""
from typing import Mapping, Optional, Set
@@ -51,14 +51,14 @@ def remap_source_on_keys(
def filter_batch(
batch: pa.Table, condition: str, _pre_rewritten: bool = False,
) -> pa.Table:
- from pypaimon.write.backend_dispatch import filter_merge_condition_batch
as execute
+ from pypaimon.write.merge_condition import filter_batch as execute
return execute(batch, condition, _pre_rewritten)
def apply_condition(
batch: pa.Table, rewritten: str, empty_schema: pa.Schema,
) -> pa.Table:
- from pypaimon.write.backend_dispatch import apply_merge_condition as
execute
+ from pypaimon.write.merge_condition import apply_condition as execute
return execute(batch, rewritten, empty_schema)
diff --git a/paimon-python/pypaimon/read/read_dispatch.py
b/paimon-python/pypaimon/read/read_dispatch.py
index 2e2867fedb..a78356f2b6 100644
--- a/paimon-python/pypaimon/read/read_dispatch.py
+++ b/paimon-python/pypaimon/read/read_dispatch.py
@@ -55,11 +55,6 @@ def python_row_group_cache(request):
return request._cache
-def create_python_row_filter(rule_json):
- from pypaimon.execution.python.read.predicate_json_parser import
parse_predicate_to_batch_filter
- return parse_predicate_to_batch_filter(rule_json)
-
-
def validate_read_backend(read):
"""Validate the explicitly selected Python backend without loading
Native."""
if not read.table.options.native_read_enabled():
diff --git a/paimon-python/pypaimon/table/file_store_table.py
b/paimon-python/pypaimon/table/file_store_table.py
index 40e0dcb816..4ca3e6bde4 100644
--- a/paimon-python/pypaimon/table/file_store_table.py
+++ b/paimon-python/pypaimon/table/file_store_table.py
@@ -15,7 +15,7 @@
# specific language governing permissions and limitations
# under the License.
-from typing import List, Optional, Set, TYPE_CHECKING
+from typing import List, Optional, Set
from pypaimon.catalog.catalog_environment import CatalogEnvironment
from pypaimon.common.file_io import FileIO
@@ -30,9 +30,6 @@ from pypaimon.table.bucket_mode import BucketMode
from pypaimon.table.table import Table
from pypaimon.write.write_builder import BatchWriteBuilder, StreamWriteBuilder
-if TYPE_CHECKING:
- from pypaimon.execution.python.write.row_key_extractor import
RowKeyExtractor
-
class FileStoreTable(Table):
def __init__(self, file_io: FileIO, identifier: Identifier, table_path:
str,
@@ -486,12 +483,6 @@ class FileStoreTable(Table):
dry_run=dry_run,
)
- def create_row_key_extractor(
- self, ignore_existing: bool = False
- ) -> 'RowKeyExtractor':
- from pypaimon.write.backend_dispatch import create_row_key_extractor
- return create_row_key_extractor(self, ignore_existing)
-
def copy(self, options: dict) -> 'FileStoreTable':
return self._copy(options, resolve_time_travel=True)
diff --git a/paimon-python/pypaimon/tests/execution_layout_test.py
b/paimon-python/pypaimon/tests/execution_layout_test.py
index 8c20b61fb5..beed1f371c 100644
--- a/paimon-python/pypaimon/tests/execution_layout_test.py
+++ b/paimon-python/pypaimon/tests/execution_layout_test.py
@@ -61,11 +61,20 @@ modules = [
'pypaimon.schema.arrow_schema', 'pypaimon.schema.data_type_casts',
'pypaimon.schema.schema_manager', 'pypaimon.utils.file_store_path_factory',
'pypaimon.table.system.files_table',
'pypaimon.common.predicate_json_parser',
+ 'pypaimon.common.transform_schema', 'pypaimon.common.arrow_predicate',
+ 'pypaimon.common.external_path_provider',
'pypaimon.common.uri_reader', 'pypaimon.common.blob_index_cache',
'pypaimon.common.delta_varint_compressor', 'pypaimon.common.file_io',
]
for name in modules:
importlib.import_module(name)
+
+from pypaimon.utils.file_store_path_factory import FileStorePathFactory
+factory = FileStorePathFactory(
+ '/warehouse', ['p'], '__DEFAULT_PARTITION__', 'parquet', 'data-',
'changelog-',
+ False, False, 'zstd', external_paths=['/external'],
external_path_strategy='round-robin')
+provider = factory.create_external_path_provider(('x',), 0,
canonical_partition=True)
+assert provider.get_next_external_data_path('data.parquet') ==
'/external/p=x/bucket-0/data.parquet'
assert not any(name == 'pypaimon.execution' or
name.startswith('pypaimon.execution.')
for name in sys.modules)
"""
diff --git a/paimon-python/pypaimon/tests/external_paths_test.py
b/paimon-python/pypaimon/tests/external_paths_test.py
index 0a6457f12d..a77d3506a9 100644
--- a/paimon-python/pypaimon/tests/external_paths_test.py
+++ b/paimon-python/pypaimon/tests/external_paths_test.py
@@ -24,7 +24,7 @@ import pyarrow as pa
from pypaimon import CatalogFactory, Schema
from pypaimon.catalog.catalog import Identifier
-from pypaimon.execution.python.write.external_path_provider import (
+from pypaimon.common.external_path_provider import (
EntropyInjectExternalPathProvider, ExternalPathProvider,
RoundRobinExternalPathProvider, WeightedExternalPathProvider, _murmur3_32)
from pypaimon.common.options.core_options import (CoreOptions,
diff --git a/paimon-python/pypaimon/tests/multimodal_temporal_boundary_test.py
b/paimon-python/pypaimon/tests/multimodal_temporal_boundary_test.py
index 4dc94cec86..5fc030a059 100644
--- a/paimon-python/pypaimon/tests/multimodal_temporal_boundary_test.py
+++ b/paimon-python/pypaimon/tests/multimodal_temporal_boundary_test.py
@@ -81,7 +81,7 @@ assert not loaded, loaded
])
def test_temporal_mask_schema_matches_actual_python_mask(data_type, transform):
from pypaimon.common.transform_schema import effective_masked_schema
- from pypaimon.execution.python.read.predicate_json_parser import
_apply_predicate_transform
+ from pypaimon.common.arrow_predicate import _apply_predicate_transform
schema = pa.schema([pa.field('value', data_type, nullable=False,
metadata={b'field': b'value'})],
metadata={b'schema': b'value'})
diff --git a/paimon-python/pypaimon/tests/native_cast_completion_test.py
b/paimon-python/pypaimon/tests/native_cast_completion_test.py
index 845ad33f48..bcbe4042ea 100644
--- a/paimon-python/pypaimon/tests/native_cast_completion_test.py
+++ b/paimon-python/pypaimon/tests/native_cast_completion_test.py
@@ -55,7 +55,7 @@ def native_only():
'pypaimon.execution.python.read.table_read.PythonTableRead._create_split_read',
side_effect=AssertionError('Native read fell back')
), \
-
patch('pypaimon.execution.python.read.predicate_json_parser._apply_predicate_transform',
+ patch('pypaimon.common.arrow_predicate._apply_predicate_transform',
side_effect=AssertionError('Python executed an authorization
transform')), \
patch('pypaimon.execution.python.read.reader.auth_masking_reader._apply_predicate_transform',
side_effect=AssertionError('Python executed an authorization
mask')):
diff --git a/paimon-python/pypaimon/tests/native_write_type_test.py
b/paimon-python/pypaimon/tests/native_write_type_test.py
index 7d081dea09..59fca4d726 100644
--- a/paimon-python/pypaimon/tests/native_write_type_test.py
+++ b/paimon-python/pypaimon/tests/native_write_type_test.py
@@ -27,6 +27,7 @@ import pytest
from pypaimon import Schema
from pypaimon.execution.native.runtime import (native_runtime_available)
+from pypaimon.execution.python.write.row_key_extractor import
create_row_key_extractor
from pypaimon.table.row.generic_row import GenericRow
from pypaimon.tests import native_plan_rest_test
from pypaimon.write.write_dispatch import NativeTableWrite
@@ -173,7 +174,7 @@ def
test_full_input_routes_before_projecting_unwritten_routing_fields(
data = pa.Table.from_pylist([
dict(id=1, pt=7, value=42), dict(id=None, pt=None, value=43),
], schema=_SCHEMA)
- partitions, bucket_ids =
table.create_row_key_extractor().extract_partition_bucket_batch(data.to_batches()[0])
+ partitions, bucket_ids =
create_row_key_extractor(table).extract_partition_bucket_batch(data.to_batches()[0])
expected_buckets = dict(zip(partitions, bucket_ids))
builder = table.new_batch_write_builder()
writer = builder.new_write()
@@ -210,7 +211,7 @@ def
test_rows_route_with_unwritten_columns_without_requiring_other_fields(
values = dict(id=1, pt=7, value=42, unwritten=blob if unwritten_blob else
999)
row = GenericRow([values[name] for name in names], [fields[name] for name
in names])
full_input = pa.Table.from_pylist([dict(values, unwritten=None)],
schema=schema).to_batches()[0]
- _, buckets =
table.create_row_key_extractor().extract_partition_bucket_batch(full_input)
+ _, buckets =
create_row_key_extractor(table).extract_partition_bucket_batch(full_input)
builder = table.new_batch_write_builder()
writer = builder.new_write()
try:
diff --git a/paimon-python/pypaimon/tests/predicate_json_parser_test.py
b/paimon-python/pypaimon/tests/predicate_json_parser_test.py
index 66cbc9bf37..ea3d94d9bb 100644
--- a/paimon-python/pypaimon/tests/predicate_json_parser_test.py
+++ b/paimon-python/pypaimon/tests/predicate_json_parser_test.py
@@ -24,7 +24,7 @@ from pathlib import Path
import pyarrow as pa
from pypaimon.common.predicate_json_parser import extract_referenced_fields
-from pypaimon.execution.python.read.predicate_json_parser import (
+from pypaimon.common.arrow_predicate import (
_apply_predicate_transform,
_convert_literal,
_paimon_type_to_arrow,
diff --git
a/paimon-python/pypaimon/tests/public_execution_factory_boundary_test.py
b/paimon-python/pypaimon/tests/public_execution_factory_boundary_test.py
index 7869fedb20..6f4b928dbb 100644
--- a/paimon-python/pypaimon/tests/public_execution_factory_boundary_test.py
+++ b/paimon-python/pypaimon/tests/public_execution_factory_boundary_test.py
@@ -15,7 +15,7 @@
# specific language governing permissions and limitations
# under the License.
-"""Public factories retain captured inputs and enter their selected dispatch
boundary."""
+"""Public factories retain captured inputs and keep standalone helpers
independent."""
import subprocess
import sys
@@ -172,7 +172,9 @@ assert not any(name.startswith('pypaimon.execution.python')
for name in sys.modu
('-2', ['id', 'p'], 'PostponeBucketRowKeyExtractor'),
('-1', ['id', 'p'], 'DynamicBucketRowKeyExtractor'),
])
-def test_public_row_key_factory_preserves_per_row_contract(tmp_path, bucket,
primary_keys, expected):
+def test_python_row_key_factory_preserves_per_row_contract(tmp_path, bucket,
primary_keys, expected):
+ from pypaimon.execution.python.write.row_key_extractor import
create_row_key_extractor
+
catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
catalog.create_database('default', True)
arrow_schema = pa.schema([('id', pa.int32()), ('p', pa.string())])
@@ -184,7 +186,7 @@ def
test_public_row_key_factory_preserves_per_row_contract(tmp_path, bucket, pri
rows = pa.record_batch([pa.array([1, 2], pa.int32()), pa.array(['a',
'a'])], schema=arrow_schema)
with patch('pypaimon.execution.native.runtime.native_runtime_available',
side_effect=AssertionError('Per-row helper rediscovered the
Native writer')):
- extractor = table.create_row_key_extractor(ignore_existing=True)
+ extractor = create_row_key_extractor(table, ignore_existing=True)
partitions, buckets = extractor.extract_partition_bucket_batch(rows)
assert type(extractor).__name__ == expected
assert partitions == [('a',), ('a',)]
@@ -194,16 +196,29 @@ def
test_public_row_key_factory_preserves_per_row_contract(tmp_path, bucket, pri
assert buckets == [-2 if bucket == '-2' else 0] * 2
-def test_ray_condition_compatibility_helpers_keep_python_execution():
+def test_shared_merge_condition_helpers_do_not_load_execution():
if sys.version_info < (3, 10):
pytest.skip('Python MERGE conditions require Python 3.10')
pytest.importorskip('datafusion')
- from pypaimon.ray.merge_condition import apply_condition, filter_batch,
rewrite_condition
+ source = r'''
+import importlib.abc
+import sys
+import pyarrow as pa
- rows = pa.table({'s.id': pa.chunked_array([[1, 2], [3]]), 't.id': [2, 1,
2]})
- with patch('pypaimon.execution.native.runtime.native_runtime_available',
- side_effect=AssertionError('Python condition helper discovered
Native execution')):
- assert filter_batch(rows, 's.id > t.id').column('s.id').to_pylist() ==
[2, 3]
- schema = pa.schema([('s.id', pa.int64())])
- empty = apply_condition(rows, rewrite_condition('s.id < 0'), schema)
- assert empty.num_rows == 0 and empty.schema == schema
+class RejectExecution(importlib.abc.MetaPathFinder):
+ def find_spec(self, fullname, path=None, target=None):
+ if fullname.startswith(('pypaimon.execution', 'pypaimon_rust')):
+ raise AssertionError('Shared condition helper selected execution:
' + fullname)
+
+sys.meta_path.insert(0, RejectExecution())
+from pypaimon.ray.merge_condition import apply_condition, filter_batch,
rewrite_condition
+rows = pa.table({'s.id': pa.chunked_array([[1, 2], [3]]), 't.id': [2, 1, 2]})
+assert filter_batch(rows, 's.id > t.id').column('s.id').to_pylist() == [2, 3]
+schema = pa.schema([('s.id', pa.int64())])
+empty = apply_condition(rows, rewrite_condition('s.id < 0'), schema)
+assert empty.num_rows == 0 and empty.schema == schema
+assert not any(name.startswith(('pypaimon.execution', 'pypaimon_rust')) for
name in sys.modules)
+'''
+ result = subprocess.run([sys.executable, '-c', source],
stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE, universal_newlines=True,
timeout=30)
+ assert result.returncode == 0, result.stdout + result.stderr
diff --git a/paimon-python/pypaimon/tests/query_auth_execution_boundary_test.py
b/paimon-python/pypaimon/tests/query_auth_execution_boundary_test.py
index 0284b763ef..9bc4a3471e 100644
--- a/paimon-python/pypaimon/tests/query_auth_execution_boundary_test.py
+++ b/paimon-python/pypaimon/tests/query_auth_execution_boundary_test.py
@@ -15,7 +15,7 @@
# specific language governing permissions and limitations
# under the License.
-"""Authorization metadata validation does not select a read executor."""
+"""Authorization metadata and standalone Arrow filters do not select an
executor."""
import subprocess
import sys
@@ -26,6 +26,8 @@ def
test_authorization_fields_validate_without_loading_either_execution_backend(
import importlib.abc
import json
import sys
+from datetime import datetime
+import pyarrow as pa
class RejectExecution(importlib.abc.MetaPathFinder):
def find_spec(self, fullname, path=None, target=None):
@@ -59,6 +61,25 @@ result.validate_read_type(fields, fields)
assert result.get_extra_fields([fields[1]], fields) == [fields[0]]
assert TableQueryAuthResult(None, None).extract_row_filter() is None
assert TableQueryAuthResult([], None).extract_row_filter() is None
+assert 'pypaimon.common.arrow_predicate' not in sys.modules
+
+batch = pa.RecordBatch.from_pydict({
+ 'event_time': pa.array([datetime(2026, 1, 2), datetime(2025, 1, 2)],
type=pa.timestamp('ms')),
+ 'secret': ['first', 'second'],
+})
+assert result.extract_row_filter()(batch).to_pylist() == [True, False]
+cast = json.dumps({
+ 'kind': 'LEAF', 'function': 'EQUAL', 'literals': ['42'],
+ 'transform': {'name': 'CAST', 'fieldRef': {'name': 'value', 'type':
'INT'}, 'type': 'STRING'},
+})
+assert TableQueryAuthResult([cast], None).extract_row_filter()(
+ pa.RecordBatch.from_pydict({'value': [42, 43]})).to_pylist() == [True,
False]
+like = json.dumps({
+ 'kind': 'LEAF', 'function': 'LIKE', 'literals': ['a%'],
+ 'transform': {'name': 'FIELD_REF', 'fieldRef': {'name': 'value', 'type':
'STRING'}},
+})
+assert TableQueryAuthResult([like], None).extract_row_filter()(
+ pa.RecordBatch.from_pydict({'value': ['abc', 'xyz']})).to_pylist() ==
[True, False]
try:
TableQueryAuthResult([year_filter('missing')],
None).validate_against_schema(fields)
diff --git a/paimon-python/pypaimon/tests/test_aggregation_e2e.py
b/paimon-python/pypaimon/tests/test_aggregation_e2e.py
index 31968d0d78..3eee1c327b 100644
--- a/paimon-python/pypaimon/tests/test_aggregation_e2e.py
+++ b/paimon-python/pypaimon/tests/test_aggregation_e2e.py
@@ -297,7 +297,7 @@ class AggregationMergeEngineE2ETest(unittest.TestCase):
writer_class = StreamTableWrite if streaming else BatchTableWrite
# A rejected writer must never create the bucket index maintainer,
# including callers that construct TableWrite directly.
- with patch.object(table, 'create_row_key_extractor') as extractor:
+ with
patch('pypaimon.execution.python.write.row_key_extractor.create_row_key_extractor')
as extractor:
for create in (builder.new_write, lambda: writer_class(table,
'test')):
with self.subTest(streaming=streaming, create=create):
with self.assertRaisesRegex(
diff --git a/paimon-python/pypaimon/tests/test_sequence_field_write.py
b/paimon-python/pypaimon/tests/test_sequence_field_write.py
index 5a3231189f..47130214f6 100644
--- a/paimon-python/pypaimon/tests/test_sequence_field_write.py
+++ b/paimon-python/pypaimon/tests/test_sequence_field_write.py
@@ -221,7 +221,7 @@ def
test_dynamic_bucket_sequence_rejected_before_index_creation(
builder = (table.new_stream_write_builder() if streaming
else table.new_batch_write_builder())
writer_class = StreamTableWrite if streaming else BatchTableWrite
- with patch.object(table, 'create_row_key_extractor') as extractor:
+ with
patch('pypaimon.execution.python.write.row_key_extractor.create_row_key_extractor')
as extractor:
with pytest.raises(error):
if direct:
writer_class(table, 'test')
diff --git a/paimon-python/pypaimon/tests/write/table_write_test.py
b/paimon-python/pypaimon/tests/write/table_write_test.py
index 8ab45cb3ac..5685f0253f 100644
--- a/paimon-python/pypaimon/tests/write/table_write_test.py
+++ b/paimon-python/pypaimon/tests/write/table_write_test.py
@@ -38,6 +38,7 @@ from pypaimon.common.options.options import Options
from pypaimon.manifest.manifest_list_manager import ManifestListManager
from pypaimon.write.table_write import TableWrite
from pypaimon.execution.python.write.table_write import PythonTableWrite
+from pypaimon.execution.python.write.row_key_extractor import
create_row_key_extractor
from pypaimon.execution.python.write.writer.append_only_data_writer import
AppendOnlyDataWriter
@@ -363,7 +364,7 @@ class TableWriteTest(unittest.TestCase):
self.pa_schema, partition_keys=partition_keys,
options={'bucket': '-1'})
self.catalog.create_table(name, schema, False)
- return self.catalog.get_table(name).create_row_key_extractor()
+ return create_row_key_extractor(self.catalog.get_table(name))
def test_extract_partition_bucket_groups_multi_partition(self):
ex = self._unaware_partitioned_extractor(
diff --git a/paimon-python/pypaimon/utils/file_store_path_factory.py
b/paimon-python/pypaimon/utils/file_store_path_factory.py
index 7ba5342b7c..b66b54f406 100644
--- a/paimon-python/pypaimon/utils/file_store_path_factory.py
+++ b/paimon-python/pypaimon/utils/file_store_path_factory.py
@@ -26,7 +26,7 @@ from pypaimon.table.bucket_mode import BucketMode
from pypaimon.table.row.generic_row import _is_ltz_type, _normalize_ltz,
_parse_type_precision_scale
if TYPE_CHECKING:
- from pypaimon.execution.python.write.external_path_provider import
ExternalPathProvider
+ from pypaimon.common.external_path_provider import ExternalPathProvider
def _is_null_or_whitespace_only(value) -> bool:
@@ -246,10 +246,10 @@ class FileStorePathFactory:
if not self.external_paths:
return None
- from pypaimon.write.backend_dispatch import
create_external_path_provider
+ from pypaimon.common.external_path_provider import ExternalPathProvider
relative_bucket_path = self.relative_bucket_path(partition, bucket,
canonical_partition)
- return create_external_path_provider(
+ return ExternalPathProvider.create(
self.external_path_strategy,
self.external_paths,
relative_bucket_path,
diff --git a/paimon-python/pypaimon/write/backend_dispatch.py
b/paimon-python/pypaimon/write/backend_dispatch.py
index ab773ac5af..d72931ac08 100644
--- a/paimon-python/pypaimon/write/backend_dispatch.py
+++ b/paimon-python/pypaimon/write/backend_dispatch.py
@@ -565,48 +565,6 @@ def close_table_commit(owner):
pass
-def create_row_key_extractor(table, ignore_existing=False):
- """Construct the public per-row extractor through the Python write
boundary."""
- from pypaimon.table.bucket_mode import BucketMode
- from pypaimon.execution.python.write.row_key_extractor import (
- DynamicBucketRowKeyExtractor, FixedBucketRowKeyExtractor,
- PostponeBucketRowKeyExtractor, UnawareBucketRowKeyExtractor,
- )
-
- bucket_mode = table.bucket_mode()
- if bucket_mode == BucketMode.HASH_FIXED:
- return FixedBucketRowKeyExtractor(table.table_schema)
- elif bucket_mode == BucketMode.BUCKET_UNAWARE:
- return UnawareBucketRowKeyExtractor(table.table_schema)
- elif bucket_mode == BucketMode.POSTPONE_MODE:
- return PostponeBucketRowKeyExtractor(table.table_schema)
- elif bucket_mode == BucketMode.HASH_DYNAMIC:
- return DynamicBucketRowKeyExtractor(
- table.table_schema, table=table, ignore_existing=ignore_existing)
- elif bucket_mode == BucketMode.CROSS_PARTITION:
- raise ValueError(
- "CROSS_PARTITION primary-key writes require a persistent "
- "global primary-key index, which PyPaimon does not yet support"
- )
- raise ValueError(f"Unsupported bucket mode: {bucket_mode}")
-
-
-def filter_merge_condition_batch(batch, condition, pre_rewritten=False):
- """Execute the public Python condition helper without selecting a table
writer."""
- from pypaimon.execution.python.write.merge_condition import filter_batch
- return filter_batch(batch, condition, pre_rewritten)
-
-
-def apply_merge_condition(batch, rewritten, empty_schema):
- from pypaimon.execution.python.write.merge_condition import apply_condition
- return apply_condition(batch, rewritten, empty_schema)
-
-
-def create_external_path_provider(strategy, paths, relative_bucket_path,
weights):
- from pypaimon.execution.python.write.external_path_provider import
ExternalPathProvider
- return ExternalPathProvider.create(strategy, paths, relative_bucket_path,
weights)
-
-
def execute_row_id_conflict_check(table, messages, latest_snapshot_id,
check_from_snapshot):
if table.options.native_commit_enabled():
from pypaimon.execution.native.write.native_commit import
prepare_native_row_id_conflict_check
diff --git a/paimon-python/pypaimon/write/merge_condition.py
b/paimon-python/pypaimon/write/merge_condition.py
index 884c369dfc..ae96b0f073 100644
--- a/paimon-python/pypaimon/write/merge_condition.py
+++ b/paimon-python/pypaimon/write/merge_condition.py
@@ -250,3 +250,42 @@ def _safe_literals(field, literals) -> bool:
if type_name in {'STRING', 'CHAR', 'VARCHAR'}:
return all(isinstance(literal, str) for literal in literals)
return False
+
+
+def filter_batch(
+ batch: pa.Table, condition: str, _pre_rewritten: bool = False,
+) -> pa.Table:
+ if batch.num_rows == 0:
+ return batch
+ datafusion = _load_datafusion()
+ rewritten = condition if _pre_rewritten else rewrite_condition(condition)
+ config = datafusion.SessionConfig().set(
+ "datafusion.optimizer.enable_round_robin_repartition", "false"
+ )
+ ctx = datafusion.SessionContext(config)
+ input_batches = batch.to_batches()
+ # Use one batch per partition and rebuild from partitioned batches so
+ # DataFusion neither concatenates 32-bit offsets nor reorders the input.
+ ctx.register_record_batches(
+ "_batch", [[record_batch] for record_batch in input_batches]
+ )
+ result = ctx.sql(
+ f'SELECT * FROM _batch WHERE {rewritten}'
+ )
+ output_batches = [
+ record_batch
+ for partition in result.collect_partitioned()
+ for record_batch in partition
+ ]
+ if not output_batches:
+ return batch.schema.empty_table()
+ return pa.Table.from_batches(output_batches)
+
+
+def apply_condition(
+ batch: pa.Table, rewritten: str, empty_schema: pa.Schema,
+) -> pa.Table:
+ batch = filter_batch(batch, rewritten, _pre_rewritten=True)
+ if batch.num_rows == 0:
+ return empty_schema.empty_table()
+ return batch