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 d76769838b [python] Align INT/BIGINT and timestamp evolution with Java 
(#10508)
d76769838b is described below

commit d76769838bd1c191e8e62167f9f173c66a252c21
Author: Jingsong Lee <[email protected]>
AuthorDate: Mon Oct 12 13:55:18 2026 +0800

    [python] Align INT/BIGINT and timestamp evolution with Java (#10508)
---
 paimon-python/pypaimon/casting/data_type_casts.py  |  15 ++-
 paimon-python/pypaimon/casting/schema_evolution.py |  46 +++++++-
 .../pypaimon/tests/native_predicate_delete_test.py |   2 +-
 .../tests/native_schema_evolution_cast_test.py     | 121 +++++++++++++++++++++
 .../pypaimon/tests/schema_evolution_cast_test.py   |  58 ++++++++++
 .../tests/schema_evolution_nested_read_test.py     |   9 ++
 paimon-python/pypaimon/tests/table_update_test.py  |  36 +++++-
 paimon-python/pypaimon/write/table_delete.py       |   8 +-
 8 files changed, 280 insertions(+), 15 deletions(-)

diff --git a/paimon-python/pypaimon/casting/data_type_casts.py 
b/paimon-python/pypaimon/casting/data_type_casts.py
index 819b9c115c..849be57e54 100644
--- a/paimon-python/pypaimon/casting/data_type_casts.py
+++ b/paimon-python/pypaimon/casting/data_type_casts.py
@@ -192,7 +192,7 @@ def _equals_ignore_nullable(source_type, target_type) -> 
bool:
     return source_copy == target_copy
 
 
-# Caches the PyArrow cast-kernel probe per (source, target) pyarrow type so the
+# Caches the read-time cast probe per (source, target) pyarrow type so the
 # alter-time check stays cheap. Keyed by the pyarrow type strings.
 _EXECUTABLE_CAST_CACHE = {}
 
@@ -225,12 +225,14 @@ def can_execute_cast(source_type, target_type) -> bool:
     # Any other conversion touching a constructed type has no runtime cast.
     if source_root in CONSTRUCTED or target_root in CONSTRUCTED:
         return False
-    # Leaf-to-leaf: defer to PyArrow's cast-kernel availability, which is the
-    # read path's actual cast executor (``array.cast(target, safe=False)``).
-    return _pyarrow_cast_supported(source_type, target_type)
+    # Leaf-to-leaf: probe the same executor used by old-file readers, including
+    # Java conversions with no direct PyArrow kernel, such as INT -> TIMESTAMP.
+    return _schema_evolution_cast_supported(source_type, target_type)
 
 
-def _pyarrow_cast_supported(source_type, target_type) -> bool:
+def _schema_evolution_cast_supported(source_type, target_type) -> bool:
+    from pypaimon.casting.schema_evolution import 
cast_array_for_schema_evolution
+
     source_pa = PyarrowFieldParser.from_paimon_type(source_type)
     target_pa = PyarrowFieldParser.from_paimon_type(target_type)
     if source_pa == target_pa:
@@ -248,7 +250,8 @@ def _pyarrow_cast_supported(source_type, target_type) -> 
bool:
     # avoiding per-value parse/overflow errors (which ``safe=False`` -- 
matching
     # the read path -- tolerates anyway).
     try:
-        pa.nulls(1, type=source_pa).cast(target_pa, safe=False)
+        cast_array_for_schema_evolution(
+            pa.nulls(1, type=source_pa), target_pa, source_type, target_type)
         ok = True
     except (pa.lib.ArrowNotImplementedError, pa.lib.ArrowInvalid,
             pa.lib.ArrowTypeError):
diff --git a/paimon-python/pypaimon/casting/schema_evolution.py 
b/paimon-python/pypaimon/casting/schema_evolution.py
index 62793ec79c..b01cc99d7e 100644
--- a/paimon-python/pypaimon/casting/schema_evolution.py
+++ b/paimon-python/pypaimon/casting/schema_evolution.py
@@ -77,12 +77,49 @@ def _truncate_timestamp(array, target_type, 
source_paimon_type, target_paimon_ty
     return pa.array(values, type=pa.int64()).cast(target_type)
 
 
+def _timestamp_to_bigint(array):
+    ticks = {'s': 1, 'ms': 1000, 'us': 1000000, 'ns': 
1000000000}[array.type.unit]
+    values = []
+    for value in array.cast(pa.int64()).to_pylist():
+        if value is None:
+            values.append(None)
+        elif array.type.tz is None:
+            values.append(value // ticks)
+        else:
+            # Java's LTZ rule round-trips Timestamp through LocalDateTime.
+            # Timestamp stores its epoch day as an int in that conversion.
+            millis = value * 1000 if array.type.unit == 's' else value // 
(ticks // 1000)
+            if not -(1 << 63) <= millis < (1 << 63):
+                values.append(None)
+                continue
+            day = _narrow_integer(millis // 86400000, 32)
+            values.append((day * 86400000 + millis % 86400000) // 1000)
+    return pa.array(values, type=pa.int64())
+
+
+def _integer_to_timestamp(array, target_type):
+    ticks = {'s': 1, 'ms': 1000, 'us': 1000000, 'ns': 
1000000000}[target_type.unit]
+    values = []
+    for value in array.to_pylist():
+        if value is None:
+            values.append(None)
+            continue
+        # NumericPrimitiveToTimestamp performs value.longValue() * 1000
+        # before conversion. Java's signed long multiplication wraps.
+        millis = _narrow_integer(value * 1000, 64)
+        value = millis // 1000 if target_type.unit == 's' else millis * (ticks 
// 1000)
+        values.append(value if -(1 << 63) <= value < (1 << 63) else None)
+    return pa.array(values, type=pa.int64()).cast(target_type)
+
+
 def cast_array_for_schema_evolution(array, target_type, 
source_paimon_type=None, target_paimon_type=None):
     """Materialize existing type changes using Java numeric and timestamp 
casts.
 
     Declared Paimon types are needed for precision changes within one Arrow
-    timestamp unit. Numeric narrowing preserves low bits, while floating-point
-    values truncate and saturate to int/long before byte/short narrowing.
+    timestamp unit. INTEGER/BIGINT to TIMESTAMP and TIMESTAMP to BIGINT use
+    epoch seconds, including pre-epoch flooring and Java's millis 
multiplication.
+    Numeric narrowing preserves low bits, while floating-point values truncate
+    and saturate to int/long before byte/short narrowing.
     DECIMAL scale reduction uses HALF_UP and returns NULL on precision 
overflow.
     """
     timestamp = _truncate_timestamp(array, target_type, source_paimon_type, 
target_paimon_type)
@@ -90,6 +127,11 @@ def cast_array_for_schema_evolution(array, target_type, 
source_paimon_type=None,
         return timestamp
     if array.type == target_type:
         return array
+    if pa.types.is_timestamp(array.type) and target_type == pa.int64():
+        return _timestamp_to_bigint(array)
+    if (array.type in (pa.int32(), pa.int64())
+            and pa.types.is_timestamp(target_type) and target_type.tz is None):
+        return _integer_to_timestamp(array, target_type)
     if (pa.types.is_signed_integer(target_type)
             and (pa.types.is_floating(array.type) or 
pa.types.is_decimal(array.type))):
         return _numeric_to_integer(array, target_type)
diff --git a/paimon-python/pypaimon/tests/native_predicate_delete_test.py 
b/paimon-python/pypaimon/tests/native_predicate_delete_test.py
index 1484b102f6..4622dab0af 100644
--- a/paimon-python/pypaimon/tests/native_predicate_delete_test.py
+++ b/paimon-python/pypaimon/tests/native_predicate_delete_test.py
@@ -176,7 +176,7 @@ def 
test_data_delete_rejects_disabled_dv_without_fallback(native_rest_catalog, m
     predicate = pb.equal('id', 999)
     if mixed:
         predicate = pb.or_predicates([predicate, pb.equal('p', 'a')])
-    with _core_only(), pytest.raises(Exception, 
match='deletion-vectors.enabled'):
+    with _core_only(), pytest.raises(NotImplementedError, 
match='deletion-vectors.enabled'):
         
table.new_batch_write_builder().new_update().delete_by_predicate(predicate)
     assert _ids(table) == [0, 1, 2, 3]
 
diff --git a/paimon-python/pypaimon/tests/native_schema_evolution_cast_test.py 
b/paimon-python/pypaimon/tests/native_schema_evolution_cast_test.py
index 4f6d7b6400..f910737817 100644
--- a/paimon-python/pypaimon/tests/native_schema_evolution_cast_test.py
+++ b/paimon-python/pypaimon/tests/native_schema_evolution_cast_test.py
@@ -214,3 +214,124 @@ def 
test_nested_narrowing_preserves_null_containers_and_leaf_projection(rest_cat
         leaves = _read(table, native, projection=['id', 'r.n', 'r.t'])
         assert leaves.column('r_n').to_pylist() == [-128, None, 127]
         assert leaves.column('r_t').cast(pa.int64()).to_pylist() == [100, 
None, -100]
+
+
+_TEMPORAL_CASES = [
+    (pa.timestamp('ms'), 'BIGINT', [42001, -1, -42001, 2147483650000, None],
+     [42, -1, -43, 2147483650, None]),
+    (pa.timestamp('us'), 'BIGINT', [42000001, -1, -42000001, 2147483650000000, 
None],
+     [42, -1, -43, 2147483650, None]),
+    (pa.timestamp('ns'), 'BIGINT', [42000000001, -1, -42000000001, 
2147483650000000000, None],
+     [42, -1, -43, 2147483650, None]),
+    (pa.timestamp('ms', 'UTC'), 'BIGINT', [42001, -1, -42001, 2147483650000, 
None],
+     [42, -1, -43, 2147483650, None]),
+    (pa.timestamp('us', 'UTC'), 'BIGINT', [42000001, -1, -42000001, 
2147483650000000, None],
+     [42, -1, -43, 2147483650, None]),
+    (pa.timestamp('ns', 'UTC'), 'BIGINT', [42000000001, -1, -42000000001, 
2147483650000000000, None],
+     [42, -1, -43, 2147483650, None]),
+    (pa.int32(), 'TIMESTAMP(0)', [42, -42, 2147483647, -2147483648, None],
+     [42, -42, 2147483647, -2147483648, None]),
+    (pa.int32(), 'TIMESTAMP(1)', [42, -42, 2147483647, -2147483648, None],
+     [42000, -42000, 2147483647000, -2147483648000, None]),
+    (pa.int32(), 'TIMESTAMP(4)', [42, -42, 2147483647, -2147483648, None],
+     [42000000, -42000000, 2147483647000000, -2147483648000000, None]),
+    (pa.int32(), 'TIMESTAMP(7)', [42, -42, 2147483647, -2147483648, None],
+     [42000000000, -42000000000, 2147483647000000000, -2147483648000000000, 
None]),
+    (pa.int64(), 'TIMESTAMP(0)', [42, -42, 2147483650, 2 ** 63 - 1, -(2 ** 
63), None],
+     [42, -42, 2147483650, -1, 0, None]),
+    (pa.int64(), 'TIMESTAMP(1)', [42, -42, 2147483650, 2 ** 63 - 1, -(2 ** 
63), None],
+     [42000, -42000, 2147483650000, -1000, 0, None]),
+    (pa.int64(), 'TIMESTAMP(4)', [42, -42, 2147483650, 2 ** 63 - 1, -(2 ** 
63), None],
+     [42000000, -42000000, 2147483650000000, -1000000, 0, None]),
+    (pa.int64(), 'TIMESTAMP(7)', [42, -42, 2147483650, 2 ** 63 - 1, -(2 ** 
63), None],
+     [42000000000, -42000000000, 2147483650000000000, -1000000000, 0, None]),
+]
+
+
[email protected]('mode', ['append', 'pk', 'de'])
[email protected]('source_type,target_name,values,expected', 
_TEMPORAL_CASES)
+def test_epoch_seconds_evolution_reads_old_and_new_rest_files(
+        rest_catalog, mode, source_type, target_name, values, expected):
+    catalog, _ = rest_catalog
+    _table(catalog, mode, source_type, values)
+    target = AtomicType(target_name)
+    catalog.alter_table('default.t', [SchemaChange.update_column_type('v', 
target)], False)
+    table = catalog.get_table('default.t')
+    target_type = PyarrowFieldParser.from_paimon_type(target)
+    for native in (False, True):
+        result = _read(table, native).column('v')
+        assert result.type == target_type
+        assert result.cast(pa.int64()).to_pylist() == expected
+    # Both schema versions must materialize the current type in one read.
+    _write(table, pa.table({'id': [len(values)], 'v': pa.array([expected[0]], 
type=target_type)}))
+    table = catalog.get_table('default.t')
+    first = pa.scalar(expected[0], type=target_type).as_py()
+    pb = table.new_read_builder().new_predicate_builder()
+    for native in (False, True):
+        assert _read(table, native).column('v').cast(pa.int64()).to_pylist() 
== expected + [expected[0]]
+        assert _read(table, native, pb.equal('v', first), 
['id']).column('id').to_pylist() == [0, len(values)]
+        assert _read(table, native, pb.is_null('v'), 
['id']).column('id').to_pylist() == [len(values) - 1]
+
+
[email protected]('source_type,target_name,values,expected', 
_TEMPORAL_CASES)
+def test_single_value_epoch_stats_do_not_prune_converted_match(
+        rest_catalog, source_type, target_name, values, expected):
+    catalog, _ = rest_catalog
+    _table(catalog, 'append', source_type, values[:1])
+    target = AtomicType(target_name)
+    catalog.alter_table('default.t', [SchemaChange.update_column_type('v', 
target)], False)
+    table = catalog.get_table('default.t')
+    literal = pa.scalar(expected[0], 
type=PyarrowFieldParser.from_paimon_type(target)).as_py()
+    predicate = table.new_read_builder().new_predicate_builder().equal('v', 
literal)
+    for native in (False, True):
+        assert _read(table, native, predicate, 
['id']).column('id').to_pylist() == [0]
+
+
[email protected]('mode', ['append', 'pk', 'de'])
[email protected]('integer_type', [pa.int32(), pa.int64()])
+def test_nested_epoch_seconds_evolution_and_projection(rest_catalog, mode, 
integer_type):
+    catalog, _ = rest_catalog
+    schema = pa.schema([
+        ('id', pa.int64()),
+        ('r', pa.struct([('t', pa.timestamp('us')), ('s', integer_type)])),
+        ('a', pa.list_(pa.timestamp('ns'))),
+        ('m', pa.map_(pa.string(), integer_type)),
+    ])
+    options = {'file.format': 'parquet', 'read.batch-size': '1'}
+    if mode == 'pk':
+        options['bucket'] = '1'
+    elif mode == 'de':
+        options.update({'data-evolution.enabled': 'true', 
'row-tracking.enabled': 'true'})
+    catalog.create_table('default.t', Schema.from_pyarrow_schema(
+        schema, primary_keys=['id'] if mode == 'pk' else [], options=options), 
False)
+    boundary = 2 ** 63 - 1 if integer_type == pa.int64() else -(2 ** 31)
+    expected_boundary = -1000000 if integer_type == pa.int64() else 
-2147483648000000
+    _write(catalog.get_table('default.t'), pa.Table.from_pylist([
+        {'id': 0, 'r': {'t': 42000001, 's': 42}, 'a': [42000000001, None, -1],
+         'm': [('positive', 42), ('negative', -42)]},
+        {'id': 1, 'r': None, 'a': None, 'm': None},
+        {'id': 2, 'r': {'t': -1, 's': boundary}, 'a': [], 'm': []},
+    ], schema=schema))
+    catalog.alter_table('default.t', [
+        SchemaChange.update_column_type(['r', 't'], AtomicType('BIGINT')),
+        SchemaChange.update_column_type(['r', 's'], 
AtomicType('TIMESTAMP(6)')),
+        SchemaChange.update_column_type(['a', 'element'], 
AtomicType('BIGINT')),
+        SchemaChange.update_column_type(['m', 'value'], 
AtomicType('TIMESTAMP(3)')),
+    ], False)
+    table = catalog.get_table('default.t')
+    expected_map = pa.array([[('positive', 42000), ('negative', -42000)], 
None, []],
+                            type=pa.map_(pa.string(), 
pa.timestamp('ms'))).to_pylist()
+    for native in (False, True):
+        actual = _read(table, native)
+        assert actual.column('r').flatten()[0].to_pylist() == [42, None, -1]
+        assert actual.column('r').flatten()[1].cast(pa.int64()).to_pylist() == 
[42000000, None, expected_boundary]
+        assert actual.column('a').to_pylist() == [[42, None, -1], None, []]
+        assert actual.column('m').to_pylist() == expected_map
+        leaves = _read(table, native, projection=['id', 'r.t', 'r.s'])
+        assert leaves.column('r_t').to_pylist() == [42, None, -1]
+        assert leaves.column('r_s').cast(pa.int64()).to_pylist() == [42000000, 
None, expected_boundary]
+        selected = _read(table, native, projection=[
+            'id', "m['positive']", "m['negative']", "m['missing']"])
+        assert selected.column('m_positive').cast(pa.int64()).to_pylist() == 
[42000, None, None]
+        assert selected.column('m_negative').cast(pa.int64()).to_pylist() == 
[-42000, None, None]
+        assert selected.column('m_missing').null_count == 3
diff --git a/paimon-python/pypaimon/tests/schema_evolution_cast_test.py 
b/paimon-python/pypaimon/tests/schema_evolution_cast_test.py
index 1b6e4f817b..97d3ecc3a4 100644
--- a/paimon-python/pypaimon/tests/schema_evolution_cast_test.py
+++ b/paimon-python/pypaimon/tests/schema_evolution_cast_test.py
@@ -54,3 +54,61 @@ def test_timestamp_floor_overflow_returns_null(zone, 
precision):
         source, source.type, AtomicType(name + '(9)'), AtomicType('%s(%d)' % 
(name, precision)))
     quantum = 10 ** (9 - precision)
     assert output.cast(pa.int64()).to_pylist() == [None, (-(2 ** 63) + 1000) 
// quantum * quantum, -quantum, None]
+
+
[email protected]('unit,ticks', [('s', 1), ('ms', 1000), ('us', 
1000000), ('ns', 1000000000)])
[email protected]('zone', [None, 'UTC'])
[email protected]('chunked', [False, True])
+def test_timestamp_to_bigint_uses_epoch_seconds_flooring(unit, ticks, zone, 
chunked):
+    fraction = 0 if unit == 's' else 1
+    values = [ticks + fraction, -1, -ticks - fraction, 0,
+              (2 ** 31 + 2) * ticks, -(2 ** 31 + 2) * ticks, None]
+    expected = [1, -1, -1 if unit == 's' else -2, 0, 2 ** 31 + 2, -(2 ** 31 + 
2), None]
+    source = pa.array([None] + values + [None], type=pa.timestamp(unit, 
zone)).slice(1, len(values))
+    if chunked:
+        source = pa.chunked_array([source.slice(0, 2), source.slice(2, 0), 
source.slice(2)])
+    result = cast_array_for_schema_evolution(source, pa.int64())
+    assert result.type == pa.int64()
+    assert result.to_pylist() == expected
+
+
[email protected]('unit,ticks', [('s', 1), ('ms', 1000), ('us', 
1000000), ('ns', 1000000000)])
[email protected]('chunked', [False, True])
[email protected]('source_type', [pa.int32(), pa.int64()])
+def 
test_integer_to_timestamp_uses_epoch_seconds_and_java_millis_wrapping(unit, 
ticks, chunked, source_type):
+    values = [42, -42, 1700000000, None]
+    expected = [42 * ticks, -42 * ticks, 1700000000 * ticks, None]
+    if source_type == pa.int64():
+        values.extend([2 ** 63 - 1, -(2 ** 63), 2 ** 63 - 2])
+        expected.extend([-ticks, 0, -2 * ticks])
+    else:
+        values.extend([2 ** 31 - 1, -(2 ** 31), 0])
+        expected.extend([(2 ** 31 - 1) * ticks, -(2 ** 31) * ticks, 0])
+    source = pa.array([None] + values + [None], type=source_type).slice(1, 
len(values))
+    if chunked:
+        source = pa.chunked_array([source.slice(0, 1), source.slice(1)])
+    result = cast_array_for_schema_evolution(source, pa.timestamp(unit))
+    assert result.type == pa.timestamp(unit)
+    assert result.cast(pa.int64()).to_pylist() == expected
+
+
[email protected]('zone,expected', [
+    (None, [9223372036854775, -9223372036854776, None]),
+    ('UTC', [-53757322505225, 53757322505224, None]),
+])
+def test_timestamp_to_bigint_matches_java_extreme_millis_round_trip(zone, 
expected):
+    # Golden outputs from Java CastExecutors, including LTZ's int epoch day.
+    source = pa.array([2 ** 63 - 1, -(2 ** 63), None], type=pa.timestamp('ms', 
zone))
+    assert cast_array_for_schema_evolution(source, pa.int64()).to_pylist() == 
expected
+
+
[email protected]('unit,expected', [
+    ('s', [9223372036854775, -9223372036854775, None]),
+    ('ms', [9223372036854775000, -9223372036854775000, None]),
+    ('us', [None, None, None]),
+    ('ns', [None, None, None]),
+])
+def test_bigint_to_timestamp_keeps_lenient_arrow_storage_overflow(unit, 
expected):
+    source = pa.array([9223372036854775, -9223372036854775, None], 
type=pa.int64())
+    result = cast_array_for_schema_evolution(source, pa.timestamp(unit))
+    assert result.cast(pa.int64()).to_pylist() == expected
diff --git a/paimon-python/pypaimon/tests/schema_evolution_nested_read_test.py 
b/paimon-python/pypaimon/tests/schema_evolution_nested_read_test.py
index 74bfec80f3..899634fafe 100644
--- a/paimon-python/pypaimon/tests/schema_evolution_nested_read_test.py
+++ b/paimon-python/pypaimon/tests/schema_evolution_nested_read_test.py
@@ -764,6 +764,15 @@ class SupportsCastTest(unittest.TestCase):
             self.assertFalse(supports_cast(AtomicType(src), AtomicType(dst)),
                              '{} -> {}'.format(src, dst))
 
+    def test_can_execute_integer_to_timestamp(self):
+        for source in ('INT', 'BIGINT'):
+            for precision in (0, 1, 4, 7):
+                target = 'TIMESTAMP({})'.format(precision)
+                with self.subTest(source=source, target=target):
+                    self.assertTrue(can_execute_cast(AtomicType(source), 
AtomicType(target)))
+        for source in ('TINYINT', 'SMALLINT'):
+            self.assertFalse(can_execute_cast(AtomicType(source), 
AtomicType('TIMESTAMP(6)')))
+
     def test_can_execute_cast_decimal_precision(self):
         # A numeric -> decimal cast has a PyArrow kernel but is only executable
         # when the target precision can hold the source's range at the target
diff --git a/paimon-python/pypaimon/tests/table_update_test.py 
b/paimon-python/pypaimon/tests/table_update_test.py
index 2eb425e156..2187e824df 100644
--- a/paimon-python/pypaimon/tests/table_update_test.py
+++ b/paimon-python/pypaimon/tests/table_update_test.py
@@ -73,6 +73,36 @@ def 
test_write_builder_creates_row_id_updater_with_commit_identity(
     table.new_read_builder.assert_not_called()
 
 
[email protected]('missing_method,option', [
+    ('data_evolution_enabled', 'data-evolution.enabled'),
+    ('row_tracking_enabled', 'row-tracking.enabled'),
+    ('deletion_vectors_enabled', 'deletion-vectors.enabled'),
+])
+def 
test_python_row_id_delete_reports_unsupported_table_capabilities(missing_method,
 option):
+    from pypaimon.write.table_delete import TableDeleteByRowId
+
+    table = mock.Mock()
+    table.options.data_evolution_enabled.return_value = True
+    table.options.row_tracking_enabled.return_value = True
+    table.options.deletion_vectors_enabled.return_value = True
+    getattr(table.options, missing_method).return_value = False
+    with pytest.raises(NotImplementedError, match=option):
+        TableDeleteByRowId(table).delete([])
+    table.snapshot_manager.assert_not_called()
+
+
+def test_python_row_id_delete_keeps_invalid_input_as_value_error():
+    from pypaimon.write.table_delete import TableDeleteByRowId
+
+    table = mock.Mock()
+    table.options.data_evolution_enabled.return_value = True
+    table.options.row_tracking_enabled.return_value = True
+    table.options.deletion_vectors_enabled.return_value = True
+    with pytest.raises(ValueError, match='_ROW_ID value must not be null'):
+        TableDeleteByRowId(table).delete([None])
+    table.snapshot_manager.assert_not_called()
+
+
 def test_batch_row_id_update_batches_reuse_file_index():
     table = mock.MagicMock()
     table.field_names = ["value"]
@@ -922,7 +952,7 @@ class _TableUpdateTestBase(DataEvolutionTestBase):
         table = self._create_seeded_table()
         pb = table.new_read_builder().new_predicate_builder()
 
-        with self.assertRaises(ValueError) as ctx:
+        with self.assertRaises(NotImplementedError) as ctx:
             self._do_delete_by_predicate(table, pb.equal('id', 1))
 
         self.assertIn('deletion-vectors.enabled', str(ctx.exception))
@@ -940,7 +970,7 @@ class _TableUpdateTestBase(DataEvolutionTestBase):
     def test_delete_by_row_id_requires_deletion_vectors(self):
         table = self._create_seeded_table()
 
-        with self.assertRaises(ValueError) as ctx:
+        with self.assertRaises(NotImplementedError) as ctx:
             self._do_delete_by_row_id(table, [0])
 
         self.assertIn('deletion-vectors.enabled', str(ctx.exception))
@@ -1066,7 +1096,7 @@ class _TableUpdateTestBase(DataEvolutionTestBase):
             pb.equal('age', 30),
         ])
 
-        with self.assertRaises(ValueError) as ctx:
+        with self.assertRaises(NotImplementedError) as ctx:
             self._do_delete_by_predicate(table, predicate)
 
         self.assertIn('deletion-vectors.enabled', str(ctx.exception))
diff --git a/paimon-python/pypaimon/write/table_delete.py 
b/paimon-python/pypaimon/write/table_delete.py
index 00ea6b1cec..2dd78d351b 100644
--- a/paimon-python/pypaimon/write/table_delete.py
+++ b/paimon-python/pypaimon/write/table_delete.py
@@ -98,16 +98,18 @@ class TableDeleteByRowId:
         return commit_messages
 
     def _validate_delete(self):
+        # Java DataEvolutionDeleteSink.validateTable reports unsupported table
+        # capabilities; Native maps the same failure to NotImplementedError.
         if not self.table.options.data_evolution_enabled():
-            raise ValueError(
+            raise NotImplementedError(
                 "delete requires 'data-evolution.enabled' = 'true'."
             )
         if not self.table.options.row_tracking_enabled():
-            raise ValueError(
+            raise NotImplementedError(
                 "delete requires 'row-tracking.enabled' = 'true'."
             )
         if not self.table.options.deletion_vectors_enabled(False):
-            raise ValueError(
+            raise NotImplementedError(
                 "delete requires 'deletion-vectors.enabled' = 'true'."
             )
 

Reply via email to