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 cd47e65487 [Python] Align supported temporal schema evolution with 
Java (#10524)
cd47e65487 is described below

commit cd47e65487d6726d9d1d9e0a4334dbf1cdf475f3
Author: Jingsong Lee <[email protected]>
AuthorDate: Tue Oct 13 11:01:43 2026 +0800

    [Python] Align supported temporal schema evolution with Java (#10524)
---
 paimon-python/pypaimon/casting/schema_evolution.py |   5 +
 paimon-python/pypaimon/casting/temporal_cast.py    |  95 +++++++++
 paimon-python/pypaimon/casting/temporal_string.py  |  25 +--
 paimon-python/pypaimon/casting/temporal_utils.py   | 108 +++++++++++
 .../tests/native_temporal_zone_cast_test.py        | 213 +++++++++++++++++++++
 .../pypaimon/tests/schema_evolution_cast_test.py   | 180 +++++++++++++++++
 6 files changed, 602 insertions(+), 24 deletions(-)

diff --git a/paimon-python/pypaimon/casting/schema_evolution.py 
b/paimon-python/pypaimon/casting/schema_evolution.py
index 3aabc54371..afa3650627 100644
--- a/paimon-python/pypaimon/casting/schema_evolution.py
+++ b/paimon-python/pypaimon/casting/schema_evolution.py
@@ -22,6 +22,7 @@ import math
 
 import pyarrow as pa
 
+from pypaimon.casting.temporal_cast import cast_temporal
 from pypaimon.casting.temporal_string import temporal_to_string
 from pypaimon.table.row.generic_row import _parse_type_precision_scale
 
@@ -122,11 +123,15 @@ def cast_array_for_schema_evolution(array, target_type, 
source_paimon_type=None,
     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.
+    LTZ conversions use the process local zone, including Java gap/overlap 
rules.
     """
     if pa.types.is_string(target_type):
         strings = temporal_to_string(array, source_paimon_type, 
target_paimon_type)
         if strings is not None:
             return strings
+    converted = cast_temporal(array, target_type, source_paimon_type, 
target_paimon_type)
+    if converted is not None:
+        return converted
     timestamp = _truncate_timestamp(array, target_type, source_paimon_type, 
target_paimon_type)
     if timestamp is not None:
         return timestamp
diff --git a/paimon-python/pypaimon/casting/temporal_cast.py 
b/paimon-python/pypaimon/casting/temporal_cast.py
new file mode 100644
index 0000000000..5700100446
--- /dev/null
+++ b/paimon-python/pypaimon/casting/temporal_cast.py
@@ -0,0 +1,95 @@
+# 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.
+
+"""Java local-zone conversions used by existing schema-evolution casts."""
+
+import pyarrow as pa
+
+from pypaimon.casting.temporal_utils import (
+    _epoch_day_to_java_date, _java_date_to_epoch_day,
+    _local_offset, _local_to_utc_seconds, _signed,
+)
+from pypaimon.table.row.generic_row import _parse_type_precision_scale
+
+
+def cast_temporal(array, target, source_type=None, target_type=None):
+    timestamp = pa.types.is_timestamp(array.type)
+    source_name = str(source_type).upper() if source_type is not None else ''
+    ltz = timestamp and (array.type.tz is not None if source_type is None else
+                         source_name.startswith('TIMESTAMP_LTZ') or 'WITH 
LOCAL TIME ZONE' in source_name)
+    target_ts = pa.types.is_timestamp(target)
+    cross = timestamp and target_ts and ltz != (target.tz is not None)
+    date = pa.types.is_date32(array.type) and target_ts and target.tz is not 
None
+    numeric = array.type == pa.int64() and target_ts and target.tz is not None
+    if not (cross or date or numeric or timestamp and 
(pa.types.is_date32(target) or pa.types.is_time(target))):
+        return None
+    raw = array.cast(pa.int32() if date else pa.int64()).to_pylist()
+    unit = array.type.unit if timestamp else 's'
+    source_precision = (_parse_type_precision_scale(source_type)[0] if 
source_type is not None else
+                        {'s': 0, 'ms': 3, 'us': 6, 'ns': 9}[unit] if timestamp 
else 0)
+    target_precision = (_parse_type_precision_scale(target_type)[0] if 
target_type is not None else
+                        {'s': 0, 'ms': 3, 'us': 6, 'ns': 9}[target.unit] if 
target_ts else 0)
+    values = []
+    for value in raw:
+        if value is None:
+            values.append(None)
+            continue
+        if date:
+            millis, sub_milli = _java_date_to_epoch_day(value) * 86400000, 0
+        elif unit == 's':
+            millis, sub_milli = _signed(value * 1000, 64), 0
+        elif unit == 'ms':
+            millis, sub_milli = value, 0
+        elif unit == 'us':
+            millis, sub_milli = value // 1000, value % 1000 * 1000
+        else:
+            millis, sub_milli = value // 1000000, value % 1000000
+        original = millis
+        if cross and not ltz:
+            millis = _signed(millis // 86400000, 32) * 86400000 + millis % 
86400000
+        if date or cross and not ltz:
+            millis = _local_to_utc_seconds(millis // 1000) * 1000 + millis % 
1000
+        elif ltz or numeric:
+            millis = _signed(millis + _local_offset(millis // 1000) * 1000, 64)
+        if pa.types.is_date32(target):
+            value = (_epoch_day_to_java_date((original + 
_local_offset(original // 1000) * 1000) // 86400000)
+                     if ltz else _signed(millis // 86400000, 32))
+        elif pa.types.is_time(target):
+            value = ((original + _local_offset(original // 1000) * 1000) % 
86400000
+                     if ltz else millis % 86400000)
+        else:
+            if source_precision > target_precision:
+                if target_precision <= 3:
+                    quantum = 10 ** (3 - target_precision)
+                    millis, sub_milli = _signed(millis // quantum * quantum, 
64), 0
+                else:
+                    quantum = 10 ** (9 - target_precision)
+                    sub_milli = sub_milli // quantum * quantum
+            if target.unit == 's':
+                value = millis // 1000
+            elif target.unit == 'ms':
+                value = millis
+            elif target.unit == 'us':
+                value = millis * 1000 + sub_milli // 1000
+            else:
+                value = millis * 1000000 + sub_milli
+            if not -(1 << 63) <= value < (1 << 63):
+                value = None
+        values.append(value)
+    if pa.types.is_time(target):
+        return pa.array(values, 
type=pa.int32()).cast(pa.time32('ms')).cast(target, safe=False)
+    return pa.array(values, type=pa.int32() if pa.types.is_date32(target) else 
pa.int64()).cast(target)
diff --git a/paimon-python/pypaimon/casting/temporal_string.py 
b/paimon-python/pypaimon/casting/temporal_string.py
index 5cf6e5034c..a47dba3a9c 100644
--- a/paimon-python/pypaimon/casting/temporal_string.py
+++ b/paimon-python/pypaimon/casting/temporal_string.py
@@ -17,35 +17,12 @@
 
 """Java DateTimeUtils text shared by schema evolution and row rendering."""
 
-import time
-
 import pyarrow as pa
 
+from pypaimon.casting.temporal_utils import _epoch_day_to_date, _local_offset
 from pypaimon.table.row.generic_row import _parse_type_precision_scale
 
 
-def _epoch_day_to_date(days):
-    # March-based Gregorian eras, with floor division also for pre-epoch days.
-    days += 719468
-    era, day_of_era = divmod(days, 146097)
-    year_of_era = (day_of_era - day_of_era // 1460 + day_of_era // 36524 - 
day_of_era // 146096) // 365
-    year = year_of_era + era * 400
-    day_of_year = day_of_era - (365 * year_of_era + year_of_era // 4 - 
year_of_era // 100)
-    march_month = (5 * day_of_year + 2) // 153
-    day = day_of_year - (153 * march_month + 2) // 5 + 1
-    month = march_month + (3 if march_month < 10 else -9)
-    return year + (month <= 2), month, day
-
-
-def _local_offset(seconds):
-    # Avoid converting the date itself through datetime's 1..9999 year range.
-    local = time.localtime(seconds)
-    offset = getattr(local, 'tm_gmtoff', None)
-    if offset is not None:
-        return offset
-    return -(time.altzone if local.tm_isdst > 0 else time.timezone)
-
-
 def format_timestamp(value, precision, nanos=None):
     """Keep significant fraction digits, with declared precision as a 
minimum."""
     return format_timestamp_parts(value.year, value.month, value.day, 
value.hour, value.minute,
diff --git a/paimon-python/pypaimon/casting/temporal_utils.py 
b/paimon-python/pypaimon/casting/temporal_utils.py
new file mode 100644
index 0000000000..7a923093d1
--- /dev/null
+++ b/paimon-python/pypaimon/casting/temporal_utils.py
@@ -0,0 +1,108 @@
+# 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.
+
+"""Integer calendar and process-local zone helpers for Java temporal casts."""
+
+import time
+
+
+_GREGORIAN_CYCLE_SECONDS = 146097 * 86400
+# Match the finite zone lookup window used by the Native temporal helpers.
+_MIN_ZONE_SECONDS = -377705023201
+_MAX_ZONE_SECONDS = 253402207200
+
+
+def _epoch_day_to_date(days):
+    # March-based Gregorian eras, with floor division also for pre-epoch days.
+    days += 719468
+    era, day_of_era = divmod(days, 146097)
+    year_of_era = (day_of_era - day_of_era // 1460 + day_of_era // 36524 - 
day_of_era // 146096) // 365
+    year = year_of_era + era * 400
+    day_of_year = day_of_era - (365 * year_of_era + year_of_era // 4 - 
year_of_era // 100)
+    march_month = (5 * day_of_year + 2) // 153
+    day = day_of_year - (153 * march_month + 2) // 5 + 1
+    month = march_month + (3 if march_month < 10 else -9)
+    return year + (month <= 2), month, day
+
+
+def _local_offset(seconds):
+    # libc can lose DST in very large years. Java's final transition rules
+    # repeat every Gregorian 400-year cycle, including month/day/weekday/time.
+    # Query an equivalent date in 2370..2770, after recorded transitions.
+    if seconds > _MAX_ZONE_SECONDS:
+        seconds = seconds % _GREGORIAN_CYCLE_SECONDS + _GREGORIAN_CYCLE_SECONDS
+    elif seconds < _MIN_ZONE_SECONDS:
+        # Before recorded history Java uses its first wall offset.
+        seconds = _MIN_ZONE_SECONDS
+    local = time.localtime(seconds)
+    offset = getattr(local, 'tm_gmtoff', None)
+    if offset is not None:
+        return offset
+    return -(time.altzone if local.tm_isdst > 0 else time.timezone)
+
+
+def _signed(value, bits):
+    return (value + (1 << (bits - 1))) % (1 << bits) - (1 << (bits - 1))
+
+
+def _div(value, divisor):
+    # Java integer division truncates towards zero.
+    return -(abs(value) // divisor) if value < 0 else value // divisor
+
+
+def _rem(value, divisor):
+    return value - _div(value, divisor) * divisor
+
+
+def _java_date_to_epoch_day(date):
+    j = _signed(date + 2440588 + 32044, 32)
+    g, dg = _div(j, 146097), _rem(j, 146097)
+    c = _div((_div(dg, 36524) + 1) * 3, 4)
+    dc = dg - c * 36524
+    b, db = _div(dc, 1461), _rem(dc, 1461)
+    a = _div((_div(db, 365) + 1) * 3, 4)
+    da = db - a * 365
+    y = g * 400 + c * 100 + b * 4 + a
+    m = _div(da * 5 + 308, 153) - 2
+    d = da - _div((m + 4) * 153, 5) + 122
+    year, month, day = y - 4800 + _div(m + 2, 12), _rem(m + 2, 12) + 1, d + 1
+    leap = year % 400 == 0 or year % 4 == 0 and year % 100 != 0
+    days = 29 if month == 2 and leap else 28 if month == 2 else 30 if month in 
(4, 6, 9, 11) else 31
+    if not 1 <= month <= 12 or not 1 <= day <= days:
+        raise ValueError('Invalid Java DATE conversion: {}-{}-{}'.format(year, 
month, day))
+    year -= month <= 2
+    era, yoe = divmod(year, 400)
+    march = month + (-3 if month > 2 else 9)
+    return era * 146097 + yoe * 365 + yoe // 4 - yoe // 100 + (153 * march + 
2) // 5 + day - 1 - 719468
+
+
+def _epoch_day_to_java_date(day):
+    year, month, day = _epoch_day_to_date(day)
+    a = (14 - month) // 12
+    y, m = year + 4800 - a, month + 12 * a - 3
+    return _signed(day + (153 * m + 2) // 5 + 365 * y + _div(y, 4) - _div(y, 
100)
+                   + _div(y, 400) - 32045 - 2440588, 32)
+
+
+def _local_to_utc_seconds(seconds):
+    # Inspect both sides of a possible transition. In an overlap both offsets
+    # are valid: Java chooses the earlier instant. In a gap neither is valid:
+    # applying the earlier offset shifts the wall clock forward through the 
gap.
+    offsets = {_local_offset(seconds + delta) for delta in (-172800, 0, 
172800)}
+    candidates = [seconds - offset for offset in offsets]
+    valid = [utc for utc in candidates if utc + _local_offset(utc) == seconds]
+    return min(valid) if valid else max(candidates)
diff --git a/paimon-python/pypaimon/tests/native_temporal_zone_cast_test.py 
b/paimon-python/pypaimon/tests/native_temporal_zone_cast_test.py
new file mode 100644
index 0000000000..5e772e5634
--- /dev/null
+++ b/paimon-python/pypaimon/tests/native_temporal_zone_cast_test.py
@@ -0,0 +1,213 @@
+# 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.
+
+"""Existing temporal changes read natively from old REST Parquet files."""
+
+import os
+
+import pyarrow as pa
+import pytest
+
+from pypaimon import Schema
+from pypaimon.read.native_plan import native_runtime_available
+from pypaimon.schema.data_types import AtomicType, PyarrowFieldParser
+from pypaimon.schema.schema_change import SchemaChange
+from pypaimon.tests import native_plan_rest_test
+from pypaimon.tests.native_schema_evolution_cast_test import _read, _table, 
_write
+
+rest_catalog = native_plan_rest_test.rest_catalog
+pytestmark = [pytest.mark.native_plan, pytest.mark.skipif(
+    not native_runtime_available(), reason='Rust reader required')]
+
+# Golden outputs from Java CastExecutors; UTC is also exercised by CI.
+_JAVA_CASTS = {
+    'America/New_York': [
+        [18000000, 17999900, 1710055800100, 1730611800100, 1710070200100, 
1730633400100, None],
+        [-18000000, -18000100, 1710019800100, 1730583000100, 1710041400100, 
1730597400100, None],
+        [-1, -1, 19791, 20029, 19792, 20030, None],
+        [68400000, 68399999, 77400123, 77400123, 12600123, 5400123, None],
+    ],
+    'Asia/Shanghai': [
+        [-28800000, -28800100, 1710009000100, 1730568600100, 1710027000100, 
1730586600100, None],
+        [28800000, 28799900, 1710066600100, 1730626200100, 1710084600100, 
1730644200100, None],
+        [0, 0, 19792, 20030, 19792, 20030, None],
+        [28800000, 28799999, 37800123, 34200123, 55800123, 52200123, None],
+    ],
+    'UTC': [
+        [0, -100, 1710037800100, 1730597400100, 1710055800100, 1730615400100, 
None],
+        [0, -100, 1710037800100, 1730597400100, 1710055800100, 1730615400100, 
None],
+        [0, -1, 19792, 20030, 19792, 20030, None],
+        [0, 86399999, 9000123, 5400123, 27000123, 23400123, None],
+    ],
+}
+_JAVA_WIDE = {
+    'America/New_York': [
+        [-53757322487462200, 53757322519624100],
+        [9223372036840375800, 9223372036837013800],
+        [-622191233, 622191233],
+        [11575807, 42662192],
+    ],
+    'Asia/Shanghai': [
+        [-53757322534367200, 53757322476424100],
+        [-9223372036825975900, -9223372036825632900],
+        [-622191233, 622191234],
+        [54775807, 3167192],
+    ],
+    'UTC': [
+        [-53757322505224200, 53757322505224100],
+        [9223372036854775800, 9223372036854775716],
+        [-622191233, 622191233],
+        [25975807, 60424192],
+    ],
+}
+
+_VALUES = [0, -1, 1710037800123, 1730597400123, 1710055800123, 1730615400123, 
None]
+
+
+def _zone():
+    # Run non-UTC cases in separate processes so Native's cached system zone
+    # and Python's libc zone are initialized under the same environment.
+    name = os.environ.get('TZ', 'UTC')
+    if name not in _JAVA_CASTS:
+        pytest.skip('Java golden values are defined for UTC, New York and 
Shanghai')
+    return name
+
+
+def _case(operation):
+    date_values = {'UTC': [0, -86400000, 1717200000000, 1325203200000, None],
+                   'Asia/Shanghai': [-28800000, -115200000, 1717171200000, 
1325174400000, None],
+                   'America/New_York': [18000000, -68400000, 1717214400000, 
1325221200000, None]}
+    zone = _zone()
+    if operation < 4:
+        source, target = [
+            (pa.timestamp('ms'), 'TIMESTAMP_LTZ(1)'),
+            (pa.timestamp('ms', 'UTC'), 'TIMESTAMP(1)'),
+            (pa.timestamp('ms', 'UTC'), 'DATE'),
+            (pa.timestamp('ms', 'UTC'), 'TIME(1)'),
+        ][operation]
+        return source, _VALUES, target, _JAVA_CASTS[zone][operation]
+    if operation == 4:
+        return pa.date32(), [0, -1, 19875, 15338, None], 'TIMESTAMP_LTZ(3)', 
date_values[zone]
+    offset = {'UTC': 0, 'Asia/Shanghai': 28800000, 'America/New_York': 
-18000000}[zone]
+    return pa.int64(), [0, -1, 42, None], 'TIMESTAMP_LTZ(3)', [offset, offset 
- 1000, offset + 42000, None]
+
+
+def _raw(column):
+    kind = column.type
+    return column.cast(pa.int32() if pa.types.is_date(kind) or 
pa.types.is_time(kind) else pa.int64()).to_pylist()
+
+
[email protected]('mode', ['append', 'pk', 'de'])
[email protected]('operation', range(6))
+def test_existing_temporal_changes_read_old_rest_files(rest_catalog, mode, 
operation):
+    source, values, target, expected = _case(operation)
+    catalog, _ = rest_catalog
+    _table(catalog, mode, source, values)
+    catalog.alter_table('default.t', [SchemaChange.update_column_type('v', 
AtomicType(target))], False)
+    table = catalog.get_table('default.t')
+    for native in (False, True):
+        result = _read(table, native)
+        assert _raw(result.column('v')) == expected
+        assert result.column('id').to_pylist() == list(range(len(values)))
+    target_arrow = PyarrowFieldParser.from_paimon_type(AtomicType(target))
+    _write(table, pa.table({'id': [len(values)], 'v': pa.array([expected[0]], 
type=target_arrow)}))
+    table = catalog.get_table('default.t')
+    literal = pa.scalar(expected[0], type=target_arrow).as_py()
+    predicate = table.new_read_builder().new_predicate_builder().equal('v', 
literal)
+    matched_ids = [index for index, value in enumerate(expected) if value == 
expected[0]] + [len(values)]
+    for native in (False, True):
+        assert _raw(_read(table, native).column('v')) == expected + 
[expected[0]]
+        assert _read(table, native, predicate, 
['id']).column('id').to_pylist() == matched_ids
+
+
[email protected]('mode', ['append', 'pk', 'de'])
+def 
test_local_zone_casts_reach_row_array_and_selected_map_values(rest_catalog, 
mode):
+    zone = _zone()
+    catalog, _ = rest_catalog
+    schema = pa.schema([
+        ('id', pa.int64()),
+        ('r', pa.struct([('t', pa.timestamp('ms')), ('d', pa.date32())])),
+        ('a', pa.list_(pa.timestamp('ms', 'UTC'))),
+        ('m', pa.map_(pa.string(), pa.timestamp('ms', 'UTC'))),
+    ])
+    options = {'read.batch-size': '2', 'bucket': '1'} if mode == 'pk' else 
{'read.batch-size': '2'}
+    if 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)
+    _write(catalog.get_table('default.t'), pa.Table.from_pylist([
+        dict(id=0, r={'t': 0, 'd': 0}, a=[0, -1, None], m=[('first', 0), 
('last', -1)]),
+        dict(id=1, r=None, a=None, m=None),
+        dict(id=2, r={'t': _VALUES[2], 'd': 19875}, a=[], m=[]),
+    ], schema=schema))
+    catalog.alter_table('default.t', [
+        SchemaChange.update_column_type(['r', 't'], 
AtomicType('TIMESTAMP_LTZ(1)')),
+        SchemaChange.update_column_type(['r', 'd'], 
AtomicType('TIMESTAMP_LTZ(3)')),
+        SchemaChange.update_column_type(['a', 'element'], AtomicType('DATE')),
+        SchemaChange.update_column_type(['m', 'value'], AtomicType('TIME(1)')),
+    ], False)
+    table = catalog.get_table('default.t')
+    expected_t = [_JAVA_CASTS[zone][0][0], None, _JAVA_CASTS[zone][0][2]]
+    expected_d = [_case(4)[3][0], None, _case(4)[3][2]]
+    expected_a = pa.array([[_JAVA_CASTS[zone][2][0], _JAVA_CASTS[zone][2][1], 
None], None, []],
+                          type=pa.list_(pa.date32())).to_pylist()
+    for native in (False, True):
+        result = _read(table, native)
+        assert _raw(result.column('r').flatten()[0]) == expected_t
+        assert _raw(result.column('r').flatten()[1]) == expected_d
+        assert result.column('a').to_pylist() == expected_a
+        leaves = _read(table, native, projection=['id', 'r.t', 'r.d'])
+        assert _raw(leaves.column('r_t')) == expected_t
+        assert _raw(leaves.column('r_d')) == expected_d
+        selected = _read(table, native, projection=['id', "m['first']", 
"m['last']", "m['missing']"])
+        assert _raw(selected.column('m_first')) == [_JAVA_CASTS[zone][3][0], 
None, None]
+        assert _raw(selected.column('m_last')) == [_JAVA_CASTS[zone][3][1], 
None, None]
+        assert selected.column('m_missing').null_count == 3
+
+
[email protected]('mode', ['append', 'de'])
[email protected]('operation', range(4))
+def test_wide_temporal_casts_preserve_mixed_rest_batches(rest_catalog, mode, 
operation):
+    zone = _zone()
+    source, _, target, ordinary = _case(operation)
+    catalog, _ = rest_catalog
+    _table(catalog, mode, source, [0, 2 ** 63 - 1, None, -(2 ** 63)])
+    catalog.alter_table('default.t', [SchemaChange.update_column_type('v', 
AtomicType(target))], False)
+    table = catalog.get_table('default.t')
+    expected = [ordinary[0], _JAVA_WIDE[zone][operation][0], None, 
_JAVA_WIDE[zone][operation][1]]
+    for native in (False, True):
+        result = _read(table, native).column('v')
+        assert _raw(result) == expected
+        assert result.null_count == 1
+
+
[email protected]('mode', ['append', 'de'])
+def test_local_timestamp_can_cross_library_bounds_in_rest_reads(rest_catalog, 
mode):
+    zone = _zone()
+    values = [-377705023201000, -377705023200000, 253402207199000, 
253402207200000, None]
+    expected = {
+        'UTC': values,
+        'Asia/Shanghai': [-377705052344000, -377705052343000, 253402178399000, 
253402178400000, None],
+        'America/New_York': [-377705005439000, -377705005438000, 
253402225199000, 253402225200000, None],
+    }[zone]
+    catalog, _ = rest_catalog
+    _table(catalog, mode, pa.timestamp('ms'), values)
+    catalog.alter_table('default.t', [SchemaChange.update_column_type('v', 
AtomicType('TIMESTAMP_LTZ(3)'))], False)
+    for native in (False, True):
+        result = _read(catalog.get_table('default.t'), native).column('v')
+        assert _raw(result) == expected
+        assert result.null_count == 1
diff --git a/paimon-python/pypaimon/tests/schema_evolution_cast_test.py 
b/paimon-python/pypaimon/tests/schema_evolution_cast_test.py
index 1b52f967d8..f3280ec9f8 100644
--- a/paimon-python/pypaimon/tests/schema_evolution_cast_test.py
+++ b/paimon-python/pypaimon/tests/schema_evolution_cast_test.py
@@ -16,6 +16,9 @@
 # under the License.
 
 from decimal import Decimal
+import os
+import time
+from types import SimpleNamespace
 
 import pyarrow as pa
 import pytest
@@ -24,6 +27,36 @@ from pypaimon.casting.schema_evolution import 
cast_array_for_schema_evolution
 from pypaimon.schema.data_types import AtomicType
 
 
[email protected]
+def local_zone():
+    if not hasattr(time, 'tzset'):
+        pytest.skip('Changing the process local zone requires tzset')
+    original = os.environ.get('TZ')
+
+    def set_zone(name):
+        os.environ['TZ'] = name
+        time.tzset()
+
+    yield set_zone
+    if original is None:
+        os.environ.pop('TZ', None)
+    else:
+        os.environ['TZ'] = original
+    time.tzset()
+
+
[email protected]('source_type,target_type,expected', [
+    (pa.timestamp('ms', 'UTC'), pa.timestamp('ms'), [28800000, 28799999, 
None]),
+    (pa.timestamp('ms'), pa.timestamp('ms', 'UTC'), [-28800000, -28800001, 
None]),
+    (pa.date32(), pa.timestamp('ms', 'UTC'), [-28800000, -115200000, None]),
+])
+def test_temporal_cast_uses_java_process_local_zone(local_zone, source_type, 
target_type, expected):
+    local_zone('Asia/Shanghai')
+    source = pa.array([0, -1, None], type=source_type)
+    result = cast_array_for_schema_evolution(source, target_type)
+    assert result.cast(pa.int64()).to_pylist() == expected
+
+
 @pytest.mark.parametrize('source_type,values,target_type,expected', [
     (pa.float32(), [128.9, -129.9, 1e30, -1e30, None], pa.int8(), [-128, 127, 
-1, 0, None]),
     (pa.float64(), [32768.9, -32769.9, 1e30, -1e30, None], pa.int16(), 
[-32768, 32767, -1, 0, None]),
@@ -163,3 +196,150 @@ def 
test_timestamp_seconds_wrapping_keeps_wrapped_millisecond_fraction():
     assert cast_array_for_schema_evolution(
         source, pa.string(), AtomicType('TIMESTAMP(3)'), 
AtomicType('STRING')).to_pylist() == [
         '5471-09-26 16:47:04.384', None]
+
+
+_JAVA_ZONE_CASTS = [
+    ('America/New_York', [
+        [18000000, 17999900, 1710055800100, 1730611800100, 1710070200100, 
1730633400100,
+         -53757322487462200, 53757322519624100],
+        [-18000000, -18000100, 1710019800100, 1730583000100, 1710041400100, 
1730597400100,
+         9223372036840375800, 9223372036837013800],
+        [-1, -1, 19791, 20029, 19792, 20030,
+         -622191233, 622191233],
+        [68400000, 68399999, 77400123, 77400123, 12600123, 5400123,
+         11575807, 42662192],
+    ]),
+    ('Asia/Shanghai', [
+        [-28800000, -28800100, 1710009000100, 1730568600100, 1710027000100, 
1730586600100,
+         -53757322534367200, 53757322476424100],
+        [28800000, 28799900, 1710066600100, 1730626200100, 1710084600100, 
1730644200100,
+         -9223372036825975900, -9223372036825632900],
+        [0, 0, 19792, 20030, 19792, 20030,
+         -622191233, 622191234],
+        [28800000, 28799999, 37800123, 34200123, 55800123, 52200123,
+         54775807, 3167192],
+    ]),
+]
+
+
[email protected]('zone,expected', _JAVA_ZONE_CASTS)
[email protected]('operation', range(4))
[email protected]('chunked', [False, True])
+def test_temporal_zone_casts_match_java_boundaries(local_zone, zone, expected, 
operation, chunked):
+    local_zone(zone)
+    from_type, target, from_name, target_name = [
+        (pa.timestamp('ms'), pa.timestamp('ms', 'UTC'), 'TIMESTAMP(9)', 
'TIMESTAMP_LTZ(1)'),
+        (pa.timestamp('ms', 'UTC'), pa.timestamp('ms'), 'TIMESTAMP_LTZ(9)', 
'TIMESTAMP(1)'),
+        (pa.timestamp('ms', 'UTC'), pa.date32(), 'TIMESTAMP_LTZ(9)', 'DATE'),
+        (pa.timestamp('ms', 'UTC'), pa.time32('ms'), 'TIMESTAMP_LTZ(9)', 
'TIME(1)'),
+    ][operation]
+    values = [0, -1, 1710037800123, 1730597400123, 1710055800123, 
1730615400123,
+              9223372036854775807, -9223372036854775808]
+    source = pa.array([None] + values + [None, None], type=from_type).slice(1, 
9)
+    if chunked:
+        source = pa.chunked_array([source.slice(0, 1), source.slice(1, 3), 
source.slice(4)])
+    result = cast_array_for_schema_evolution(source, target, 
AtomicType(from_name), AtomicType(target_name))
+    if operation in (2, 3):
+        result = result.cast(pa.int32())
+    else:
+        result = result.cast(pa.int64())
+    assert result.to_pylist() == expected[operation] + [None]
+
+
[email protected]('zone,expected', [
+    ('Asia/Shanghai', [-28800000, -115200000, 1717171200000, 1325174400000, 
None]),
+    ('America/New_York', [18000000, -68400000, 1717214400000, 1325221200000, 
None]),
+    ('Pacific/Apia', [39600000, -46800000, 1717153200000, 1325239200000, 
None]),
+])
+def test_date_to_ltz_resolves_java_midnight(local_zone, zone, expected):
+    local_zone(zone)
+    source = pa.array([0, -1, 19875, 15338, None], type=pa.date32())
+    assert cast_array_for_schema_evolution(source, pa.timestamp('ms', 
'UTC')).cast(pa.int64()).to_pylist() == expected
+
+
[email protected]('value', [-(2 ** 31), 2 ** 31 - 1])
+def test_date_to_ltz_rejects_invalid_java_julian_date(local_zone, value):
+    local_zone('UTC')
+    with pytest.raises(ValueError, match='Invalid Java DATE'):
+        cast_array_for_schema_evolution(pa.array([value], type=pa.date32()), 
pa.timestamp('ms', 'UTC'))
+
+
[email protected]('precision,target,expected', [
+    (6, pa.timestamp('us', 'UTC'), [1710055800123456, 17999999999, None]),
+    (1, pa.timestamp('ms', 'UTC'), [1710055800100, 17999900, None]),
+])
+def test_cross_zone_cast_preserves_nanos_and_declared_precision(local_zone, 
precision, target, expected):
+    local_zone('America/New_York')
+    source = pa.array([1710037800123456789, -1, None], type=pa.timestamp('ns'))
+    result = cast_array_for_schema_evolution(
+        source, target, AtomicType('TIMESTAMP(9)'), 
AtomicType('TIMESTAMP_LTZ(%d)' % precision))
+    assert result.cast(pa.int64()).to_pylist() == expected
+
+
+def test_bigint_to_ltz_keeps_java_epoch_seconds_and_long_wrapping(local_zone):
+    local_zone('Asia/Shanghai')
+    source = pa.array([0, -1, 2 ** 63 - 1, -(2 ** 63), None], type=pa.int64())
+    assert cast_array_for_schema_evolution(source, pa.timestamp('ms', 
'UTC')).cast(pa.int64()).to_pylist() == [
+        28800000, 28799000, 28799000, 28800000, None]
+
+
+def test_ltz_semantics_follow_declared_paimon_type(local_zone):
+    local_zone('Asia/Shanghai')
+    source = pa.array([0, -1, None], type=pa.timestamp('ms'))
+    result = cast_array_for_schema_evolution(
+        source, pa.timestamp('ms'), AtomicType('TIMESTAMP_LTZ(3)'), 
AtomicType('TIMESTAMP(3)'))
+    assert result.cast(pa.int64()).to_pylist() == [28800000, 28799999, None]
+
+
[email protected]('zone,values,expected', [
+    ('Asia/Shanghai', [-377705023201000, -377705023200000, None],
+     [-377705052344000, -377705052343000, None]),
+    ('America/New_York', [253402207199000, 253402207200000, None],
+     [253402225199000, 253402225200000, None]),
+])
+def test_local_wall_time_can_cross_date_library_bounds(local_zone, zone, 
values, expected):
+    local_zone(zone)
+    source = pa.array(values, type=pa.timestamp('ms'))
+    result = cast_array_for_schema_evolution(source, pa.timestamp('ms', 'UTC'))
+    assert result.cast(pa.int64()).to_pylist() == expected
+
+
[email protected]('platform', ['standard_offset', 'range_error'])
[email protected]('target,expected', [
+    (pa.timestamp('ms'), [
+        -18000000, 9223372036840375807, 9223372021198375807, 
9223372036830600000,
+        9223372036837013808, 253402282800000, None]),
+    (pa.time32('ms'), [68400000, 11575807, 7975807, 1800000, 42662192, 
68400000, None]),
+    (pa.date32(), [-1, -622191233, -622191414, -622191233, 622191233, 2932896, 
None]),
+    (pa.string(), [
+        '1969-12-31 19:00:00.000', '/+-.-04-06 03:12:55.807', '/+---10-08 
02:12:55.807',
+        '/+-.-04-06 00:30:00.000', '/+-.-04-06 02:16:53.808', '9999-12-31 
19:00:00.000', None]),
+])
+def test_wide_ltz_casts_keep_java_dst_when_platform_localtime_cannot(
+        local_zone, monkeypatch, platform, target, expected):
+    local_zone('America/New_York')
+    libc_localtime = time.localtime
+
+    def limited_localtime(seconds=None):
+        if seconds is None:
+            return libc_localtime()
+        if seconds > 253402207200:
+            if platform == 'standard_offset':
+                # Some libc implementations lose the far-future DST rule.
+                return SimpleNamespace(tm_gmtoff=-18000, tm_isdst=0)
+            raise OverflowError('Platform cannot convert this future year')
+        if seconds < -377705023201 and platform == 'range_error':
+            raise OverflowError('Platform cannot convert this ancient year')
+        return libc_localtime(seconds)
+
+    monkeypatch.setattr(time, 'localtime', limited_localtime)
+    values = [0, 9223372036854775807, 9223372021216375807, 9223372036845000000,
+              -9223372036854775808, 253402300800000, None]
+    source = pa.array([None] + values + [None], type=pa.timestamp('ms', 
'UTC')).slice(1, len(values))
+    source = pa.chunked_array([source.slice(0, 3), source.slice(3)])
+    result = cast_array_for_schema_evolution(source, target)
+    if not pa.types.is_string(target):
+        result = result.cast(pa.int64() if pa.types.is_timestamp(target) else 
pa.int32())
+    # Golden values from real Java CastExecutors, including a local midnight
+    # whose DATE changes if a standard offset replaces the correct DST offset.
+    assert result.to_pylist() == expected

Reply via email to