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 5d019f5a6d [python] Read REST system tables with Native SystemRead
(#10604)
5d019f5a6d is described below
commit 5d019f5a6d94bff0187a96c94c9952b8e824bbde
Author: Jingsong Lee <[email protected]>
AuthorDate: Fri Oct 16 15:19:49 2026 +0800
[python] Read REST system tables with Native SystemRead (#10604)
---
.../pypaimon/table/system/branches_table.py | 4 +-
.../pypaimon/table/system/buckets_table.py | 32 +-
.../pypaimon/table/system/file_key_ranges_table.py | 18 +-
paimon-python/pypaimon/table/system/files_table.py | 223 ++++++-----
.../pypaimon/table/system/manifests_table.py | 4 +-
.../pypaimon/table/system/native_system_table.py | 46 +++
.../pypaimon/table/system/schemas_table.py | 4 +-
.../pypaimon/table/system/snapshots_table.py | 4 +-
.../pypaimon/table/system/system_table.py | 17 +
.../pypaimon/table/system/system_table_read.py | 2 +-
.../pypaimon/table/system/system_table_scan.py | 5 +-
.../pypaimon/table/system/table_indexes_table.py | 9 +-
paimon-python/pypaimon/table/system/tags_table.py | 4 +-
.../pypaimon/tests/native_merge_into_test.py | 5 +-
.../pypaimon/tests/native_system_table_test.py | 406 +++++++++++++++++++++
.../pypaimon/tests/system/branches_table_test.py | 5 +-
.../pypaimon/tests/system/buckets_table_test.py | 2 +-
.../pypaimon/tests/system/files_table_test.py | 114 +++++-
.../tests/system/native_system_table_test.py | 68 ++++
.../pypaimon/tests/system/snapshots_table_test.py | 3 +-
.../pypaimon/tests/system/tags_table_test.py | 3 +-
21 files changed, 813 insertions(+), 165 deletions(-)
diff --git a/paimon-python/pypaimon/table/system/branches_table.py
b/paimon-python/pypaimon/table/system/branches_table.py
index 227e30ba0b..78ef2694b6 100644
--- a/paimon-python/pypaimon/table/system/branches_table.py
+++ b/paimon-python/pypaimon/table/system/branches_table.py
@@ -23,7 +23,7 @@ import pyarrow
from pypaimon.branch.branch_manager import BranchManager
from pypaimon.schema.data_types import AtomicType, DataField, RowType
-from pypaimon.table.system.system_table import SystemTable
+from pypaimon.table.system.system_table import SystemTable,
metadata_local_millis
TABLE_TYPE = RowType(False, [
@@ -59,7 +59,7 @@ class BranchesTable(SystemTable):
# store cannot return an mtime (some remote object stores
# via PyArrowFileIO) fall back to epoch 0 so the schema
# contract holds.
- create_times.append(0 if ms is None else int(ms))
+ create_times.append(metadata_local_millis(0 if ms is None else ms))
return pyarrow.table({
"branch_name": pyarrow.array(names, type=pyarrow.string()),
"create_time": pyarrow.array(create_times, type=_TIMESTAMP_TYPE),
diff --git a/paimon-python/pypaimon/table/system/buckets_table.py
b/paimon-python/pypaimon/table/system/buckets_table.py
index ddd5e8c6c6..f3ae4f434e 100644
--- a/paimon-python/pypaimon/table/system/buckets_table.py
+++ b/paimon-python/pypaimon/table/system/buckets_table.py
@@ -24,7 +24,8 @@ import pyarrow
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_partition
+from pypaimon.table.system.system_table import SystemTable, metadata_snapshot
TABLE_TYPE = RowType(False, [
@@ -57,7 +58,7 @@ class BucketsTable(SystemTable):
return ["partition", "bucket"]
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()
@@ -80,11 +81,8 @@ class BucketsTable(SystemTable):
stats = bucket_map.get(key)
if stats is None:
- render_items = tuple(
- (name, str(val) if val is not _NULL else None)
- for name, val in raw_key)
stats = {
- "render_items": render_items,
+ "partition": _render_partition(entry.partition),
"bucket": bucket_id,
"record_count": 0,
"file_size_in_bytes": 0,
@@ -96,7 +94,7 @@ class BucketsTable(SystemTable):
stats["record_count"] += int(entry.file.row_count)
stats["file_size_in_bytes"] += int(entry.file.file_size)
stats["file_count"] += 1
- ct_ms = entry.file.creation_time_epoch_millis()
+ ct_ms = None if entry.file.creation_time is None else
entry.file.creation_time.get_millisecond()
if ct_ms is not None:
if (stats["last_update_time"] is None
or ct_ms > stats["last_update_time"]):
@@ -105,7 +103,7 @@ class BucketsTable(SystemTable):
sorted_keys = sorted(
bucket_map.keys(),
key=lambda k: (
- _render_partition(bucket_map[k]["render_items"]) or "",
+ bucket_map[k]["partition"] or "",
k[1]))
partition_strings: List[Optional[str]] = []
@@ -117,7 +115,7 @@ class BucketsTable(SystemTable):
for key in sorted_keys:
stats = bucket_map[key]
- partition_strings.append(_render_partition(stats["render_items"]))
+ partition_strings.append(stats["partition"])
buckets.append(stats["bucket"])
record_counts.append(stats["record_count"])
file_sizes.append(stats["file_size_in_bytes"])
@@ -147,19 +145,3 @@ class BucketsTable(SystemTable):
"file_count": pyarrow.array([], type=pyarrow.int64()),
"last_update_time": pyarrow.array([], type=_TIMESTAMP_TYPE),
})
-
-
-def _render_partition(spec_items) -> Optional[str]:
- """Render a partition spec as ``pt=v/pt2=v2`` or None when empty.
-
- Null partition values are rendered as ``__NULL__`` to distinguish them
- from the literal string ``"None"``. A partition whose value is
- literally ``"__NULL__"`` will produce the same rendered string —
- aggregation keys are still distinct, but the displayed partition
- column will collide. This is a display-only limitation.
- """
- if not spec_items:
- return None
- return "/".join(
- "{}={}".format(name, "__NULL__" if value is None else value)
- for name, value in spec_items)
diff --git a/paimon-python/pypaimon/table/system/file_key_ranges_table.py
b/paimon-python/pypaimon/table/system/file_key_ranges_table.py
index 290caae252..d6df0307c8 100644
--- a/paimon-python/pypaimon/table/system/file_key_ranges_table.py
+++ b/paimon-python/pypaimon/table/system/file_key_ranges_table.py
@@ -25,12 +25,11 @@ 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.files_table import (
- _render_key,
+ _data_file_format, _render_key,
_render_partition,
- _stringify_path,
+ _metadata_file_path,
)
-from pypaimon.table.system.system_table import SystemTable
-from pypaimon.utils.file_store_path_factory import canonical_data_file_path
+from pypaimon.table.system.system_table import SystemTable, metadata_snapshot
TABLE_TYPE = RowType(False, [
@@ -61,7 +60,7 @@ class FileKeyRangesTable(SystemTable):
return ["file_path"]
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 _empty_table()
@@ -71,7 +70,6 @@ class FileKeyRangesTable(SystemTable):
entries = manifest_file_manager.read_entries_parallel(
manifest_files, drop_stats=False)
- file_format = self.base_table.options.file_format()
rows = {
"partition": [],
"bucket": [],
@@ -90,12 +88,8 @@ class FileKeyRangesTable(SystemTable):
meta = entry.file
rows["partition"].append(_render_partition(entry.partition))
rows["bucket"].append(int(entry.bucket))
- rows["file_path"].append(_stringify_path(
- meta.external_path
- or meta.file_path
- or canonical_data_file_path(
- self.base_table, tuple(entry.partition.values),
int(entry.bucket), meta.file_name)))
- rows["file_format"].append(file_format)
+ rows["file_path"].append(_metadata_file_path(self.base_table,
entry))
+ rows["file_format"].append(_data_file_format(meta.file_name))
rows["schema_id"].append(int(meta.schema_id))
rows["level"].append(int(meta.level))
rows["record_count"].append(int(meta.row_count))
diff --git a/paimon-python/pypaimon/table/system/files_table.py
b/paimon-python/pypaimon/table/system/files_table.py
index fca9ab6d84..24e184d241 100644
--- a/paimon-python/pypaimon/table/system/files_table.py
+++ b/paimon-python/pypaimon/table/system/files_table.py
@@ -17,17 +17,15 @@
"""The ``$files`` system table — per-data-file detail of the latest
snapshot."""
-import json
from typing import Any, List, Optional
import pyarrow
from pypaimon.manifest.manifest_file_manager import ManifestFileManager
from pypaimon.manifest.manifest_list_manager import ManifestListManager
-from pypaimon.manifest.simple_stats_evolutions import SimpleStatsEvolutions
from pypaimon.schema.data_types import (ArrayType, AtomicType, DataField,
RowType)
-from pypaimon.table.system.system_table import SystemTable
+from pypaimon.table.system.system_table import SystemTable, metadata_snapshot
TABLE_TYPE = RowType(False, [
@@ -64,59 +62,103 @@ _TIMESTAMP_TYPE = pyarrow.timestamp("ms")
_WRITE_COLS_TYPE = pyarrow.list_(pyarrow.string())
-def _to_json(obj: Any) -> str:
- return json.dumps(obj, separators=(',', ':'), ensure_ascii=False,
- default=str)
-
-
-def _stringify_path(value: Any) -> str:
+def _render_value(value, data_type=None) -> str:
if value is None:
- return ""
- if isinstance(value, bytes):
- try:
- return value.decode("utf-8")
- except UnicodeDecodeError:
- return value.hex()
+ return "null"
+ if data_type is not None:
+ from pypaimon.casting.row_to_string import cast_value_to_string
+ from pypaimon.casting.numeric_utils import java_floating_string
+ type_name = str(getattr(data_type, 'type', data_type)).upper()
+ if type_name in ('FLOAT', 'REAL', 'DOUBLE'):
+ return java_floating_string(value, type_name != 'DOUBLE')
+ if type_name in ('BYTES', 'BINARY', 'VARBINARY') or
type_name.startswith(('BINARY(', 'VARBINARY(')):
+ from pypaimon.utils.file_store_path_factory import
_decode_java_utf8
+ return _decode_java_utf8(value)
+ if 'TIMESTAMP_LTZ' in type_name or 'WITH LOCAL TIME ZONE' in type_name:
+ from pypaimon.casting.temporal_string import format_timestamp_parts
+ from pypaimon.casting.temporal_utils import _epoch_day_to_date,
_local_offset
+ from pypaimon.table.row.generic_row import (
+ _datetime_to_millis_and_nanos, _normalize_ltz,
_parse_type_precision_scale)
+ # BinaryRow timestamps need not fit Arrow's i64 nanosecond range.
+ value = _normalize_ltz(value)
+ millis, nanos = _datetime_to_millis_and_nanos(value)
+ seconds = millis // 1000
+ day, second_of_day = divmod(seconds + _local_offset(seconds),
86400)
+ year, month, day = _epoch_day_to_date(day)
+ hour, second_of_day = divmod(second_of_day, 3600)
+ minute, second = divmod(second_of_day, 60)
+ nanos += millis % 1000 * 1000000 + getattr(value, 'nanosecond', 0)
+ precision, _ = _parse_type_precision_scale(data_type)
+ return format_timestamp_parts(year, month, day, hour, minute,
second, nanos, precision)
+ return cast_value_to_string(value, data_type)
+ if isinstance(value, bool):
+ return 'true' if value else 'false'
return str(value)
-def _to_python(value: Any) -> Any:
- """Render an internal-row cell value into a JSON-safe primitive."""
+def _render_object(value, data_type=None) -> str:
+ # Java InternalRowUtils.get returns integer DATE/TIME and a Timestamp
+ # whose toString is ISO LocalDateTime, including for LTZ key/stats cells.
if value is None:
- return None
- if isinstance(value, bytes):
- try:
- return value.decode("utf-8")
- except UnicodeDecodeError:
- return value.hex()
- if hasattr(value, "isoformat"):
- try:
- return value.isoformat()
- except Exception:
- return str(value)
- if isinstance(value, (int, float, bool, str, list, dict)):
- return value
- return str(value)
+ return 'null'
+ type_name = str(getattr(data_type, 'type', data_type)).upper()
+ if type_name == 'DATE':
+ from datetime import date
+ return str((value - date(1970, 1, 1)).days)
+ if type_name.startswith('TIME') and not type_name.startswith('TIMESTAMP'):
+ return str(((value.hour * 60 + value.minute) * 60 + value.second) *
1000 + value.microsecond // 1000)
+ if type_name.startswith('TIMESTAMP'):
+ from datetime import timezone
+ if value.tzinfo is not None:
+ value = value.astimezone(timezone.utc).replace(tzinfo=None)
+ nanos = value.microsecond * 1000 + getattr(value, 'nanosecond', 0)
+ text = '{:04d}-{:02d}-{:02d}T{:02d}:{:02d}'.format(
+ value.year, value.month, value.day, value.hour, value.minute)
+ if value.second or nanos:
+ text += ':{:02d}'.format(value.second)
+ if nanos:
+ precision = 3 if nanos % 1000000 == 0 else 6 if nanos % 1000 == 0
else 9
+ text += '.' + '{:09d}'.format(nanos)[:precision]
+ return text
+ return _render_value(value, data_type)
def _render_key(row) -> Optional[str]:
- if row is None:
- return None
- values = getattr(row, "values", None)
- if not values:
+ if row is None or not len(row):
return None
- return _to_json([_to_python(v) for v in values])
+ fields = getattr(row, 'fields', [])
+ return '[' + ', '.join(_render_object(row.get_field(i), fields[i].type)
for i in range(len(row))) + ']'
def _render_partition(partition_row) -> Optional[str]:
if partition_row is None:
return None
- fields = getattr(partition_row, "fields", None) or []
- values = getattr(partition_row, "values", None) or []
- if not fields:
- return None
- return "/".join("{}={}".format(field.name, value)
- for field, value in zip(fields, values))
+ fields = getattr(partition_row, 'fields', [])
+ return '{' + ', '.join(_render_value(partition_row.get_field(i),
field.type)
+ for i, field in enumerate(fields)) + '}'
+
+
+def _metadata_file_path(table, entry):
+ if entry.file.external_path is not None:
+ return entry.file.external_path
+ # Display Java's logical bucket path; the IO path may have decoded URI
+ # escapes or dropped file: to reach a literal escaped local directory.
+ from pypaimon.utils.path import resolve_path
+ relative_bucket = table.path_factory().relative_bucket_path(
+ tuple(entry.partition.values), int(entry.bucket),
canonical_partition=True)
+ bucket = resolve_path(str(table.table_path), relative_bucket)
+ return bucket.rstrip('/') + '/' + entry.file.file_name
+
+
+def _data_file_format(file_name):
+ parts = file_name.rsplit('.', 2)
+ if len(parts) < 2:
+ raise ValueError('{} is not a legal file name'.format(file_name))
+ if parts[-1].lower() in ('gz', 'bz2', 'deflate', 'snappy', 'lz4', 'zst'):
+ if len(parts) < 3:
+ raise ValueError('{} is not a legal file name'.format(file_name))
+ return parts[-2]
+ return parts[-1]
def _row_values(row) -> List[Any]:
@@ -129,23 +171,44 @@ def _stats_fields(stats_row) -> List[str]:
return [field.name for field in stats_row.fields]
-def _render_stats_map(values: List[Any], columns: List[str]) -> str:
- pairs = {}
- n = min(len(columns), len(values) if values is not None else 0)
- for i in range(n):
- pairs[columns[i]] = _to_python(values[i])
- return _to_json(pairs)
-
-
-def _render_null_counts(null_counts: Optional[List[int]],
- columns: List[str]) -> str:
- pairs = {}
- if null_counts:
- n = min(len(columns), len(null_counts))
- for i in range(n):
- pairs[columns[i]] = (None if null_counts[i] is None
- else int(null_counts[i]))
- return _to_json(pairs)
+def _render_stats_map(values: List[Any], columns: List[str], fields=None) ->
str:
+ values = values or []
+ pairs = {name: _render_object(values[i] if i < len(values) else None,
+ fields[i].type if fields else None)
+ for i, name in enumerate(columns)}
+ return '{' + ', '.join(name + '=' + pairs[name] for name in sorted(pairs))
+ '}'
+
+
+def _render_null_counts(null_counts: Optional[List[int]], columns: List[str])
-> str:
+ return _render_stats_map(null_counts, columns)
+
+
+def _file_stats(table, meta):
+ from pypaimon.casting.schema_evolution import
cast_array_for_schema_evolution
+ from pypaimon.schema.data_types import PyarrowFieldParser
+ file_schema = table.schema_manager.get_schema(meta.schema_id)
+ physical = {field.id: field for field in
file_schema.data_file_fields(meta.write_cols)}
+ stats = meta.value_stats
+ names = _stats_fields(stats.min_values)
+ nulls, lower, upper = [], [], []
+ for field in table.fields:
+ source = physical.get(field.id)
+ if source is None:
+ nulls.append(meta.row_count)
+ lower.append(None)
+ upper.append(None)
+ continue
+ index = names.index(source.name) if source.name in names else None
+ nulls.append(stats.null_counts[index] if index is not None and
stats.null_counts is not None
+ and index < len(stats.null_counts) else None)
+ for row, output in ((stats.min_values, lower), (stats.max_values,
upper)):
+ value = row.get_field(index) if index is not None and index <
len(row) else None
+ if value is not None and source.type != field.type:
+ value = cast_array_for_schema_evolution(
+ pyarrow.array([value],
type=PyarrowFieldParser.from_paimon_type(source.type)),
+ PyarrowFieldParser.from_paimon_type(field.type),
source.type, field.type)[0].as_py()
+ output.append(value)
+ return nulls, lower, upper
class FilesTable(SystemTable):
@@ -161,7 +224,7 @@ class FilesTable(SystemTable):
return ["file_path"]
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()
@@ -171,11 +234,6 @@ class FilesTable(SystemTable):
entries = manifest_file_manager.read_entries_parallel(
manifest_files, drop_stats=False)
- file_format = self.base_table.options.file_format()
- stats_evolutions = SimpleStatsEvolutions(
- self._schema_fields, self.base_table.table_schema.id)
- stats_columns = [field.name for field in stats_evolutions.table_fields]
-
rows = {
"partition": [],
"bucket": [],
@@ -203,9 +261,8 @@ class FilesTable(SystemTable):
meta = entry.file
rows["partition"].append(_render_partition(entry.partition))
rows["bucket"].append(int(entry.bucket))
- rows["file_path"].append(_stringify_path(
- meta.file_path or meta.file_name))
- rows["file_format"].append(file_format)
+ rows["file_path"].append(_metadata_file_path(self.base_table,
entry))
+ rows["file_format"].append(_data_file_format(meta.file_name))
rows["schema_id"].append(int(meta.schema_id))
rows["level"].append(int(meta.level))
rows["record_count"].append(int(meta.row_count))
@@ -213,28 +270,22 @@ class FilesTable(SystemTable):
rows["min_key"].append(_render_key(meta.min_key))
rows["max_key"].append(_render_key(meta.max_key))
- # Evolve stats to the current schema by field id, so a dropped or
- # re-added column never inherits another column's stats.
- value_stats =
stats_evolutions.get_or_create(meta.schema_id).evolution(
- meta.value_stats, meta.row_count,
- _stats_fields(meta.value_stats.min_values))
- rows["null_value_counts"].append(
- _render_null_counts(value_stats.null_counts, stats_columns))
- rows["min_value_stats"].append(_render_stats_map(
- _row_values(value_stats.min_values),
- stats_columns))
- rows["max_value_stats"].append(_render_stats_map(
- _row_values(value_stats.max_values),
- stats_columns))
+ nulls, lower, upper = _file_stats(self.base_table, meta)
+ columns = [field.name for field in self.base_table.fields]
+ rows["null_value_counts"].append(_render_null_counts(nulls,
columns))
+ rows["min_value_stats"].append(_render_stats_map(lower, columns,
self.base_table.fields))
+ rows["max_value_stats"].append(_render_stats_map(upper, columns,
self.base_table.fields))
rows["min_sequence_number"].append(int(meta.min_sequence_number))
rows["max_sequence_number"].append(int(meta.max_sequence_number))
- rows["creation_time"].append(meta.creation_time_epoch_millis())
+ rows["creation_time"].append(
+ None if meta.creation_time is None else
meta.creation_time.get_millisecond())
rows["deleteRowCount"].append(
None if meta.delete_row_count is None
else int(meta.delete_row_count))
rows["file_source"].append(
- None if meta.file_source is None else str(meta.file_source))
+ None if meta.file_source is None else
+ {0: "APPEND", 1: "COMPACT"}.get(meta.file_source,
str(meta.file_source)))
rows["first_row_id"].append(
None if meta.first_row_id is None
else int(meta.first_row_id))
@@ -280,12 +331,6 @@ class FilesTable(SystemTable):
rows["write_cols"], type=_WRITE_COLS_TYPE),
})
- def _schema_fields(self, schema_id: int) -> List[DataField]:
- table_schema = self.base_table.table_schema
- if schema_id == table_schema.id:
- return table_schema.fields
- return self.base_table.schema_manager.get_schema(schema_id).fields
-
@staticmethod
def _empty_table() -> pyarrow.Table:
return pyarrow.table({
diff --git a/paimon-python/pypaimon/table/system/manifests_table.py
b/paimon-python/pypaimon/table/system/manifests_table.py
index a2bcb90877..785261c832 100644
--- a/paimon-python/pypaimon/table/system/manifests_table.py
+++ b/paimon-python/pypaimon/table/system/manifests_table.py
@@ -24,7 +24,7 @@ import pyarrow
from pypaimon.casting.row_to_string import cast_row_to_string
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.system_table import SystemTable, metadata_snapshot
TABLE_TYPE = RowType(False, [
@@ -53,7 +53,7 @@ class ManifestsTable(SystemTable):
return ["file_name"]
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()
diff --git a/paimon-python/pypaimon/table/system/native_system_table.py
b/paimon-python/pypaimon/table/system/native_system_table.py
new file mode 100644
index 0000000000..0123576b69
--- /dev/null
+++ b/paimon-python/pypaimon/table/system/native_system_table.py
@@ -0,0 +1,46 @@
+# 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.
+
+"""Materialize REST metadata through the Rust core system reader."""
+
+import pyarrow
+
+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',
+))
+
+
+def try_read_native_system_table(system_table):
+ base = system_table.base_table
+ name = system_table.system_table_name()
+ if (name not in _NATIVE_TABLES or not base.options.native_read_enabled()
+ or not native_method_available('Table', 'new_system_read')
+ or not _rest_catalog_supported(base)):
+ return None
+ # Capture the full result for the existing in-memory SystemSplit. Reading
+ # or pickling that split never recreates REST credentials or file IO.
+ batch = _native_table(base).new_system_read(name).read()
+ table = pyarrow.Table.from_batches([batch])
+ schema =
PyarrowFieldParser.from_paimon_schema(system_table.row_type().fields)
+ return table.cast(schema)
diff --git a/paimon-python/pypaimon/table/system/schemas_table.py
b/paimon-python/pypaimon/table/system/schemas_table.py
index 7b917be4e3..77f8fb597e 100644
--- a/paimon-python/pypaimon/table/system/schemas_table.py
+++ b/paimon-python/pypaimon/table/system/schemas_table.py
@@ -23,7 +23,7 @@ from typing import Any, List, Optional
import pyarrow
from pypaimon.schema.data_types import AtomicType, DataField, RowType
-from pypaimon.table.system.system_table import SystemTable
+from pypaimon.table.system.system_table import SystemTable,
metadata_local_millis
TABLE_TYPE = RowType(False, [
@@ -82,7 +82,7 @@ class SchemasTable(SystemTable):
primary_keys_jsons.append(_to_json(list(schema.primary_keys)))
options_jsons.append(_to_json(dict(schema.options)))
comments.append(schema.comment)
- update_times.append(int(schema.time_millis))
+ update_times.append(metadata_local_millis(schema.time_millis))
return pyarrow.table({
"schema_id": pyarrow.array(schema_ids, type=pyarrow.int64()),
diff --git a/paimon-python/pypaimon/table/system/snapshots_table.py
b/paimon-python/pypaimon/table/system/snapshots_table.py
index d32c36163c..ee4302ec6a 100644
--- a/paimon-python/pypaimon/table/system/snapshots_table.py
+++ b/paimon-python/pypaimon/table/system/snapshots_table.py
@@ -22,7 +22,7 @@ from typing import List, Optional
import pyarrow
from pypaimon.schema.data_types import AtomicType, DataField, RowType
-from pypaimon.table.system.system_table import SystemTable
+from pypaimon.table.system.system_table import SystemTable,
metadata_local_millis
TABLE_TYPE = RowType(False, [
@@ -85,7 +85,7 @@ class SnapshotsTable(SystemTable):
commit_users.append(snap.commit_user)
commit_identifiers.append(int(snap.commit_identifier))
commit_kinds.append(snap.commit_kind)
- commit_times.append(int(snap.time_millis))
+ commit_times.append(metadata_local_millis(snap.time_millis))
base_manifest_lists.append(snap.base_manifest_list)
delta_manifest_lists.append(snap.delta_manifest_list)
changelog_manifest_lists.append(snap.changelog_manifest_list)
diff --git a/paimon-python/pypaimon/table/system/system_table.py
b/paimon-python/pypaimon/table/system/system_table.py
index 7ace7f23f3..a4f2c11c82 100644
--- a/paimon-python/pypaimon/table/system/system_table.py
+++ b/paimon-python/pypaimon/table/system/system_table.py
@@ -41,6 +41,23 @@ if TYPE_CHECKING: # pragma: no cover - import for type
hints only
from pypaimon.table.system.system_table_scan import SystemTableScan
+def metadata_local_millis(millis):
+ """Java DateTimeUtils.toLocalDateTime for an epoch-millisecond instant."""
+ from pypaimon.casting.temporal_utils import _local_offset, _signed
+ return _signed(int(millis) + _local_offset(int(millis) // 1000) * 1000, 64)
+
+
+def metadata_snapshot(table):
+ """Use the table's captured view, including an explicitly empty
snapshot."""
+ if '_read_snapshot' in vars(table):
+ return table._read_snapshot
+ from pypaimon.common.options.options import Options
+ from pypaimon.snapshot.time_travel_util import TimeTravelUtil
+ snapshot = TimeTravelUtil.try_travel_to_snapshot(
+ Options(table.table_schema.options), table.tag_manager(),
table.snapshot_manager())
+ return snapshot if snapshot is not None else
table.snapshot_manager().get_latest_snapshot()
+
+
_READ_ONLY_MESSAGE = "System table is read-only"
diff --git a/paimon-python/pypaimon/table/system/system_table_read.py
b/paimon-python/pypaimon/table/system/system_table_read.py
index e33717227e..e7f4c81736 100644
--- a/paimon-python/pypaimon/table/system/system_table_read.py
+++ b/paimon-python/pypaimon/table/system/system_table_read.py
@@ -145,7 +145,7 @@ class SystemTableRead:
combined = pyarrow.concat_tables(slices)
if self.limit is not None and combined.num_rows > self.limit:
combined = combined.slice(0, self.limit)
- return combined
+ return
combined.cast(PyarrowFieldParser.from_paimon_schema(self.read_type))
def _reject_non_scalar_filter_fields(self, predicate: Predicate) -> None:
"""Reject value comparisons on nested system-table columns.
diff --git a/paimon-python/pypaimon/table/system/system_table_scan.py
b/paimon-python/pypaimon/table/system/system_table_scan.py
index 5bc43302a9..b6d1f64025 100644
--- a/paimon-python/pypaimon/table/system/system_table_scan.py
+++ b/paimon-python/pypaimon/table/system/system_table_scan.py
@@ -70,5 +70,8 @@ class SystemTableScan:
self.system_table = system_table
def plan(self) -> Plan:
- arrow_table = self.system_table._build_arrow_table()
+ from pypaimon.table.system.native_system_table import
try_read_native_system_table
+ arrow_table = try_read_native_system_table(self.system_table)
+ if arrow_table is None:
+ arrow_table = self.system_table._build_arrow_table()
return Plan(_splits=[SystemSplit(arrow_table)])
diff --git a/paimon-python/pypaimon/table/system/table_indexes_table.py
b/paimon-python/pypaimon/table/system/table_indexes_table.py
index 4bf2103f0b..f50bf2cf39 100644
--- a/paimon-python/pypaimon/table/system/table_indexes_table.py
+++ b/paimon-python/pypaimon/table/system/table_indexes_table.py
@@ -25,7 +25,7 @@ from pypaimon.index.index_file_handler import IndexFileHandler
from pypaimon.schema.data_types import (ArrayType, AtomicType, DataField,
RowType)
from pypaimon.table.system.files_table import _render_partition
-from pypaimon.table.system.system_table import SystemTable
+from pypaimon.table.system.system_table import SystemTable, metadata_snapshot
_DV_RANGE_ROW_TYPE = RowType(False, [
@@ -73,8 +73,9 @@ class TableIndexesTable(SystemTable):
return ["file_name"]
def _build_arrow_table(self) -> pyarrow.Table:
- snapshot = self.base_table.snapshot_manager().get_latest_snapshot()
- entries = IndexFileHandler(self.base_table).scan(snapshot)
+ snapshot = metadata_snapshot(self.base_table)
+ # scan(None) means latest; a resolved empty view must stay empty.
+ entries = [] if snapshot is None else
IndexFileHandler(self.base_table).scan(snapshot)
rows = {
"partition": [],
@@ -139,7 +140,7 @@ class TableIndexesTable(SystemTable):
def _render_dv_ranges(dv_ranges) -> Optional[List[Dict[str, object]]]:
- if not dv_ranges:
+ if dv_ranges is None:
return None
rendered = []
for meta in dv_ranges.values():
diff --git a/paimon-python/pypaimon/table/system/tags_table.py
b/paimon-python/pypaimon/table/system/tags_table.py
index dc9bc18636..cc9bd23381 100644
--- a/paimon-python/pypaimon/table/system/tags_table.py
+++ b/paimon-python/pypaimon/table/system/tags_table.py
@@ -23,7 +23,7 @@ import pyarrow
from pypaimon.common.time_utils import duration_to_iso8601,
local_datetime_to_millis
from pypaimon.schema.data_types import AtomicType, DataField, RowType
-from pypaimon.table.system.system_table import SystemTable
+from pypaimon.table.system.system_table import SystemTable,
metadata_local_millis
TABLE_TYPE = RowType(False, [
@@ -70,7 +70,7 @@ class TagsTable(SystemTable):
names.append(name)
snapshot_ids.append(int(tag.id))
schema_ids.append(int(tag.schema_id))
- commit_times.append(int(tag.time_millis))
+ commit_times.append(metadata_local_millis(tag.time_millis))
record_counts.append(
None if tag.total_record_count is None
else int(tag.total_record_count))
diff --git a/paimon-python/pypaimon/tests/native_merge_into_test.py
b/paimon-python/pypaimon/tests/native_merge_into_test.py
index 738cf43cea..ac764e90b7 100644
--- a/paimon-python/pypaimon/tests/native_merge_into_test.py
+++ b/paimon-python/pypaimon/tests/native_merge_into_test.py
@@ -496,7 +496,10 @@ def
test_python_written_partition_source_uses_java_paths_for_native_merge(tmp_pa
assert _rows(source) == values
ranges = catalog.get_table('db.source$file_key_ranges').new_read_builder()
reported =
ranges.new_read().to_arrow(ranges.new_scan().plan().splits())['file_path'].to_pylist()
- assert reported and all(source.file_io.exists(path) for path in reported)
+ from pypaimon.utils.path import to_file_io_path
+ # System tables display Java logical paths; preserve literal escaped
+ # components when passing these strings into Python's URI-based FileIO.
+ assert reported and all(source.file_io.exists(to_file_io_path(path)) for
path in reported)
assert all('pa%23rt=' in path for path in reported)
builder = target.new_stream_write_builder() if stream else
target.new_batch_write_builder()
kwargs = dict(commit_identifier=57) if stream else {}
diff --git a/paimon-python/pypaimon/tests/native_system_table_test.py
b/paimon-python/pypaimon/tests/native_system_table_test.py
new file mode 100644
index 0000000000..7af10c298d
--- /dev/null
+++ b/paimon-python/pypaimon/tests/native_system_table_test.py
@@ -0,0 +1,406 @@
+# 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.
+
+"""REST system-table reads use Rust metadata providers with Java semantics."""
+
+import json
+import pickle
+from unittest.mock import patch
+
+import pyarrow as pa
+import pytest
+
+from pypaimon import Schema
+from pypaimon.common.predicate_builder import PredicateBuilder
+from pypaimon.read.native_plan import _native_table, native_method_available
+from pypaimon.schema.data_types import AtomicType
+from pypaimon.schema.schema_change import SchemaChange
+from pypaimon.table.system.native_system_table import _NATIVE_TABLES
+from pypaimon.table.system.system_table_loader import load
+from pypaimon.tests import native_plan_rest_test
+
+rest_catalog = native_plan_rest_test.rest_catalog
+pytestmark = pytest.mark.skipif(
+ not native_method_available('Table', 'new_system_read'),
+ reason='Native system-table reader required')
+
+SCHEMA = pa.schema([('id', pa.int32()), ('value', pa.string()), ('pt',
pa.string())])
+
+
[email protected]
+def source(rest_catalog):
+ catalog, server = rest_catalog
+ catalog.create_table('default.metadata', Schema.from_pyarrow_schema(
+ SCHEMA, partition_keys=['pt'], options={
+ 'file.format': 'parquet', 'metadata.stats-mode': 'full',
+ 'write.native.enabled': 'false', 'commit.native.enabled': 'false',
+ }), False)
+ table = catalog.get_table('default.metadata')
+ return catalog, table, server
+
+
+def _append(table, rows):
+ builder = table.copy({'write.native.enabled':
'false'}).new_batch_write_builder()
+ writer, commit = builder.new_write(), builder.new_commit()
+ try:
+ writer.write_arrow(pa.Table.from_pylist(rows,
schema=SCHEMA).select(table.field_names))
+ commit.commit(writer.prepare_commit())
+ finally:
+ writer.close()
+ commit.close()
+
+
+def _system(table, name, native):
+ return load(name, table.copy({'read.native.enabled': str(native).lower()}))
+
+
+def _read(table, projection=None, predicate=None, limit=None):
+ builder = table.new_read_builder()
+ if projection is not None:
+ builder.with_projection(projection)
+ if predicate is not None:
+ builder.with_filter(predicate)
+ if limit is not None:
+ builder.with_limit(limit)
+ return builder.new_read().to_arrow(builder.new_scan().plan().splits())
+
+
+def _canonical(table, name):
+ rows = table.to_pylist()
+ if name == 'schemas':
+ for row in rows:
+ for field in ('fields', 'partition_keys', 'primary_keys',
'options'):
+ row[field] = json.loads(row[field])
+ return sorted(json.dumps(row, sort_keys=True, default=str) for row in rows)
+
+
[email protected]('name', sorted(_NATIVE_TABLES))
[email protected]('empty', [True, False])
+def test_rest_system_table_parity_and_no_python_materialization(source, name,
empty):
+ catalog, table, _ = source
+ if not empty:
+ _append(table, [{'id': 1, 'value': 'old', 'pt': 'a'},
+ {'id': 2, 'value': None, 'pt': None}])
+ table.create_tag('first', snapshot_id=1, time_retained='1d')
+ catalog.create_tag(table.identifier, 'first', 1)
+ catalog.create_branch(table.identifier, 'dev', tag_name='first')
+ from pypaimon.consumer.consumer import Consumer
+ table.consumer_manager().reset_consumer('reader', Consumer(2))
+ expected = _read(_system(table, name, False))
+ native = _system(table, name, True)
+ with patch.object(type(native), '_build_arrow_table',
+ side_effect=AssertionError('Python metadata
materialized')):
+ actual = _read(native)
+ if not empty and name in ('tags', 'branches', 'consumers'):
+ assert actual.num_rows > 0
+ assert actual.schema == expected.schema
+ assert _canonical(actual, name) == _canonical(expected, name)
+
+
+def test_projection_filter_limit_and_pickled_split(source):
+ _, table, _ = source
+ _append(table, [{'id': 1, 'value': 'a', 'pt': 'old'}])
+ _append(table, [{'id': 2, 'value': 'b', 'pt': 'new'}])
+ system = _system(table, 'snapshots', True)
+ predicate =
PredicateBuilder(system.row_type().fields).greater_than('snapshot_id', 1)
+ assert _read(system, ['snapshot_id'], predicate, 1).to_pylist() ==
[{'snapshot_id': 2}]
+ assert _read(system, ['snapshot_id'], limit=0).num_rows == 0
+ builder = system.new_read_builder().with_projection(['snapshot_id'])
+ splits = pickle.loads(pickle.dumps(builder.new_scan().plan().splits()))
+ _append(table, [{'id': 3, 'value': 'c', 'pt': 'later'}])
+ 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]('view', ['first', 'empty', 'tag'])
+def test_captured_metadata_view(source, name, view):
+ catalog, table, _ = source
+ _append(table, [{'id': 1, 'value': 'a', 'pt': 'old'}])
+ first = table.snapshot_manager().get_latest_snapshot()
+ table.create_tag('first', snapshot_id=1)
+ catalog.create_tag(table.identifier, 'first', 1)
+ _append(table, [{'id': 2, 'value': 'b', 'pt': 'new'}])
+ if view == 'tag':
+ table = table.copy({'scan.tag-name': 'first'})
+ else:
+ table = table._copy_with_snapshot(first if view == 'first' else None)
+ expected = _read(_system(table, name, False))
+ actual = _read(_system(table, name, True))
+ assert _canonical(actual, name) == _canonical(expected, name)
+ if view == 'empty':
+ assert actual.num_rows == 0
+ elif name in ('files', 'file_key_ranges'):
+ assert actual.column('partition').to_pylist() == ['{old}']
+
+
+def test_options_use_latest_persisted_schema(source):
+ catalog, table, _ = source
+ _append(table, [{'id': 1, 'value': 'a', 'pt': 'a'}])
+ catalog.alter_table(table.identifier,
[SchemaChange.set_option('metadata.marker', 'persisted')], False)
+ table = table.copy({'scan.snapshot-id': '1', 'metadata.marker': 'dynamic'})
+ for native in (False, True):
+ result = _read(_system(table, 'options', native)).to_pylist()
+ options = {row['key']: row['value'] for row in result}
+ assert options['metadata.marker'] == 'persisted'
+ assert 'scan.snapshot-id' not in options
+
+
+def test_stats_follow_readded_column_ids(source):
+ catalog, table, _ = source
+ _append(table, [{'id': 1, 'value': 'old', 'pt': 'a'}])
+ catalog.alter_table(table.identifier, [SchemaChange.drop_column('value')],
False)
+ catalog.alter_table(table.identifier, [SchemaChange.add_column('value',
AtomicType('STRING'))], False)
+ table = catalog.get_table(table.identifier)
+ _append(table, [{'id': 2, 'value': 'new', 'pt': 'a'}])
+ for native in (False, True):
+ rows = sorted(_read(_system(table, 'files', native)).to_pylist(),
key=lambda row: row['schema_id'])
+ assert rows[0]['min_value_stats'] == '{id=1, pt=a, value=null}'
+ assert rows[0]['null_value_counts'] == '{id=0, pt=0, value=1}'
+ assert rows[1]['min_value_stats'] == '{id=2, pt=a, value=new}'
+
+
+def test_native_error_is_not_retried_in_python(source):
+ _, table, _ = source
+ system = _system(table, 'files', True)
+ with patch('pypaimon.table.system.native_system_table._native_table',
side_effect=RuntimeError('denied')):
+ with patch.object(type(system), '_build_arrow_table') as fallback:
+ with pytest.raises(RuntimeError, match='denied'):
+ _read(system)
+ fallback.assert_not_called()
+
+
+def
test_native_reader_preserves_schema_on_empty_and_rejects_catalog_tables(source):
+ _, table, _ = source
+ native = _native_table(table)
+ read = native.new_system_read('files')
+ result = read.read()
+ 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')
+ with pytest.raises(Exception, match='requires a catalog|unknown'):
+ native.new_system_read('unknown')
+
+
+def test_writer_version_is_loaded_from_snapshot_metadata(source):
+ _, table, _ = source
+ _append(table, [{'id': 1, 'value': 'a', 'pt': 'a'}])
+ path = table.snapshot_manager().snapshot_dir + '/snapshot-1'
+ with table.file_io.new_input_stream(path) as stream:
+ snapshot = json.loads(stream.read())
+ snapshot['writerVersion'] = 'Java test writer'
+ table.file_io.overwrite_file_utf8(path, json.dumps(snapshot))
+ assert _read(_system(table, 'snapshots', True),
['writer_version']).to_pylist() == [
+ {'writer_version': 'Java test writer'}]
+
+
[email protected]('name', ['files', 'file_key_ranges', 'buckets',
'snapshots', 'schemas', 'consumers'])
+def test_branch_namespace(source, name):
+ from pypaimon.common.identifier import Identifier
+ from pypaimon.consumer.consumer import Consumer
+ catalog, table, _ = source
+ _append(table, [{'id': 1, 'value': 'main', 'pt': 'a'}])
+ table.create_tag('first', snapshot_id=1)
+ catalog.create_tag(table.identifier, 'first', 1)
+ catalog.create_branch(table.identifier, 'dev', tag_name='first')
+ branch = catalog.get_table(Identifier('default', 'metadata', branch='dev'))
+ branch.consumer_manager().reset_consumer('branch-reader', Consumer(17))
+ table.consumer_manager().reset_consumer('main-reader', Consumer(91))
+ actual = _read(_system(branch, name, True))
+ expected = _read(_system(branch, name, False))
+ assert _canonical(actual, name) == _canonical(expected, name)
+ if name == 'consumers':
+ assert actual.to_pylist() == [{'consumer_id': 'branch-reader',
'next_snapshot_id': 17}]
+
+
+def test_vertical_file_statistics_use_physical_columns(rest_catalog):
+ catalog, _ = rest_catalog
+ schema = pa.schema([('id', pa.int32()), ('value', pa.int32())])
+ catalog.create_table('default.vertical',
Schema.from_pyarrow_schema(schema, options={
+ 'row-tracking.enabled': 'true', 'data-evolution.enabled': 'true',
+ 'metadata.stats-mode': 'full', 'write.native.enabled': 'false',
+ 'commit.native.enabled': 'false', 'file.format': 'parquet',
+ }), False)
+ table = catalog.get_table('default.vertical')
+ builder = table.new_batch_write_builder()
+ left = builder.new_write().with_write_type(['id'])
+ right = builder.new_write().with_write_type(['value'])
+ commit = builder.new_commit()
+ try:
+ left.write_arrow(pa.table({'id': pa.array([1, 2], type=pa.int32())}))
+ right.write_arrow(pa.table({'value': pa.array([41, 42],
type=pa.int32())}))
+ commit.commit(left.prepare_commit() + right.prepare_commit())
+ finally:
+ left.close()
+ right.close()
+ commit.close()
+ actual = _read(_system(table, 'files', True))
+ expected = _read(_system(table, 'files', False))
+ assert _canonical(actual, 'files') == _canonical(expected, 'files')
+ stats = {tuple(row['write_cols']): row for row in actual.to_pylist()}
+ assert stats[('id',)]['min_value_stats'] == '{id=1, value=null}'
+ assert stats[('value',)]['min_value_stats'] == '{id=null, value=41}'
+ assert stats[('value',)]['null_value_counts'] == '{id=2, value=0}'
+
+
+def test_primary_key_ranges_are_java_arrays(rest_catalog):
+ catalog, _ = rest_catalog
+ catalog.create_table('default.keyed', Schema.from_pyarrow_schema(
+ SCHEMA, partition_keys=['pt'], primary_keys=['pt', 'id'], options={
+ 'bucket': '1', 'file.format': 'parquet', 'write.native.enabled':
'false',
+ 'commit.native.enabled': 'false'}), False)
+ table = catalog.get_table('default.keyed')
+ _append(table, [{'id': 2, 'value': 'b', 'pt': 'a'}, {'id': 1, 'value':
'a', 'pt': 'a'}])
+ for name in ('files', 'file_key_ranges'):
+ actual, expected = _read(_system(table, name, True)),
_read(_system(table, name, False))
+ assert _canonical(actual, name) == _canonical(expected, name)
+ assert actual.column('min_key').to_pylist() == ['[1]']
+ assert actual.column('max_key').to_pylist() == ['[2]']
+
+
+def test_nonempty_table_indexes(rest_catalog):
+ from pypaimon.tests.native_sorted_index_build_test import _create, _append
as append, _build, _commit
+ catalog, _ = rest_catalog
+ table = _create(catalog)
+ append(table, [{'id': 1, 'name': 'a', 'pt': 0}, {'id': 2, 'name': 'b',
'pt': 0}])
+ _commit(table, _build(table, 'btree'))
+ actual = _read(_system(table, 'table_indexes', True))
+ expected = _read(_system(table, 'table_indexes', False))
+ assert _canonical(actual, 'table_indexes') == _canonical(expected,
'table_indexes')
+ rows = actual.to_pylist()
+ assert rows and rows[0]['index_field_name'] == 'name'
+ # Populate an index before fixing the view to empty: otherwise both paths
+ # return no indexes even if the Python reader incorrectly reloads latest.
+ empty = table._copy_with_snapshot(None)
+ for native in (False, True):
+ result = _read(_system(empty, 'table_indexes', native))
+ assert result.num_rows == 0
+ assert result.schema == actual.schema
+
+
+def test_native_metadata_auth_guard(source):
+ _, table, _ = source
+ denied = _native_table(table.copy({'query-auth.enabled': 'true'}))
+ with pytest.raises(Exception, match='query-auth.enabled'):
+ denied.new_system_read('options')
+
+
[email protected]('keyed', [False, True])
+def test_metadata_temporal_cells_use_java_object_strings(rest_catalog, keyed):
+ from datetime import date, datetime, time, timezone
+ catalog, _ = rest_catalog
+ schema = pa.schema([('day', pa.date32()), ('clock', pa.time32('ms')),
+ ('ts', pa.timestamp('ms')), ('ltz', pa.timestamp('ms',
tz='UTC'))])
+ catalog.create_table('default.temporal', Schema.from_pyarrow_schema(
+ schema, primary_keys=['day', 'clock', 'ts'] if keyed else [], options={
+ 'bucket': '1' if keyed else '-1', 'file.format': 'parquet',
+ 'metadata.stats-mode': 'full', 'write.native.enabled': 'false',
+ 'commit.native.enabled': 'false'}), False)
+ table = catalog.get_table('default.temporal')
+ builder = table.new_batch_write_builder()
+ writer, commit = builder.new_write(), builder.new_commit()
+ try:
+ writer.write_arrow(pa.Table.from_pylist([{
+ 'day': date(2024, 1, 2), 'clock': time(1, 2, 3, 4000),
+ 'ts': datetime(2024, 1, 2, 0, 0, 0, 123000),
+ 'ltz': datetime(2024, 1, 2, tzinfo=timezone.utc),
+ }], schema=schema))
+ commit.commit(writer.prepare_commit())
+ finally:
+ writer.close()
+ commit.close()
+ actual = _read(_system(table, 'files', True))
+ expected = _read(_system(table, 'files', False))
+ assert _canonical(actual, 'files') == _canonical(expected, 'files')
+ row = actual.to_pylist()[0]
+ assert row['min_value_stats'] == (
+ '{clock=3723004, day=19724, ltz=2024-01-02T00:00,
ts=2024-01-02T00:00:00.123}')
+ if keyed:
+ ranges = _read(_system(table, 'file_key_ranges', True))
+ assert ranges.column('min_key').to_pylist() == ['[19724, 3723004,
2024-01-02T00:00:00.123]']
+
+
[email protected]('name', ['files', 'file_key_ranges'])
+def test_metadata_paths_keep_escaped_partition_components(source, name):
+ _, table, _ = source
+ _append(table, [{'id': 1, 'value': 'a', 'pt': 'a/b%=λ'}])
+ actual = _read(_system(table, name, True))
+ expected = _read(_system(table, name, False))
+ assert _canonical(actual, name) == _canonical(expected, name)
+ path = actual.column('file_path')[0].as_py()
+ assert '/pt=a%2Fb%25%3Dλ/bucket-0/' in path
+ from pypaimon.utils.path import to_file_io_path
+ assert table.file_io.exists(to_file_io_path(path))
+ assert actual.column('partition').to_pylist() == ['{a/b%=λ}']
+
+
+def
test_system_read_filters_before_projection_without_python_arrow_filter(source):
+ from pypaimon.read.native_plan import _predicate_to_native
+ _, table, _ = source
+ _append(table, [{'id': 1, 'value': 'a', 'pt': 'a'}])
+ _append(table, [{'id': 2, 'value': 'b', 'pt': 'b'}])
+ system = _system(table, 'snapshots', True)
+ predicate =
PredicateBuilder(system.row_type().fields).greater_than('snapshot_id', 1)
+ read =
_native_table(table).new_system_read('snapshots').with_projection(['commit_user'])
+ read.with_filter(_predicate_to_native(predicate)).with_limit(1)
+ with patch.object(type(predicate), 'to_arrow',
side_effect=AssertionError('Python Arrow filter ran')):
+ result = read.read()
+ assert result.num_rows == 1
+ assert result.schema.names == ['commit_user']
+ assert [field.name() for field in read.read_type()] == ['commit_user']
+ read.with_limit(0)
+ empty = read.read()
+ assert empty.num_rows == 0
+ assert empty.schema == result.schema
+
+
+def
test_captured_native_split_can_be_reused_with_another_read_configuration(source):
+ _, table, _ = source
+ _append(table, [{'id': 1, 'value': 'a', 'pt': 'a'}])
+ system = _system(table, 'snapshots', True)
+ builder = system.new_read_builder().with_projection(['snapshot_id'])
+ splits = pickle.loads(pickle.dumps(builder.new_scan().plan().splits()))
+ assert splits[0].row_count == 1
+ other =
system.new_read_builder().with_projection(['commit_user']).new_read()
+ assert other.to_arrow(splits).num_rows == 1
+
+
+def
test_captured_native_split_reads_without_recreating_rest_environment(source):
+ _, table, _ = source
+ _append(table, [{'id': 1, 'value': 'a', 'pt': 'a'}])
+ system = _system(table, 'snapshots', True)
+ splits =
pickle.loads(pickle.dumps(system.new_read_builder().new_scan().plan().splits()))
+ reader =
system.new_read_builder().with_projection(['snapshot_id']).new_read()
+ with patch('pypaimon.table.system.native_system_table._native_table',
+ side_effect=AssertionError('REST environment recreated')):
+ assert reader.to_arrow(splits).to_pylist() == [{'snapshot_id': 1}]
+
+
+def test_system_read_repeated_and_empty_projections(source):
+ _, table, _ = source
+ read = _native_table(table).new_system_read('options')
+ read.with_projection(['value', 'key', 'value'])
+ result = read.read()
+ assert result.num_rows > 0
+ assert result.schema.names == ['value', 'key', 'value']
+ assert result.column(0).to_pylist() == result.column(2).to_pylist()
+ read.with_projection([])
+ empty = read.read()
+ assert empty.num_columns == 0
+ assert empty.num_rows == result.num_rows
+ assert read.read_type() == []
diff --git a/paimon-python/pypaimon/tests/system/branches_table_test.py
b/paimon-python/pypaimon/tests/system/branches_table_test.py
index bfd8f9ef9d..cdfd1c676a 100644
--- a/paimon-python/pypaimon/tests/system/branches_table_test.py
+++ b/paimon-python/pypaimon/tests/system/branches_table_test.py
@@ -28,6 +28,7 @@ from pypaimon import CatalogFactory, Schema
from pypaimon.common.json_util import JSON
from pypaimon.schema.data_types import DataField
from pypaimon.snapshot.snapshot import Snapshot
+from pypaimon.table.system.system_table import metadata_local_millis
from pypaimon.table.system.branches_table import BranchesTable
@@ -113,8 +114,8 @@ class BranchesTableTest(unittest.TestCase):
self.assertIsInstance(ts, datetime.datetime)
ms = int(ts.replace(
tzinfo=datetime.timezone.utc).timestamp() * 1000)
- self.assertGreaterEqual(ms, before - 5000)
- self.assertLessEqual(ms, after + 5000)
+ self.assertGreaterEqual(ms, metadata_local_millis(before) - 5000)
+ self.assertLessEqual(ms, metadata_local_millis(after) + 5000)
if __name__ == "__main__":
diff --git a/paimon-python/pypaimon/tests/system/buckets_table_test.py
b/paimon-python/pypaimon/tests/system/buckets_table_test.py
index 57a352392a..73cfaea0c5 100644
--- a/paimon-python/pypaimon/tests/system/buckets_table_test.py
+++ b/paimon-python/pypaimon/tests/system/buckets_table_test.py
@@ -109,7 +109,7 @@ class BucketsTableTest(unittest.TestCase):
self.assertGreater(arrow_table.num_rows, 0)
partitions = arrow_table.column("partition").to_pylist()
- self.assertTrue(all(p in ("dt=2024-01-01", "dt=2024-01-02")
+ self.assertTrue(all(p in ("{2024-01-01}", "{2024-01-02}")
for p in partitions))
for size in arrow_table.column("file_size_in_bytes").to_pylist():
diff --git a/paimon-python/pypaimon/tests/system/files_table_test.py
b/paimon-python/pypaimon/tests/system/files_table_test.py
index 525ab5224d..df15a3a59c 100644
--- a/paimon-python/pypaimon/tests/system/files_table_test.py
+++ b/paimon-python/pypaimon/tests/system/files_table_test.py
@@ -17,18 +17,23 @@
"""End-to-end tests for the ``$files`` system table."""
-import json
import os
import shutil
import tempfile
+import time
import unittest
+from datetime import datetime, timezone
+from types import SimpleNamespace
import pyarrow as pa
+import pytest
from pypaimon import CatalogFactory, Schema
from pypaimon.schema.data_types import AtomicType, DataField
from pypaimon.schema.schema_change import SchemaChange
-from pypaimon.table.system.files_table import FilesTable, _row_values
+from pypaimon.table.row.generic_row import GenericRow, GenericRowDeserializer,
GenericRowSerializer
+from pypaimon.table.system.files_table import FilesTable, _metadata_file_path,
_render_partition, _row_values
+from pypaimon.utils.file_store_path_factory import FileStorePathFactory
def _read(table):
@@ -36,6 +41,20 @@ def _read(table):
return rb.new_read().to_arrow(rb.new_scan().plan().splits())
+def _stats_map(text):
+ # FilesTable exposes Java Map.toString, with sorted names and unquoted
cells.
+ assert text.startswith('{') and text.endswith('}')
+ pairs = {}
+ for item in text[1:-1].split(', ') if text != '{}' else []:
+ name, value = item.split('=', 1)
+ try:
+ value = int(value)
+ except ValueError:
+ value = None if value == 'null' else value
+ pairs[name] = value
+ return pairs
+
+
class FilesTableTest(unittest.TestCase):
def setUp(self):
@@ -114,10 +133,10 @@ class FilesTableTest(unittest.TestCase):
arrow_table = _read(self.catalog.get_table("db.t$files"))
self.assertGreater(arrow_table.num_rows, 0)
- # One row per file; partitions captured as `pt=value` strings.
+ # One row per file; partitions rendered with Java Row to STRING.
partitions = set(arrow_table.column("partition").to_pylist())
- self.assertIn("dt=2024-01-01", partitions)
- self.assertIn("dt=2024-01-02", partitions)
+ self.assertIn("{2024-01-01}", partitions)
+ self.assertIn("{2024-01-02}", partitions)
for path in arrow_table.column("file_path").to_pylist():
self.assertTrue(path)
@@ -126,17 +145,17 @@ class FilesTableTest(unittest.TestCase):
for size in arrow_table.column("file_size_in_bytes").to_pylist():
self.assertGreater(size, 0)
- # Stats columns are JSON dicts keyed by column name.
+ # Stats columns use Java Map.toString keyed by column name.
for value in arrow_table.column("null_value_counts").to_pylist():
self.assertIsInstance(value, str)
- decoded = json.loads(value)
+ decoded = _stats_map(value)
self.assertIsInstance(decoded, dict)
for value in arrow_table.column("min_value_stats").to_pylist():
self.assertIsInstance(value, str)
- self.assertIsInstance(json.loads(value), dict)
+ self.assertIsInstance(_stats_map(value), dict)
for value in arrow_table.column("max_value_stats").to_pylist():
self.assertIsInstance(value, str)
- self.assertIsInstance(json.loads(value), dict)
+ self.assertIsInstance(_stats_map(value), dict)
# Append-only tables expose empty min_key / max_key.
for value in arrow_table.column("min_key").to_pylist():
@@ -178,9 +197,9 @@ class FilesTableTest(unittest.TestCase):
arrow_table = _read(self.catalog.get_table("db.stats_t$files"))
self.assertGreater(arrow_table.num_rows, 0)
- mins = [json.loads(s) for s in
+ mins = [_stats_map(s) for s in
arrow_table.column("min_value_stats").to_pylist()]
- maxs = [json.loads(s) for s in
+ maxs = [_stats_map(s) for s in
arrow_table.column("max_value_stats").to_pylist()]
# Real per-column values, not an empty {} (the BinaryRow-lacks-values
bug).
self.assertTrue(any(m for m in mins),
@@ -227,17 +246,17 @@ class FilesTableTest(unittest.TestCase):
# The re-added ``v`` is a different column: the old file has no stats
# for it and all of its rows are null there.
self.assertEqual({"id": 1, "v": None},
- json.loads(old_file["min_value_stats"]))
+ _stats_map(old_file["min_value_stats"]))
self.assertEqual({"id": 1, "v": None},
- json.loads(old_file["max_value_stats"]))
+ _stats_map(old_file["max_value_stats"]))
self.assertEqual({"id": 0, "v": 1},
- json.loads(old_file["null_value_counts"]))
+ _stats_map(old_file["null_value_counts"]))
self.assertEqual({"id": 2, "v": 20},
- json.loads(new_file["min_value_stats"]))
+ _stats_map(new_file["min_value_stats"]))
self.assertEqual({"id": 2, "v": 20},
- json.loads(new_file["max_value_stats"]))
+ _stats_map(new_file["max_value_stats"]))
self.assertEqual({"id": 0, "v": 0},
- json.loads(new_file["null_value_counts"]))
+ _stats_map(new_file["null_value_counts"]))
def test_stats_decode_errors_are_not_swallowed(self):
class CorruptRow:
@@ -251,5 +270,66 @@ class FilesTableTest(unittest.TestCase):
_row_values(CorruptRow())
[email protected]('type_name', ['TIMESTAMP_LTZ(9)', 'TIMESTAMP(9) WITH
LOCAL TIME ZONE'])
[email protected]('value,expected', [
+ (datetime(2500, 1, 1, tzinfo=timezone.utc), {
+ 'UTC': '2500-01-01 00:00:00.000000000',
+ 'America/New_York': '2499-12-31 19:00:00.000000000',
+ 'Asia/Shanghai': '2500-01-01 08:00:00.000000000',
+ }),
+ (datetime(2500, 7, 1, microsecond=123456, tzinfo=timezone.utc), {
+ 'UTC': '2500-07-01 00:00:00.123456000',
+ 'America/New_York': '2500-06-30 20:00:00.123456000',
+ 'Asia/Shanghai': '2500-07-01 08:00:00.123456000',
+ }),
+ (datetime(1600, 1, 1, microsecond=123456, tzinfo=timezone.utc), {
+ 'UTC': '1600-01-01 00:00:00.123456000',
+ 'America/New_York': '1599-12-31 19:03:58.123456000',
+ 'Asia/Shanghai': '1600-01-01 08:05:43.123456000',
+ }),
+])
+def test_ltz_partition_strings_outside_arrow_nanosecond_range(type_name,
value, expected):
+ zone = os.environ.get('TZ', time.tzname[0])
+ if zone not in expected:
+ pytest.skip('Run Java timezone golden cases with TZ set to UTC, New
York or Shanghai')
+ fields = [DataField(0, 'pt', AtomicType(type_name))]
+ row = GenericRow([value], fields)
+ decoded =
GenericRowDeserializer.from_bytes(GenericRowSerializer.to_bytes(row), fields)
+ assert _render_partition(decoded) == '{' + expected[zone] + '}'
+
+
+def test_ltz_partition_strings_preserve_submicrosecond_fraction():
+ import pandas as pd
+ expected = {
+ 'UTC': '2024-01-01 00:00:00.123456789',
+ 'America/New_York': '2023-12-31 19:00:00.123456789',
+ 'Asia/Shanghai': '2024-01-01 08:00:00.123456789',
+ }
+ zone = os.environ.get('TZ', time.tzname[0])
+ if zone not in expected:
+ pytest.skip('Run Java timezone golden cases with TZ set to UTC, New
York or Shanghai')
+ row = GenericRow([pd.Timestamp('2024-01-01 00:00:00.123456789', tz='UTC')],
+ [DataField(0, 'pt', AtomicType('TIMESTAMP_LTZ(9)'))])
+ assert _render_partition(row) == '{' + expected[zone] + '}'
+
+
[email protected]('root,expected', [
+ ('file:/warehouse/t', 'file:/warehouse/t/'),
+ ('file:///warehouse/t', 'file:/warehouse/t/'),
+ ('s3://bucket/warehouse/t', 's3://bucket/warehouse/t/'),
+])
[email protected]('data_directory', [None, 'data'])
+def test_metadata_file_paths_keep_logical_scheme_and_escaped_partition(root,
expected, data_directory):
+ factory = FileStorePathFactory(
+ root=root, partition_keys=['pt'],
default_part_value='__DEFAULT_PARTITION__',
+ format_identifier='parquet', data_file_prefix='data-',
changelog_file_prefix='changelog-',
+ legacy_partition_name=False, file_suffix_include_compression=False,
file_compression='zstd',
+ data_file_path_directory=data_directory,
partition_types=[AtomicType('STRING')])
+ table = SimpleNamespace(table_path=root, path_factory=lambda: factory)
+ entry = SimpleNamespace(file=SimpleNamespace(external_path=None,
file_name='data.parquet'),
+ partition=GenericRow(['a/b'], [DataField(0, 'pt',
AtomicType('STRING'))]), bucket=0)
+ assert _metadata_file_path(table, entry) == (
+ expected + ('data/' if data_directory else '') +
'pt=a%2Fb/bucket-0/data.parquet')
+
if __name__ == "__main__":
unittest.main()
diff --git a/paimon-python/pypaimon/tests/system/native_system_table_test.py
b/paimon-python/pypaimon/tests/system/native_system_table_test.py
new file mode 100644
index 0000000000..4383b9ae76
--- /dev/null
+++ b/paimon-python/pypaimon/tests/system/native_system_table_test.py
@@ -0,0 +1,68 @@
+# 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.
+
+"""End-to-end tests for the ``$files`` system table."""
+
+from types import SimpleNamespace
+from unittest.mock import patch
+
+import pyarrow as pa
+import pytest
+
+from pypaimon.schema.data_types import AtomicType, DataField, RowType
+from pypaimon.table.system.native_system_table import
try_read_native_system_table
+
+
+def _table(name='files', enabled=True):
+ return SimpleNamespace(
+
base_table=SimpleNamespace(options=SimpleNamespace(native_read_enabled=lambda:
enabled)),
+ system_table_name=lambda: name,
+ row_type=lambda: RowType(False, [DataField(0, 'id', AtomicType('INT',
nullable=False))]))
+
+
[email protected]('reason', ['disabled', 'old-binding', 'custom-io',
'catalog-required'])
+def test_unsupported_metadata_reads_do_not_create_native_tables(reason):
+ table = _table('partitions' if reason == 'catalog-required' 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',
+ return_value=reason != 'custom-io'):
+ with
patch('pypaimon.table.system.native_system_table._native_table') as create:
+ assert try_read_native_system_table(table) is None
+ create.assert_not_called()
+
+
+def _read_with_native_result(result=None, error=None):
+ with
patch('pypaimon.table.system.native_system_table.native_method_available',
return_value=True):
+ with
patch('pypaimon.table.system.native_system_table._rest_catalog_supported',
return_value=True):
+ with
patch('pypaimon.table.system.native_system_table._native_table') as create:
+ read = create.return_value.new_system_read.return_value.read
+ read.return_value = result
+ read.side_effect = error
+ return try_read_native_system_table(_table())
+
+
+def test_native_read_failure_is_propagated():
+ with pytest.raises(RuntimeError, match='metadata read failed'):
+ _read_with_native_result(error=RuntimeError('metadata read failed'))
+
+
+def test_native_empty_result_uses_declared_schema():
+ batch = pa.record_batch([pa.array([], type=pa.int32())], names=['id'])
+ result = _read_with_native_result(batch)
+ assert result.num_rows == 0
+ assert result.schema == pa.schema([pa.field('id', pa.int32(),
nullable=False)])
diff --git a/paimon-python/pypaimon/tests/system/snapshots_table_test.py
b/paimon-python/pypaimon/tests/system/snapshots_table_test.py
index aadb69dcaa..3cd4e01f03 100644
--- a/paimon-python/pypaimon/tests/system/snapshots_table_test.py
+++ b/paimon-python/pypaimon/tests/system/snapshots_table_test.py
@@ -27,6 +27,7 @@ from pypaimon import CatalogFactory, Schema
from pypaimon.common.json_util import JSON
from pypaimon.schema.data_types import DataField
from pypaimon.snapshot.snapshot import Snapshot
+from pypaimon.table.system.system_table import metadata_local_millis
from pypaimon.table.system.snapshots_table import SnapshotsTable
@@ -161,7 +162,7 @@ class SnapshotsTableTest(unittest.TestCase):
self.assertIsInstance(times[0], datetime.datetime)
as_ms = [int(t.replace(tzinfo=datetime.timezone.utc).timestamp() *
1000)
for t in times]
- self.assertEqual([_T0, _T1], as_ms)
+ self.assertEqual([metadata_local_millis(_T0),
metadata_local_millis(_T1)], as_ms)
if __name__ == "__main__":
diff --git a/paimon-python/pypaimon/tests/system/tags_table_test.py
b/paimon-python/pypaimon/tests/system/tags_table_test.py
index e801b656d8..290e9974e4 100644
--- a/paimon-python/pypaimon/tests/system/tags_table_test.py
+++ b/paimon-python/pypaimon/tests/system/tags_table_test.py
@@ -27,6 +27,7 @@ from pypaimon import CatalogFactory, Schema
from pypaimon.common.json_util import JSON
from pypaimon.schema.data_types import DataField
from pypaimon.snapshot.snapshot import Snapshot
+from pypaimon.table.system.system_table import metadata_local_millis
from pypaimon.table.system.tags_table import TagsTable
@@ -118,7 +119,7 @@ class TagsTableTest(unittest.TestCase):
self.assertIsInstance(ts, datetime.datetime)
ms = int(ts.replace(
tzinfo=datetime.timezone.utc).timestamp() * 1000)
- self.assertEqual(_TAG_COMMIT_MS, ms)
+ self.assertEqual(metadata_local_millis(_TAG_COMMIT_MS), ms)
# Tags created without a retention carry no create-time / TTL.
for value in arrow_table.column("create_time").to_pylist():