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 11905d8680 [python] Write TIMESTAMP(0) columns to Avro as
timestamp-millis (#10193)
11905d8680 is described below
commit 11905d8680c00cbadc5ef2df83634ab2088b11f3
Author: jackylee <[email protected]>
AuthorDate: Sat Oct 3 17:09:55 2026 +0800
[python] Write TIMESTAMP(0) columns to Avro as timestamp-millis (#10193)
---
paimon-python/pypaimon/filesystem/local_file_io.py | 25 +++++++-
paimon-python/pypaimon/schema/data_types.py | 6 +-
paimon-python/pypaimon/tests/data_types_test.py | 75 ++++++++++++++++++++++
3 files changed, 101 insertions(+), 5 deletions(-)
diff --git a/paimon-python/pypaimon/filesystem/local_file_io.py
b/paimon-python/pypaimon/filesystem/local_file_io.py
index e3530c1951..45bb5421d3 100644
--- a/paimon-python/pypaimon/filesystem/local_file_io.py
+++ b/paimon-python/pypaimon/filesystem/local_file_io.py
@@ -35,6 +35,27 @@ from pypaimon.schema.data_types import DataField,
AtomicType, PyarrowFieldParser
from pypaimon.write.blob_format_writer import BlobFormatWriter
+def _normalize_naive_datetimes(value):
+ """Recursively attach UTC to naive datetimes, including ones nested inside
+ ROW (dict) and ARRAY/MAP (list/tuple) values.
+
+ fastavro converts a naive datetime using the host timezone, which corrupts
+ the stored instant on a non-UTC host; Paimon timestamps are UTC-based.
+ ``write_avro`` previously normalized only top-level column values, so a
+ naive datetime inside a struct or list was written shifted by the host
+ offset. Normalizing recursively keeps nested timestamps correct.
+ """
+ if isinstance(value, datetime):
+ if value.tzinfo is None:
+ return value.replace(tzinfo=timezone.utc)
+ return value
+ if isinstance(value, dict):
+ return {key: _normalize_naive_datetimes(val) for key, val in
value.items()}
+ if isinstance(value, (list, tuple)):
+ return type(value)(_normalize_naive_datetimes(val) for val in value)
+ return value
+
+
def _file_uri_path(parsed, windows=None) -> str:
"""Decode a file URI path, preserving Windows drives and UNC hosts."""
if windows is None:
@@ -358,9 +379,7 @@ class LocalFileIO(FileIO):
record = {}
for col in records_dict.keys():
value = records_dict[col][i]
- if isinstance(value, datetime) and value.tzinfo is None:
- value = value.replace(tzinfo=timezone.utc)
- record[col] = value
+ record[col] = _normalize_naive_datetimes(value)
yield record
records = record_generator()
diff --git a/paimon-python/pypaimon/schema/data_types.py
b/paimon-python/pypaimon/schema/data_types.py
index c73223b883..f4b176fe6f 100755
--- a/paimon-python/pypaimon/schema/data_types.py
+++ b/paimon-python/pypaimon/schema/data_types.py
@@ -900,14 +900,16 @@ class PyarrowFieldParser:
elif pyarrow.types.is_timestamp(field_type):
unit = field_type.unit
if field_type.tz is None:
- if unit == 'ms':
+ if unit in ('s', 'ms'):
+ # Avro's coarsest timestamp is millis; seconds map to it
+ # losslessly, matching Java AvroSchemaConverter
(precision<=3).
return {"type": "long", "logicalType": "timestamp-millis"}
elif unit == 'us':
return {"type": "long", "logicalType": "timestamp-micros"}
else:
raise ValueError(f"Avro does not support pyarrow timestamp
with unit {unit}.")
else:
- if unit == 'ms':
+ if unit in ('s', 'ms'):
return {"type": "long", "logicalType":
"local-timestamp-millis"}
elif unit == 'us':
return {"type": "long", "logicalType":
"local-timestamp-micros"}
diff --git a/paimon-python/pypaimon/tests/data_types_test.py
b/paimon-python/pypaimon/tests/data_types_test.py
index 8ccef3a165..8d0957fac0 100755
--- a/paimon-python/pypaimon/tests/data_types_test.py
+++ b/paimon-python/pypaimon/tests/data_types_test.py
@@ -336,3 +336,78 @@ class DataTypesTest(unittest.TestCase):
paimon_type = PyarrowFieldParser.to_paimon_type(pa.time32('ms'),
nullable=True)
self.assertEqual(paimon_type.type, "TIME(0)")
+
+ def test_avro_timestamp_seconds_maps_and_roundtrips(self):
+ import datetime
+ import os
+ import tempfile
+
+ import fastavro
+
+ from pypaimon.filesystem.local_file_io import LocalFileIO
+
+ # TIMESTAMP(0) -> pyarrow 's'; Avro's coarsest timestamp is millis,
which
+ # holds seconds losslessly and matches Java AvroSchemaConverter
(precision<=3).
+ self.assertEqual(
+ PyarrowFieldParser.to_avro_type(pa.timestamp('s'), 'ts', 'r'),
+ {"type": "long", "logicalType": "timestamp-millis"})
+ self.assertEqual(
+ PyarrowFieldParser.to_avro_type(pa.timestamp('s', tz='UTC'), 'ts',
'r'),
+ {"type": "long", "logicalType": "local-timestamp-millis"})
+ # nanos stays rejected: Avro has no nanos logical type (matches Java
precision>6).
+ with self.assertRaises(ValueError):
+ PyarrowFieldParser.to_avro_type(pa.timestamp('ns'), 'ts', 'r')
+
+ # A second-granularity value round-trips unchanged (no seconds/millis
mixup).
+ ts = datetime.datetime(2024, 1, 2, 3, 4, 5)
+ table = pa.table({"ts": pa.array([ts], pa.timestamp('s'))})
+ with tempfile.TemporaryDirectory() as d:
+ path = os.path.join(d, "data.avro")
+ LocalFileIO().write_avro(path, table)
+ with open(path, 'rb') as f:
+ rows = list(fastavro.reader(f))
+ self.assertEqual(rows[0]["ts"].replace(tzinfo=None), ts)
+
+ def test_avro_nested_timestamp_roundtrips_on_non_utc_host(self):
+ # A naive TIMESTAMP(0) nested in a ROW or ARRAY must survive a write on
+ # a non-UTC host. fastavro converts a naive datetime using the host
+ # timezone, so without recursive UTC normalization the nested values
+ # were persisted shifted by the host offset (reproduced by the review
+ # under TZ=Asia/Shanghai). The top-level value was already normalized.
+ import datetime
+ import os
+ import tempfile
+ import time
+
+ import fastavro
+
+ from pypaimon.filesystem.local_file_io import LocalFileIO
+
+ if not hasattr(time, "tzset"):
+ self.skipTest("time.tzset is unavailable on this platform")
+
+ ts = datetime.datetime(2024, 1, 2, 3, 4, 5)
+ table = pa.table({
+ "ts": pa.array([ts], pa.timestamp('s')),
+ "r": pa.array([{"ts": ts}], pa.struct([("ts",
pa.timestamp('s'))])),
+ "arr": pa.array([[ts]], pa.list_(pa.timestamp('s'))),
+ })
+ previous_tz = os.environ.get("TZ")
+ os.environ["TZ"] = "Asia/Shanghai"
+ time.tzset()
+ try:
+ with tempfile.TemporaryDirectory() as d:
+ path = os.path.join(d, "data.avro")
+ LocalFileIO().write_avro(path, table)
+ with open(path, 'rb') as f:
+ rows = list(fastavro.reader(f))
+ finally:
+ if previous_tz is None:
+ os.environ.pop("TZ", None)
+ else:
+ os.environ["TZ"] = previous_tz
+ time.tzset()
+
+ self.assertEqual(rows[0]["ts"].replace(tzinfo=None), ts)
+ self.assertEqual(rows[0]["r"]["ts"].replace(tzinfo=None), ts)
+ self.assertEqual(rows[0]["arr"][0].replace(tzinfo=None), ts)