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 a6ae9d8cf8 [python] Read partition metadata with Native SystemRead
(#10605)
a6ae9d8cf8 is described below
commit a6ae9d8cf858137bdfce28e70ac4f66162858b40
Author: Jingsong Lee <[email protected]>
AuthorDate: Fri Oct 16 20:28:18 2026 +0800
[python] Read partition metadata with Native SystemRead (#10605)
---
.github/workflows/ci-python.yml | 22 +-
.../pypaimon/table/system/native_system_table.py | 4 +-
.../pypaimon/table/system/partitions_table.py | 96 +++++---
.../pypaimon/table/system/system_table.py | 7 +
.../tests/native_partition_metadata_test.py | 248 +++++++++++++++++++++
.../pypaimon/tests/native_system_table_test.py | 5 +-
.../tests/system/native_system_table_test.py | 4 +-
.../pypaimon/tests/system/partitions_table_test.py | 25 ++-
8 files changed, 371 insertions(+), 40 deletions(-)
diff --git a/.github/workflows/ci-python.yml b/.github/workflows/ci-python.yml
index c34c60d745..b6a0fb9f81 100644
--- a/.github/workflows/ci-python.yml
+++ b/.github/workflows/ci-python.yml
@@ -62,11 +62,6 @@ jobs:
restore-keys: |
ci-maven-python-${{ runner.os }}-${{ runner.arch }}-
- - name: Set up Maven
- uses: stCarolas/setup-maven@v5
- with:
- maven-version: 3.8.8
-
- name: Install system dependencies
shell: bash
run: |
@@ -86,6 +81,22 @@ jobs:
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
+ - name: Set up Maven
+ shell: bash
+ env:
+ MAVEN_VERSION: '3.8.8'
+ run: |
+
maven_archive="${RUNNER_TEMP}/apache-maven-${MAVEN_VERSION}-bin.tar.gz"
+ # Central can return transient 404s even for an existing release.
+ curl --fail --location --retry 3 --retry-all-errors --retry-delay 2 \
+ --output "$maven_archive" \
+
"https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/${MAVEN_VERSION}/apache-maven-${MAVEN_VERSION}-bin.tar.gz"
\
+ || curl --fail --location --retry 3 --retry-all-errors
--retry-delay 2 \
+ --output "$maven_archive" \
+
"https://archive.apache.org/dist/maven/maven-3/${MAVEN_VERSION}/binaries/apache-maven-${MAVEN_VERSION}-bin.tar.gz"
+ tar -xzf "$maven_archive" -C "$RUNNER_TEMP"
+ echo "${RUNNER_TEMP}/apache-maven-${MAVEN_VERSION}/bin" >>
"$GITHUB_PATH"
+
- name: Verify Java and Maven installation
run: |
java -version
@@ -248,6 +259,7 @@ jobs:
paimon-python/pypaimon/tests/native_cast_completion_test.py \
paimon-python/pypaimon/tests/native_temporal_zone_cast_test.py \
paimon-python/pypaimon/tests/native_schema_cast_test.py \
+ paimon-python/pypaimon/tests/native_partition_metadata_test.py \
-k 'TIMESTAMP or temporal'
done
diff --git a/paimon-python/pypaimon/table/system/native_system_table.py
b/paimon-python/pypaimon/table/system/native_system_table.py
index 0123576b69..834dff23fe 100644
--- a/paimon-python/pypaimon/table/system/native_system_table.py
+++ b/paimon-python/pypaimon/table/system/native_system_table.py
@@ -23,11 +23,9 @@ from pypaimon.schema.data_types import PyarrowFieldParser
from pypaimon.read.native_plan import _native_table, native_method_available
from pypaimon.write.native_commit import _rest_catalog_supported
-# Partitions need the catalog's list-partitions operation, which is not carried
-# by a resolved Native Table. Keep that table on the existing catalog path.
_NATIVE_TABLES = frozenset((
'snapshots', 'schemas', 'options', 'manifests', 'files', 'buckets',
- 'tags', 'branches', 'file_key_ranges', 'table_indexes', 'consumers',
+ 'tags', 'branches', 'file_key_ranges', 'table_indexes', 'consumers',
'partitions',
))
diff --git a/paimon-python/pypaimon/table/system/partitions_table.py
b/paimon-python/pypaimon/table/system/partitions_table.py
index 70685fee0b..af0b805b07 100644
--- a/paimon-python/pypaimon/table/system/partitions_table.py
+++ b/paimon-python/pypaimon/table/system/partitions_table.py
@@ -21,12 +21,17 @@ from typing import List, Optional
import pyarrow
+from pypaimon.common.options.core_options import CoreOptions
from pypaimon.index.index_file_handler import IndexFileHandler
from pypaimon.manifest.index_manifest_file import IndexManifestFile
from pypaimon.manifest.manifest_file_manager import ManifestFileManager
from pypaimon.manifest.manifest_list_manager import ManifestListManager
from pypaimon.schema.data_types import AtomicType, DataField, RowType
-from pypaimon.table.system.system_table import SystemTable
+from pypaimon.table.system.files_table import _render_object, _render_value
+from pypaimon.table.system.system_table import (
+ SystemTable, metadata_snapshot, metadata_local_millis,
metadata_epoch_millis)
+from pypaimon.table.row.generic_row import GenericRowSerializer
+from pypaimon.utils.file_store_path_factory import _is_null_or_whitespace_only
TABLE_TYPE = RowType(False, [
@@ -67,7 +72,7 @@ class PartitionsTable(SystemTable):
return ["partition"]
def _build_arrow_table(self) -> pyarrow.Table:
- snapshot = self.base_table.snapshot_manager().get_latest_snapshot()
+ snapshot = metadata_snapshot(self.base_table)
if snapshot is None:
return self._empty_table()
@@ -79,24 +84,22 @@ class PartitionsTable(SystemTable):
deleted_record_counts = self._deleted_record_counts(snapshot)
partition_map: dict = {}
+ default_name =
self.base_table.options.options.get(CoreOptions.PARTITION_DEFAULT_NAME)
+ legacy =
self.base_table.options.options.get(CoreOptions.PARTITION_GENERATE_LEGACY_NAME)
for entry in entries:
- partition_key = tuple(entry.partition.values)
- spec_items = tuple(
- (field.name, str(value))
- for field, value in zip(
- entry.partition.fields, entry.partition.values))
- spec_key = spec_items
-
+ partition_key =
bytes(GenericRowSerializer.to_bytes(entry.partition))
+ spec_key = partition_key
stats = partition_map.get(spec_key)
if stats is None:
+ partition = _normalize_partition(entry.partition,
default_name, legacy)
stats = {
- "spec_items": spec_items,
- "partition_key": partition_key,
+ "partition_string": _render_partition(partition,
default_name),
+ "deletion_key":
bytes(GenericRowSerializer.to_bytes(partition)),
"record_count": 0,
"file_size_in_bytes": 0,
"file_count": 0,
"last_update_time": None,
- "buckets": set(),
+ "total_buckets": entry.total_buckets,
}
partition_map[spec_key] = stats
@@ -104,11 +107,11 @@ class PartitionsTable(SystemTable):
stats["file_size_in_bytes"] += int(entry.file.file_size)
stats["file_count"] += 1
if entry.file.creation_time is not None:
- ct_ms = entry.file.creation_time.get_millisecond()
+ ct_ms =
metadata_epoch_millis(entry.file.creation_time.get_millisecond())
if (stats["last_update_time"] is None
or ct_ms > stats["last_update_time"]):
stats["last_update_time"] = ct_ms
- stats["buckets"].add(int(entry.bucket))
+ stats["total_buckets"] = entry.total_buckets
partition_strings: List[Optional[str]] = []
record_counts: List[int] = []
@@ -118,15 +121,16 @@ class PartitionsTable(SystemTable):
total_buckets: List[int] = []
deleted_counts: List[Optional[int]] = []
- for stats in partition_map.values():
- partition_strings.append(_render_partition(stats["spec_items"]))
+ for stats in sorted(partition_map.values(), key=lambda stats:
stats["partition_string"]):
+ partition_strings.append(stats["partition_string"])
record_counts.append(stats["record_count"])
file_sizes.append(stats["file_size_in_bytes"])
file_counts.append(stats["file_count"])
- last_update_times.append(stats["last_update_time"])
- total_buckets.append(len(stats["buckets"]))
+ last_update_times.append(None if stats["last_update_time"] is None
+ else
metadata_local_millis(stats["last_update_time"]))
+ total_buckets.append(stats["total_buckets"])
deleted_counts.append(
- deleted_record_counts.get(stats["partition_key"], 0))
+ deleted_record_counts.get(stats["deletion_key"], 0))
n = len(partition_map)
return pyarrow.table({
@@ -155,12 +159,12 @@ class PartitionsTable(SystemTable):
entries = IndexFileHandler(self.base_table).scan(
snapshot,
- lambda entry: entry.index_file.index_type
+ lambda entry: entry.kind == 0 and entry.index_file.index_type
== IndexManifestFile.DELETION_VECTORS_INDEX,
)
result = {}
for entry in entries:
- partition_key = tuple(entry.partition.values)
+ partition_key =
bytes(GenericRowSerializer.to_bytes(entry.partition))
if partition_key in result and result[partition_key] is None:
continue
@@ -197,8 +201,48 @@ class PartitionsTable(SystemTable):
})
-def _render_partition(spec_items) -> Optional[str]:
- """Render a partition spec as ``pt=v/pt2=v2`` or None when empty."""
- if not spec_items:
- return None
- return "/".join("{}={}".format(name, value) for name, value in spec_items)
+def _render_partition(partition, default_name) -> str:
+ """Java PartitionsTable uses typed SQL casts and an unescaped key=value
spec."""
+ parts = []
+ for index, field in enumerate(partition.fields):
+ value = partition.get_field(index)
+ text = default_name if value is None else _render_value(value,
field.type)
+ if _is_null_or_whitespace_only(text):
+ text = default_name
+ parts.append("{}={}".format(field.name, text))
+ return "/".join(parts)
+
+
+def _normalize_partition(partition, default_name, legacy):
+ """Java reconstructs one typed spec row for both display and deletion
lookup."""
+ from pypaimon.table.row.generic_row import (
+ GenericRow, _parse_type_precision_scale, _is_ltz_type, _normalize_ltz,
+ _datetime_to_millis_and_nanos, _millis_nanos_to_datetime)
+ values = []
+ for index, field in enumerate(partition.fields):
+ value = partition.get_field(index)
+ if value is not None:
+ text = _render_object(value, field.type) if legacy else
_render_value(value, field.type)
+ if text == default_name or _is_null_or_whitespace_only(text):
+ value = None
+ elif isinstance(value, (bytes, bytearray, memoryview)):
+ value = text.encode('utf-8')
+ elif str(field.type).upper().startswith('TIMESTAMP'):
+ precision, _ = _parse_type_precision_scale(field.type)
+ millis, nanos =
_datetime_to_millis_and_nanos(_normalize_ltz(value))
+ if _is_ltz_type(str(field.type).upper()):
+ wall_millis = millis if legacy else
metadata_local_millis(millis)
+ millis = metadata_epoch_millis(wall_millis)
+ if precision <= 3:
+ quantum = 10 ** (3 - precision)
+ millis, nanos = millis // quantum * quantum, 0
+ else:
+ quantum = 10 ** (9 - precision)
+ nanos = nanos // quantum * quantum
+ value = _millis_nanos_to_datetime(millis, nanos)
+ elif not legacy and str(field.type).upper().startswith('TIME'):
+ precision, _ = _parse_type_precision_scale(field.type)
+ quantum = 10 ** (6 - min(precision, 3))
+ value = value.replace(microsecond=value.microsecond // quantum
* quantum)
+ values.append(value)
+ return GenericRow(values, partition.fields)
diff --git a/paimon-python/pypaimon/table/system/system_table.py
b/paimon-python/pypaimon/table/system/system_table.py
index a4f2c11c82..989435c04a 100644
--- a/paimon-python/pypaimon/table/system/system_table.py
+++ b/paimon-python/pypaimon/table/system/system_table.py
@@ -47,6 +47,13 @@ def metadata_local_millis(millis):
return _signed(int(millis) + _local_offset(int(millis) // 1000) * 1000, 64)
+def metadata_epoch_millis(millis):
+ """Java DataFileMeta.creationTimeEpochMillis for a stored local
TIMESTAMP."""
+ from pypaimon.casting.temporal_utils import _local_to_utc_seconds, _signed
+ seconds, fraction = divmod(int(millis), 1000)
+ return _signed(_local_to_utc_seconds(seconds) * 1000 + fraction, 64)
+
+
def metadata_snapshot(table):
"""Use the table's captured view, including an explicitly empty
snapshot."""
if '_read_snapshot' in vars(table):
diff --git a/paimon-python/pypaimon/tests/native_partition_metadata_test.py
b/paimon-python/pypaimon/tests/native_partition_metadata_test.py
new file mode 100644
index 0000000000..2ca0a8d1bd
--- /dev/null
+++ b/paimon-python/pypaimon/tests/native_partition_metadata_test.py
@@ -0,0 +1,248 @@
+# 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 partition metadata uses the same manifest view and Java casts as
Python."""
+
+from datetime import date, datetime, time
+from decimal import Decimal
+import json
+import os
+from unittest.mock import patch
+
+import pyarrow as pa
+import pytest
+
+from pypaimon import Schema
+from pypaimon.read.native_plan import native_method_available
+from pypaimon.index.deletion_vector_meta import DeletionVectorMeta
+from pypaimon.index.index_file_meta import IndexFileMeta
+from pypaimon.manifest.index_manifest_entry import IndexManifestEntry
+from pypaimon.manifest.index_manifest_file import IndexManifestFile
+from pypaimon.table.row.generic_row import GenericRow
+from pypaimon.tests import native_plan_rest_test
+from pypaimon.tests.native_system_table_test import _canonical, _read, _system
+
+
+rest_catalog = native_plan_rest_test.rest_catalog
+
+pytestmark = [pytest.mark.native_plan, pytest.mark.skipif(
+ not native_method_available('Table', 'new_system_read'), reason='Native
system-table reader required')]
+
+
+def _create(catalog, schema, partition_keys, options=None):
+ settings = {'write.native.enabled': 'false', 'commit.native.enabled':
'false'}
+ settings.update(options or {})
+ catalog.create_table('default.part_metadata', Schema.from_pyarrow_schema(
+ schema, partition_keys=partition_keys, options=settings), False)
+ return catalog.get_table('default.part_metadata')
+
+
+def _append(table, rows, schema):
+ builder = table.new_batch_write_builder()
+ writer, commit = builder.new_write(), builder.new_commit()
+ try:
+ writer.write_arrow(pa.Table.from_pylist(rows, schema=schema))
+ commit.commit(writer.prepare_commit())
+ finally:
+ writer.close()
+ commit.close()
+
+
+def _assert_parity(table):
+ expected = _read(_system(table, 'partitions', False))
+ native = _system(table, 'partitions', True)
+ with patch.object(type(native), '_build_arrow_table',
+ side_effect=AssertionError('Python partition aggregation
ran')):
+ actual = _read(native)
+ assert actual.schema == expected.schema
+ assert _canonical(actual, 'partitions') == _canonical(expected,
'partitions')
+ return actual.to_pylist()
+
+
[email protected]('typ,value,expected', [
+ (pa.bool_(), True, 'true'),
+ (pa.decimal128(10, 2), Decimal('1.20'), '1.20'),
+ (pa.date32(), date(2024, 2, 29), '2024-02-29'),
+ (pa.time32('ms'), time(12, 34, 56, 123000), '12:34:56'),
+ (pa.timestamp('us'), datetime(2024, 2, 29, 12, 34, 56, 123456),
'2024-02-29 12:34:56.123456'),
+ (pa.string(), 'a/b=c', 'a/b=c'),
+ (pa.float32(), 1.401298464324817e-45, '1.4E-45'),
+ (pa.float64(), 5e-324, '4.9E-324'),
+ (pa.binary(), b'hello', 'hello'),
+ (pa.binary(), b'\xed\xa0\x80', '\ufffd'),
+ (pa.string(), '\u00a0', '\u00a0'),
+ (pa.string(), '\u0085', '\u0085'),
+ (pa.string(), '\u001c', 'MISSING'),
+])
+def test_partition_values_are_java_casts(rest_catalog, typ, value, expected):
+ catalog, _ = rest_catalog
+ schema = pa.schema([('id', pa.int32()), ('p', typ)])
+ table = _create(catalog, schema, ['p'], {
+ 'partition.default-name': 'MISSING', 'partition.legacy-name': 'false'})
+ _append(table, [{'id': 1, 'p': value}, {'id': 2, 'p': None}], schema)
+ rows = _assert_parity(table)
+ assert sorted(row['partition'] for row in rows) == sorted(['p=' +
expected, 'p=MISSING'])
+ assert [row['total_buckets'] for row in rows] == [-1, -1]
+ assert [row['deleted_record_count'] for row in rows] == [0, 0]
+
+
+def test_unpartitioned_metadata_uses_empty_string(rest_catalog):
+ catalog, _ = rest_catalog
+ schema = pa.schema([('id', pa.int32())])
+ table = _create(catalog, schema, [])
+ _append(table, [{'id': 1}, {'id': 2}], schema)
+ rows = _assert_parity(table)
+ assert len(rows) == 1
+ assert rows[0]['partition'] == ''
+ assert rows[0]['record_count'] == 2
+
+
+def
test_declared_bucket_count_does_not_count_only_occupied_buckets(rest_catalog):
+ catalog, _ = rest_catalog
+ schema = pa.schema([('id', pa.int32()), ('p', pa.string())])
+ table = _create(catalog, schema, ['p'], {'bucket': '8', 'bucket-key':
'id'})
+ _append(table, [{'id': 1, 'p': 'one'}], schema)
+ rows = _assert_parity(table)
+ assert rows[0]['file_count'] == 1
+ assert rows[0]['total_buckets'] == 8
+
+
+def test_rendered_partition_collisions_are_not_aggregated(rest_catalog):
+ catalog, _ = rest_catalog
+ schema = pa.schema([('id', pa.int32()), ('p', pa.string()), ('p2',
pa.string())])
+ table = _create(catalog, schema, ['p', 'p2'])
+ _append(table, [{'id': 1, 'p': 'a/p2=b', 'p2': 'c'},
+ {'id': 2, 'p': 'a', 'p2': 'b/p2=c'}], schema)
+ rows = _assert_parity(table)
+ assert [row['partition'] for row in rows] == ['p=a/p2=b/p2=c'] * 2
+ assert [row['record_count'] for row in rows] == [1, 1]
+
+
+def
test_deletion_vector_counts_read_avro_metadata_and_keep_unknown(rest_catalog):
+ catalog, _ = rest_catalog
+ schema = pa.schema([('id', pa.int32()), ('p', pa.string())])
+ table = _create(catalog, schema, ['p'], {'deletion-vectors.enabled':
'true'})
+ _append(table, [{'id': 1, 'p': 'known'}, {'id': 2, 'p': 'unknown'},
+ {'id': 3, 'p': 'zero'}], schema)
+ entries = []
+ for partition, bucket, counts in [
+ ('known', 0, [1, 2]), ('known', 1, [3]),
+ ('unknown', 0, [None]), ('unknown', 1, [5])]:
+ ranges = {str(i): DeletionVectorMeta(str(i), 0, 1, count) for i, count
in enumerate(counts)}
+ entries.append(IndexManifestEntry(
+ 0, GenericRow([partition], table.partition_keys_fields), bucket,
+ IndexFileMeta('DELETION_VECTORS', partition + str(bucket), 1, 1,
dv_ranges=ranges)))
+ # Only manifest metadata is needed; no deletion-vector payload is opened.
+ table = _with_index_entries(table, entries)
+ rows = _assert_parity(table)
+ assert {row['partition']: row['deleted_record_count'] for row in rows} == {
+ 'p=known': 6, 'p=unknown': None, 'p=zero': 0}
+
+
+def _with_index_entries(table, entries):
+ name = IndexManifestFile(table).write(entries)
+ snapshot = table.snapshot_manager().get_latest_snapshot()
+ path = table.snapshot_manager().snapshot_dir + '/snapshot-' +
str(snapshot.id)
+ with table.file_io.new_input_stream(path) as stream:
+ data = json.loads(stream.read())
+ data['indexManifest'] = name
+ table.file_io.overwrite_file_utf8(path, json.dumps(data))
+ return
table._copy_with_snapshot(table.snapshot_manager().get_latest_snapshot())
+
+
[email protected]('typ,value,canonical,legacy', [
+ (pa.string(), 'MISSING', None, True),
+ (pa.string(), 'MISSING', None, False),
+ (pa.string(), '\x1c', None, True),
+ (pa.string(), '\x1c', None, False),
+ # Java byte[].toString has no stable legacy spec; exercise its SQL cast.
+ (pa.binary(), b'\xed\xa0\x80', '\ufffd'.encode('utf-8'), False),
+ (pa.time32('ms'), time(12, 34, 56, 123000), time(12, 34, 56), True),
+ (pa.time32('ms'), time(12, 34, 56, 123000), time(12, 34, 56), False),
+])
+def test_deletion_lookup_uses_java_partition_spec_round_trip(rest_catalog,
typ, value, canonical, legacy):
+ catalog, _ = rest_catalog
+ schema = pa.schema([('id', pa.int32()), ('p', typ)])
+ table = _create(catalog, schema, ['p'], {
+ 'deletion-vectors.enabled': 'true', 'partition.default-name':
'MISSING',
+ 'partition.legacy-name': str(legacy).lower()})
+ _append(table, [{'id': i, 'p': value} for i in range(3)]
+ + [{'id': i + 3, 'p': canonical} for i in range(3)], schema)
+ entries = []
+ for partition, count in [(value, 1), (canonical, 3)]:
+ ranges = {'file': DeletionVectorMeta('file', 0, 1, count)}
+ entries.append(IndexManifestEntry(
+ 0, GenericRow([partition], table.partition_keys_fields), 0,
+ IndexFileMeta('DELETION_VECTORS', str(count), 1, 1,
dv_ranges=ranges)))
+ table = _with_index_entries(table, entries)
+ rows = _assert_parity(table)
+ assert len(rows) == 2
+ # A legacy TIME spec is an integer millisecond count, so Java keeps its
+ # sub-second digits even when the declared TIME precision is zero.
+ expected = [1, 3] if legacy and pa.types.is_time(typ) else [3, 3]
+ assert sorted(row['deleted_record_count'] for row in rows) == expected
+
+
+def test_default_legacy_time_partition_keeps_deletion_counts(rest_catalog):
+ from pypaimon.tests.deletion_vector_path_test import _delete
+ catalog, _ = rest_catalog
+ schema = pa.schema([('id', pa.int32()), ('p', pa.time32('ms'))])
+ table = _create(catalog, schema, ['p'], {
+ 'deletion-vectors.enabled': 'true', 'row-tracking.enabled': 'true',
+ 'data-evolution.enabled': 'true'})
+ _append(table, [{'id': i, 'p': time(12, 34, 56, 120000)} for i in
range(2)], schema)
+ _delete(table, [0])
+ rows = _assert_parity(table)
+ assert [(row['partition'], row['deleted_record_count']) for row in rows]
== [('p=12:34:56', 1)]
+
+
[email protected]('legacy,instant,expected,deleted', [
+ (True, '2024-01-01T00:00:00+00:00', '2024-01-01 00:00:00.000', 0),
+ (False, '2024-01-01T00:00:00+00:00', '2023-12-31 19:00:00.000', 1),
+ (True, '2024-11-03T06:30:00+00:00', '2024-11-03 06:30:00.000', 0),
+ (False, '2024-11-03T06:30:00+00:00', '2024-11-03 01:30:00.000', 0),
+])
+def test_temporal_ltz_partition_specs_match_java_in_new_york(rest_catalog,
legacy, instant, expected, deleted):
+ from pypaimon.tests.deletion_vector_path_test import _delete
+ # Native caches the process system zone. CI runs this golden case in a
+ # separate process initialized with New York, as for temporal cast tests.
+ if os.environ.get('TZ') != 'America/New_York':
+ pytest.skip('Java golden values require TZ=America/New_York')
+ catalog, _ = rest_catalog
+ schema = pa.schema([('id', pa.int32()), ('p', pa.timestamp('ms',
tz='UTC'))])
+ table = _create(catalog, schema, ['p'], {
+ 'deletion-vectors.enabled': 'true', 'row-tracking.enabled': 'true',
+ 'data-evolution.enabled': 'true', 'partition.legacy-name':
str(legacy).lower()})
+ _append(table, [{'id': i, 'p': datetime.fromisoformat(instant)} for i in
range(2)], schema)
+ _delete(table, [0])
+ rows = _assert_parity(table)
+ assert [(row['partition'], row['deleted_record_count']) for row in rows]
== [('p=' + expected, deleted)]
+
+
[email protected]('native', [False, True])
+def test_partition_split_is_captured_before_later_commits(rest_catalog,
native):
+ catalog, _ = rest_catalog
+ schema = pa.schema([('id', pa.int32()), ('p', pa.string())])
+ table = _create(catalog, schema, ['p'])
+ _append(table, [{'id': 1, 'p': 'old'}], schema)
+ system = _system(table, 'partitions', native)
+ builder = system.new_read_builder()
+ splits = builder.new_scan().plan().splits()
+ _append(table, [{'id': 2, 'p': 'new'}], schema)
+ rows = builder.new_read().to_arrow(splits).to_pylist()
+ assert [row['partition'] for row in rows] == ['p=old']
+ assert len(_assert_parity(table)) == 2
diff --git a/paimon-python/pypaimon/tests/native_system_table_test.py
b/paimon-python/pypaimon/tests/native_system_table_test.py
index 7af10c298d..7d501ceae9 100644
--- a/paimon-python/pypaimon/tests/native_system_table_test.py
+++ b/paimon-python/pypaimon/tests/native_system_table_test.py
@@ -125,7 +125,7 @@ def test_projection_filter_limit_and_pickled_split(source):
assert builder.new_read().to_arrow(splits).to_pylist() == [{'snapshot_id':
1}, {'snapshot_id': 2}]
[email protected]('name', ['files', 'file_key_ranges', 'buckets',
'manifests', 'table_indexes'])
[email protected]('name', ['files', 'file_key_ranges', 'buckets',
'manifests', 'table_indexes', 'partitions'])
@pytest.mark.parametrize('view', ['first', 'empty', 'tag'])
def test_captured_metadata_view(source, name, view):
catalog, table, _ = source
@@ -191,8 +191,7 @@ def
test_native_reader_preserves_schema_on_empty_and_rejects_catalog_tables(sour
assert result.schema.names[16] == 'deleteRowCount'
assert result.num_rows == 0
assert [field.name() for field in read.read_type()] == result.schema.names
- with pytest.raises(Exception, match='requires a catalog|unknown'):
- native.new_system_read('partitions')
+ assert native.new_system_read('partitions').read().num_rows == 0
with pytest.raises(Exception, match='requires a catalog|unknown'):
native.new_system_read('unknown')
diff --git a/paimon-python/pypaimon/tests/system/native_system_table_test.py
b/paimon-python/pypaimon/tests/system/native_system_table_test.py
index 4383b9ae76..60dc35eedf 100644
--- a/paimon-python/pypaimon/tests/system/native_system_table_test.py
+++ b/paimon-python/pypaimon/tests/system/native_system_table_test.py
@@ -34,9 +34,9 @@ def _table(name='files', enabled=True):
row_type=lambda: RowType(False, [DataField(0, 'id', AtomicType('INT',
nullable=False))]))
[email protected]('reason', ['disabled', 'old-binding', 'custom-io',
'catalog-required'])
[email protected]('reason', ['disabled', 'old-binding', 'custom-io',
'unknown-table'])
def test_unsupported_metadata_reads_do_not_create_native_tables(reason):
- table = _table('partitions' if reason == 'catalog-required' else 'files',
enabled=reason != 'disabled')
+ table = _table('unknown' if reason == 'unknown-table' else 'files',
enabled=reason != 'disabled')
with
patch('pypaimon.table.system.native_system_table.native_method_available',
return_value=reason != 'old-binding'):
with
patch('pypaimon.table.system.native_system_table._rest_catalog_supported',
diff --git a/paimon-python/pypaimon/tests/system/partitions_table_test.py
b/paimon-python/pypaimon/tests/system/partitions_table_test.py
index 108cc6d358..ac3177fe22 100644
--- a/paimon-python/pypaimon/tests/system/partitions_table_test.py
+++ b/paimon-python/pypaimon/tests/system/partitions_table_test.py
@@ -124,7 +124,7 @@ class PartitionsTableTest(unittest.TestCase):
for count in arrow_table.column("file_count").to_pylist():
self.assertGreaterEqual(count, 1)
for total_buckets in arrow_table.column("total_buckets").to_pylist():
- self.assertGreaterEqual(total_buckets, 1)
+ self.assertEqual(total_buckets, -1)
# FileSystem catalog does not maintain a "done" flag.
for done in arrow_table.column("done").to_pylist():
self.assertFalse(done)
@@ -192,6 +192,29 @@ class PartitionsTableTest(unittest.TestCase):
self.assertEqual(3, deleted_counts["dt=2024-01-01"])
self.assertIsNone(deleted_counts["dt=2024-01-02"])
+ def test_creation_times_use_java_gap_and_overlap_resolution(self):
+ import os
+ import time
+ from pypaimon.table.system.system_table import metadata_epoch_millis,
metadata_local_millis
+ original = os.environ.get('TZ')
+ try:
+ os.environ['TZ'] = 'America/New_York'
+ time.tzset()
+ # 02:55 lies in the spring gap: Java shifts to 03:55 EDT, 07:55
UTC.
+ gap_wall = 1710039300123
+ self.assertEqual(1710057300123, metadata_epoch_millis(gap_wall))
+ self.assertEqual(1710042900123,
metadata_local_millis(metadata_epoch_millis(gap_wall)))
+ # 01:30 occurs twice in the fall overlap. Java chooses 05:30 UTC
(EDT).
+ overlap_wall = 1730597400456
+ self.assertEqual(1730611800456,
metadata_epoch_millis(overlap_wall))
+ self.assertEqual(overlap_wall,
metadata_local_millis(metadata_epoch_millis(overlap_wall)))
+ finally:
+ if original is None:
+ os.environ.pop('TZ', None)
+ else:
+ os.environ['TZ'] = original
+ time.tzset()
+
if __name__ == "__main__":
unittest.main()