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 3ffb4e4ce8 [python] Support Variant extraction in native row iterators 
(#10842)
3ffb4e4ce8 is described below

commit 3ffb4e4ce8524d91ccc7ad6d27544ab324c61cac
Author: QuakeWang <[email protected]>
AuthorDate: Wed Oct 28 15:27:50 2026 +0800

    [python] Support Variant extraction in native row iterators (#10842)
---
 paimon-python/pypaimon/read/read_dispatch.py       |   7 -
 paimon-python/pypaimon/read/table_read.py          |   2 +-
 .../tests/named_projection_iterator_test.py        |  12 +-
 .../pypaimon/tests/native_variant_iterator_test.py | 210 +++++++++++++++++++++
 paimon-python/pypaimon/tests/variant_test.py       |   4 +-
 5 files changed, 219 insertions(+), 16 deletions(-)

diff --git a/paimon-python/pypaimon/read/read_dispatch.py 
b/paimon-python/pypaimon/read/read_dispatch.py
index 9c3fc4bcbc..6a78ae8fce 100644
--- a/paimon-python/pypaimon/read/read_dispatch.py
+++ b/paimon-python/pypaimon/read/read_dispatch.py
@@ -92,13 +92,6 @@ def to_iterator(self, splits: List[Split]) -> Iterator:
     Python split reader, the iterator reuses its row view and visits splits
     in order. Close the iterator when stopping before exhaustion.
     """
-    from pypaimon.read.variant_read_type import has_variant_extractions
-
-    if (self.output_projection is not None and self.output_projection.named
-            and has_variant_extractions(self.read_type)):
-        raise RuntimeError(
-            "Variant extraction expressions are not supported by 
to_iterator(); "
-            "use to_arrow() or to_arrow_batch_reader()")
     self._begin_auth_read(splits)
     limit = self.limit
 
diff --git a/paimon-python/pypaimon/read/table_read.py 
b/paimon-python/pypaimon/read/table_read.py
index 7761aea4d4..45f50bd5da 100644
--- a/paimon-python/pypaimon/read/table_read.py
+++ b/paimon-python/pypaimon/read/table_read.py
@@ -107,7 +107,7 @@ class TableRead:
         in order. Close the iterator when stopping before exhaustion.
 
         Named columns and nested paths retain their projection order. Named
-        VARIANT extraction expressions require the Arrow APIs.
+        VARIANT extraction expressions require native reading.
         """
         from pypaimon.read.read_dispatch import to_iterator
         return to_iterator(self, splits)
diff --git a/paimon-python/pypaimon/tests/named_projection_iterator_test.py 
b/paimon-python/pypaimon/tests/named_projection_iterator_test.py
index 9f0936af40..1f54d3dc16 100644
--- a/paimon-python/pypaimon/tests/named_projection_iterator_test.py
+++ b/paimon-python/pypaimon/tests/named_projection_iterator_test.py
@@ -258,21 +258,23 @@ def 
test_named_projection_keeps_non_streaming_torch_row_boundary(projected_table
 @pytest.mark.parametrize('function', ['variant_get', 'try_variant_get'])
 @pytest.mark.parametrize('nested', [False, True])
 @pytest.mark.parametrize('mixed', [False, True])
-def test_named_iterator_rejects_variant_extractions(tmp_path, function, 
nested, mixed):
+def 
test_variant_iterator_requires_native_and_keeps_torch_restriction(tmp_path, 
function, nested, mixed):
     catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
     catalog.create_database('db', True)
     payload = DataField(2 if nested else 1, 'payload', AtomicType('VARIANT'))
     fields = [DataField(0, 'id', AtomicType('BIGINT')),
               DataField(1, 'profile', RowType(True, [payload])) if nested else 
payload]
-    catalog.create_table('db.t', Schema(fields, options={'bucket': '-1'}), 
False)
+    catalog.create_table('db.t', Schema(fields, options={
+        'bucket': '-1', 'read.native.enabled': 'false', 
'scan.native-plan.enabled': 'false'}), False)
     source = 'profile.payload' if nested else 'payload'
     projection = {'x': "%s(%s, '$.x', 'float')" % (function, source)}
     if mixed:
         projection['user_id'] = 'id'
     read = 
catalog.get_table('db.t').new_read_builder().with_projection(projection).new_read()
-    with patch('pypaimon.read.read_dispatch._native_read_execution') as native:
-        with pytest.raises(RuntimeError, match='Variant extraction expressions 
are not supported by to_iterator'):
-            read.to_iterator([])
+    with patch.object(NativeTableRead, 'read_rows') as native, \
+            patch.object(PythonTableRead, 'read_rows', 
side_effect=AssertionError('Python Variant execution')):
+        with pytest.raises(RuntimeError, match='Variant extraction requires a 
compatible native reader'):
+            list(read.to_iterator([]))
         for streaming in (False, True):
             with pytest.raises(RuntimeError, match='not supported by Torch row 
format'):
                 read.to_torch([], streaming=streaming, batch_format='row')
diff --git a/paimon-python/pypaimon/tests/native_variant_iterator_test.py 
b/paimon-python/pypaimon/tests/native_variant_iterator_test.py
new file mode 100644
index 0000000000..7cd902fcbd
--- /dev/null
+++ b/paimon-python/pypaimon/tests/native_variant_iterator_test.py
@@ -0,0 +1,210 @@
+# 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.
+
+"""Native Variant projections retain the public InternalRow iterator 
contract."""
+
+from unittest.mock import patch
+
+import pyarrow as pa
+import pytest
+
+from pypaimon import CatalogFactory, Schema
+from pypaimon.data.generic_variant import GenericVariant
+from pypaimon.execution.native.read.table_read import native_read
+from pypaimon.execution.native.runtime import native_runtime_available
+from pypaimon.execution.python.read.table_read import PythonTableRead
+from pypaimon.schema.arrow_schema import read_arrow_schema
+from pypaimon.table.row.row_kind import RowKind
+
+
+pytestmark = pytest.mark.skipif(not native_runtime_available(), 
reason='pypaimon-rust not installed')
+
+
[email protected](params=['append', 'pk', 'de'])
+def variant_table(tmp_path, request):
+    mode = request.param
+    payload = GenericVariant.to_arrow_array([
+        GenericVariant.from_python({'score': 1.25, 'good': 2.5}),
+        GenericVariant.from_python({'score': 'bad', 'good': 3.5}),
+        GenericVariant.from_python({}), None,
+        GenericVariant.from_python({'score': None}), None,
+    ])
+    options = {
+        'bucket': '1' if mode == 'pk' else '-1',
+        'file.format': 'parquet', 'read.batch-size': '2',
+        'source.split.target-size': '1b',
+        'read.native.enabled': 'true', 'scan.native-plan.enabled': 'false',
+        'write.native.enabled': 'false', 'commit.native.enabled': 'false',
+    }
+    if mode == 'de':
+        options.update({'data-evolution.enabled': 'true', 
'row-tracking.enabled': 'true'})
+    data = pa.Table.from_arrays([
+        pa.array(range(1, 7), type=pa.int64()), payload,
+        pa.StructArray.from_arrays([payload], names=['payload'], 
mask=pa.array([False] * 5 + [True])),
+    ], names=['id', 'payload', 'profile'])
+    catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
+    catalog.create_database('db', True)
+    catalog.create_table('db.t', Schema.from_pyarrow_schema(
+        data.schema, primary_keys=['id'] if mode == 'pk' else None, 
options=options), False)
+    table = catalog.get_table('db.t')
+    for offset in range(0, data.num_rows, 2):
+        builder = table.new_batch_write_builder()
+        writer, commit = builder.new_write(), builder.new_commit()
+        try:
+            writer.write_arrow(data.slice(offset, 2))
+            commit.commit(writer.prepare_commit())
+        finally:
+            writer.close()
+            commit.close()
+    with patch.object(PythonTableRead, 'read_rows', 
side_effect=AssertionError('Python fallback')), \
+            patch.object(PythonTableRead, 'read_batches', 
side_effect=AssertionError('Python fallback')):
+        yield table
+
+
[email protected](params=['payload', 'profile.payload'])
+def variant_builder(variant_table, request):
+    source = request.param
+    builder = variant_table.new_read_builder().with_projection({
+        'score': "try_variant_get(%s, '$.score', 'float')" % source,
+        'identifier': 'id',
+        'score_copy': "try_variant_get(%s, '$.score', 'float')" % source,
+        'good': "variant_get(%s, '$.good', 'float')" % source,
+        'identifier_copy': 'id',
+    })
+    return builder, source
+
+
+def _rows(iterator):
+    try:
+        result = []
+        for row in iterator:
+            assert row.get_row_kind() == RowKind.INSERT
+            result.append(tuple(row.get_field(index) for index in 
range(len(row))))
+        return result
+    finally:
+        iterator.close()
+
+
[email protected]('limit', [None, 0, 1, 3])
+def test_variant_iterator_matches_arrow(variant_builder, limit):
+    builder, _ = variant_builder
+    splits = builder.new_scan().plan().splits()
+    assert len(splits) >= 2
+    if limit is not None:
+        builder.with_limit(limit)
+    read = builder.new_read()
+    expected = [(1.25, 1, 1.25, 2.5, 1), (None, 2, None, 3.5, 2)]
+    expected += [(None, value, None, None, value) for value in range(3, 7)]
+    if limit is not None:
+        expected = expected[:limit]
+    assert _rows(read.to_iterator(splits)) == expected
+    arrow = read.to_arrow(splits, parallelism=1)
+    assert arrow.column_names == [alias for alias, _ in 
read.output_projection.columns]
+    assert arrow.schema.field('score').type == pa.float32()
+    assert list(zip(*(column.to_pylist() for column in arrow.columns))) == 
expected
+    with read.to_arrow_batch_reader(splits, parallelism=1) as batches:
+        assert [tuple(row.values()) for batch in batches for row in 
batch.to_pylist()] == expected
+    assert _rows(read.to_iterator([])) == []
+    assert read.include_row_kind is False
+
+
+def test_variant_iterator_propagates_strict_cast_errors(variant_builder):
+    builder, source = variant_builder
+    builder.with_projection({'score': "variant_get(%s, '$.score', 'float')" % 
source})
+    splits = builder.new_scan().plan().splits()
+    read = builder.new_read()
+    with pytest.raises(Exception, match='Cannot cast Variant'):
+        read.to_arrow(splits, parallelism=1)
+    with pytest.raises(Exception, match='Cannot cast Variant'):
+        _rows(read.to_iterator(splits))
+
+
[email protected]('failure', ['setup', 'unsupported_split'])
+def test_variant_iterator_never_falls_back_to_python(variant_builder, failure):
+    builder, _ = variant_builder
+    splits = builder.new_scan().plan().splits()
+    target, options = (
+        ('native_read', {'side_effect': RuntimeError('Native setup failed')}) 
if failure == 'setup' else
+        ('NativeTableRead._split_files_supported', {'return_value': False}))
+    with patch('pypaimon.execution.native.read.table_read.' + target, 
**options):
+        with pytest.raises(RuntimeError, match='Variant extraction requires a 
compatible native reader'):
+            _rows(builder.new_read().to_iterator(splits))
+
+
[email protected]('stop', ['unstarted', 'close', 'failure', 'exhaust'])
+def test_variant_iterator_batch_lifetime(variant_builder, stop):
+    fetched, closed = [], []
+    failure = OSError('Native stream failed')
+
+    def tracked_native(*args, **kwargs):
+        batches = native_read(*args, **kwargs)
+
+        def tracked_batches():
+            try:
+                for batch in batches:
+                    fetched.append(batch.num_rows)
+                    yield batch
+                    if stop == 'failure':
+                        raise failure
+            finally:
+                batches.close()
+                closed.append(True)
+
+        return tracked_batches()
+
+    builder, _ = variant_builder
+    splits = builder.new_scan().plan().splits()
+    with patch('pypaimon.execution.native.read.table_read.native_read', 
side_effect=tracked_native) as native:
+        iterator = builder.new_read().to_iterator(splits)
+        native.assert_not_called()
+        try:
+            if stop != 'unstarted':
+                assert next(iterator).get_field(0) == 1.25
+                assert len(fetched) == 1
+                assert closed == []
+                if stop == 'failure':
+                    with pytest.raises(OSError) as raised:
+                        list(iterator)
+                    assert raised.value is failure
+                elif stop == 'exhaust':
+                    assert len(list(iterator)) == 5
+        finally:
+            iterator.close()
+    assert native.call_count == int(stop != 'unstarted')
+    assert closed == ([] if stop == 'unstarted' else [True])
+    if stop in ('close', 'failure'):
+        assert len(fetched) == 1
+    if stop == 'exhaust':
+        assert sum(fetched) == 6
+
+
+def test_variant_iterator_preserves_row_kinds(variant_builder):
+    builder, path = variant_builder
+    read = builder.new_read()
+    source = {'0': 1.25, '1': 1.25, '2': 2.5}
+    field = path.split('.')[0]
+    payload = {'payload': source} if field == 'profile' else source
+    kinds = ['+I', '-U', '+U', '-D']
+    batch = pa.RecordBatch.from_pylist([
+        {'rowkind': kind, field: payload, 'id': index + 1} for index, kind in 
enumerate(kinds)
+    ], schema=pa.schema([pa.field('rowkind', pa.string())] + 
list(read_arrow_schema(read.read_type))))
+    splits = builder.new_scan().plan().splits()
+    with patch('pypaimon.execution.native.read.table_read.native_read', 
return_value=iter([batch])):
+        actual = [(tuple(row.get_field(index) for index in range(len(row))), 
row.get_row_kind())
+                  for row in read.to_iterator(splits)]
+    assert actual == [((1.25, index + 1, 1.25, 2.5, index + 1), 
RowKind.from_string(kind))
+                      for index, kind in enumerate(kinds)]
diff --git a/paimon-python/pypaimon/tests/variant_test.py 
b/paimon-python/pypaimon/tests/variant_test.py
index dccaf07c85..b83694fca3 100644
--- a/paimon-python/pypaimon/tests/variant_test.py
+++ b/paimon-python/pypaimon/tests/variant_test.py
@@ -1262,7 +1262,7 @@ class TestVariantPaimonTable(unittest.TestCase):
             pa.field('payload', _variant_arrow_type()),
         ])
 
-    def test_native_variant_projection_rejects_row_readers(self):
+    def test_variant_projection_keeps_torch_row_restriction(self):
         for mode, options in (
             ('append', {}),
             ('lazy', {
@@ -1295,8 +1295,6 @@ class TestVariantPaimonTable(unittest.TestCase):
                 self.assertTrue(splits)
                 read = builder.new_read()
 
-                with self.assertRaisesRegex(RuntimeError, 'to_iterator'):
-                    read.to_iterator(splits)
                 with self.assertRaisesRegex(RuntimeError, 'Torch row format'):
                     read.to_torch(splits, streaming=True)
                 with self.assertRaisesRegex(RuntimeError, 'Torch row format'):

Reply via email to