blob: b294ede3cdcbb9f3a42f72177c8c9fb3d80fd8ad [file]
################################################################################
# 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.
################################################################################
"""
Top-level API for reading and writing Paimon tables with Daft DataFrames.
Usage::
from pypaimon.daft import explain_paimon_scan, read_paimon, write_paimon
df = read_paimon("db.table", catalog_options={"warehouse": "/path"})
explain = explain_paimon_scan("db.table", catalog_options={"warehouse": "/path"})
write_paimon(df, "db.table", catalog_options={"warehouse": "/path"})
"""
from __future__ import annotations
import datetime
from typing import TYPE_CHECKING, Any, Dict, Optional, Union
from urllib.parse import urlparse
if TYPE_CHECKING:
import daft
from pypaimon.daft.daft_explain import PaimonScanExplain
from pypaimon.table.file_store_table import FileStoreTable
def _enrich_options_with_rest_token(
catalog_options: Dict[str, str], table: "FileStoreTable"
) -> Dict[str, str]:
# REST catalogs (DLF) keep OSS STS tokens on table.file_io and the bucket on table.table_path,
# not in catalog_options; fold both in so Daft's IOConfig routes to OSS with valid credentials.
if catalog_options.get("metastore") != "rest":
return catalog_options
file_io = getattr(table, "file_io", None)
if file_io is None or not hasattr(file_io, "try_to_refresh_token"):
return catalog_options
file_io.try_to_refresh_token()
if file_io.token is None:
return catalog_options
enriched = {**catalog_options, **file_io.token.token}
parsed = urlparse(getattr(table, "table_path", "") or "")
if parsed.scheme and parsed.netloc:
enriched["warehouse"] = f"{parsed.scheme}://{parsed.netloc}"
return enriched
# Time-travel targets are mutually exclusive: at most one may be set.
TimeTravelTimestamp = Union[int, str, "datetime.datetime"]
def _validate_single_time_travel(
snapshot_id: int | None,
tag_name: str | None,
timestamp: TimeTravelTimestamp | None,
) -> None:
specified = [
name
for name, value in (
("snapshot_id", snapshot_id),
("tag_name", tag_name),
("timestamp", timestamp),
)
if value is not None
]
if len(specified) > 1:
raise ValueError(
"Only one of snapshot_id, tag_name, timestamp can be set, "
f"got: {specified}"
)
def _timestamp_scan_option(timestamp: TimeTravelTimestamp) -> dict[str, str]:
"""Map a timestamp to the matching Paimon scan option.
``datetime`` / ``int`` (epoch millis) -> ``scan.timestamp-millis``;
``str`` (e.g. ``'2026-07-09 10:00:00'``) -> ``scan.timestamp``.
A naive ``datetime`` is interpreted in the local timezone.
"""
# bool is a subclass of int; reject it so True/False can't become a timestamp.
if isinstance(timestamp, bool):
raise TypeError("timestamp must be int millis, datetime, or str, not bool")
if isinstance(timestamp, datetime.datetime):
return {"scan.timestamp-millis": str(int(timestamp.timestamp() * 1000))}
if isinstance(timestamp, int):
return {"scan.timestamp-millis": str(timestamp)}
if isinstance(timestamp, str):
return {"scan.timestamp": timestamp}
raise TypeError(
"timestamp must be int (epoch millis), datetime, or str, "
f"got: {type(timestamp).__name__}"
)
def _time_travel_table(
table: FileStoreTable,
snapshot_id: int | None = None,
tag_name: str | None = None,
timestamp: TimeTravelTimestamp | None = None,
) -> FileStoreTable:
_validate_single_time_travel(snapshot_id, tag_name, timestamp)
travel_options: dict[str, str] = {}
if snapshot_id is not None:
travel_options["scan.snapshot-id"] = str(snapshot_id)
if tag_name is not None:
travel_options["scan.tag-name"] = tag_name
if timestamp is not None:
travel_options.update(_timestamp_scan_option(timestamp))
if travel_options:
return table.copy(travel_options)
return table
def _source_for_table(
table: FileStoreTable,
catalog_options: Dict[str, str] | None = None,
io_config=None,
):
from daft import context, runners
from daft.daft import StorageConfig
from pypaimon.daft.daft_datasource import PaimonDataSource
from pypaimon.daft.daft_io_config import (
_convert_paimon_catalog_options_to_io_config,
serialize_io_config,
)
if catalog_options is None:
catalog_options = {}
# Keep the caller's io_config for source restoration and blob File fallback.
explicit_io_config_bytes = serialize_io_config(io_config) if io_config is not None else None
io_config = io_config or _convert_paimon_catalog_options_to_io_config(
_enrich_options_with_rest_token(catalog_options, table)
)
io_config = io_config or context.get_context().daft_planning_config.default_io_config
multithreaded_io = runners.get_or_create_runner().name != "ray"
storage_config = StorageConfig(multithreaded_io, io_config)
return PaimonDataSource(
table,
storage_config=storage_config,
catalog_options=catalog_options,
explicit_io_config_bytes=explicit_io_config_bytes,
)
def _read_table(
table: FileStoreTable,
catalog_options: Dict[str, str] | None = None,
io_config=None,
snapshot_id: int | None = None,
tag_name: str | None = None,
timestamp: TimeTravelTimestamp | None = None,
) -> "daft.DataFrame":
"""Read a Paimon table object into a lazy Daft DataFrame."""
table = _time_travel_table(
table, snapshot_id=snapshot_id, tag_name=tag_name, timestamp=timestamp
)
return _source_for_table(table, catalog_options=catalog_options, io_config=io_config).read()
def _normalize_explain_filters(filters: Any) -> Any:
if filters is None:
return None
if isinstance(filters, (list, tuple)):
if not filters:
return None
combined = filters[0]
for filter_expr in filters[1:]:
combined = combined & filter_expr
return combined
return filters
def _explain_table(
table: FileStoreTable,
catalog_options: Dict[str, str] | None = None,
io_config=None,
snapshot_id: int | None = None,
tag_name: str | None = None,
timestamp: TimeTravelTimestamp | None = None,
filters: Any = None,
partition_filters: Any = None,
columns: list[str] | None = None,
limit: int | None = None,
verbose: bool = False,
) -> "PaimonScanExplain":
"""Explain a Paimon table object using Daft's datasource pushdown model."""
from daft.io.pushdowns import Pushdowns
table = _time_travel_table(
table, snapshot_id=snapshot_id, tag_name=tag_name, timestamp=timestamp
)
source = _source_for_table(table, catalog_options=catalog_options, io_config=io_config)
return source.explain_scan(
Pushdowns(
filters=_normalize_explain_filters(filters),
partition_filters=_normalize_explain_filters(partition_filters),
columns=columns,
limit=limit,
),
verbose=verbose,
)
def _write_table(
df: "daft.DataFrame",
table: FileStoreTable,
mode: str = "append",
) -> "daft.DataFrame":
"""Write a Daft DataFrame to a Paimon table object.
Primary-key HASH modes materialize each ``(partition, bucket)`` group in
one Daft worker to guarantee a single Paimon writer owner. A large fixed
bucket can therefore become a single-worker memory/CPU hot spot. Dynamic
buckets are normally bounded by ``dynamic-bucket.target-row-num``. Each
group also returns its commit-message list in one pickled binary cell, so
groups producing many files can create large task results.
"""
from pypaimon.daft.daft_datasink import (
COMMIT_MESSAGES_COLUMN,
PaimonCommitDataSink,
PaimonDataSink,
make_dynamic_bucket_assignment_udf,
make_dynamic_routing_udf,
make_fixed_bucket_udf,
make_group_write_udf,
)
from pypaimon.table.bucket_mode import BucketMode
from pypaimon.index.dynamic_bucket import SHORT_MAX_VALUE
if not table.is_primary_key_table:
return df.write_sink(PaimonDataSink(table, mode))
_validate_write_column_names(df, table)
bucket_mode = table.bucket_mode()
if bucket_mode == BucketMode.POSTPONE_MODE:
return df.write_sink(PaimonDataSink(table, mode))
if bucket_mode == BucketMode.CROSS_PARTITION:
raise ValueError(
"CROSS_PARTITION primary-key Daft writes require a persistent "
"global primary-key index, which PyPaimon does not yet support"
)
import daft
data_columns = [daft.col(name) for name in table.field_names]
commit_column = _pick_internal_column(
df.column_names, COMMIT_MESSAGES_COLUMN
)
commit_sink = PaimonCommitDataSink(table, mode, commit_column)
if bucket_mode == BucketMode.HASH_FIXED:
bucket_column = _pick_internal_column(
df.column_names + [commit_column], "__paimon_bucket__"
)
bucket_udf = make_fixed_bucket_udf(table)
with_bucket = df.with_column(
bucket_column,
bucket_udf(*data_columns),
)
group_keys = list(table.partition_keys) + [bucket_column]
group_write = make_group_write_udf(
table,
mode,
commit_sink.commit_user,
precomputed_bucket=True,
)
prepared = with_bucket.groupby(*group_keys).map_groups(
group_write(
*data_columns,
daft.col(bucket_column),
).alias(commit_column)
)
return prepared.write_sink(commit_sink)
if bucket_mode == BucketMode.HASH_DYNAMIC:
latest_snapshot = table.snapshot_manager().get_latest_snapshot()
base_snapshot_id = (
latest_snapshot.id if latest_snapshot is not None else 0
)
input_partitions = df.num_partitions() or 1
max_buckets = table.options.dynamic_bucket_max_buckets()
# Bucket ids are stored as Java shorts and Paimon allocates ids in
# [0, Short.MAX_VALUE), so every assigner must own at least one id.
num_assigners = min(max(1, input_partitions), SHORT_MAX_VALUE)
if max_buckets > 0:
num_assigners = min(num_assigners, max_buckets)
num_channels = num_assigners
assigner_column = _pick_internal_column(
df.column_names + [commit_column], "__paimon_assigner__"
)
bucket_column = _pick_internal_column(
df.column_names + [commit_column, assigner_column],
"__paimon_bucket__",
)
key_hash_column = _pick_internal_column(
df.column_names + [
commit_column,
assigner_column,
bucket_column,
],
"__paimon_key_hash__",
)
new_mapping_column = _pick_internal_column(
df.column_names + [
commit_column,
assigner_column,
bucket_column,
key_hash_column,
],
"__paimon_new_mapping__",
)
base_snapshot_column = _pick_internal_column(
df.column_names + [
commit_column,
assigner_column,
bucket_column,
key_hash_column,
new_mapping_column,
],
"__paimon_base_snapshot__",
)
routing_udf = make_dynamic_routing_udf(
table,
num_channels,
num_assigners,
assigner_column,
key_hash_column,
)
with_routing = df.select(
*data_columns,
routing_udf(*data_columns),
)
bucket_assignment_udf = make_dynamic_bucket_assignment_udf(
table,
num_channels,
num_assigners,
bucket_column,
key_hash_column,
new_mapping_column,
base_snapshot_column,
ignore_existing=mode == "overwrite",
base_snapshot_id=base_snapshot_id,
)
assigner_group_keys = list(table.partition_keys) + [assigner_column]
with_bucket = with_routing.groupby(*assigner_group_keys).map_groups(
bucket_assignment_udf(
*data_columns,
daft.col(assigner_column),
daft.col(key_hash_column),
)
)
# After a parallelism change, keys handled by different current
# assigners can restore to the same historical bucket. Regroup by the
# final bucket so exactly one file writer owns it in this commit.
group_write = make_group_write_udf(
table,
mode,
commit_sink.commit_user,
precomputed_bucket=True,
base_snapshot_id=base_snapshot_id,
)
group_keys = list(table.partition_keys) + [bucket_column]
prepared = with_bucket.groupby(*group_keys).map_groups(
group_write(
*data_columns,
daft.col(bucket_column),
daft.col(key_hash_column),
daft.col(new_mapping_column),
daft.col(base_snapshot_column),
).alias(commit_column)
)
return prepared.write_sink(commit_sink)
raise ValueError(
f"Unsupported primary-key bucket mode for Daft writes: {bucket_mode.name}"
)
def _validate_write_column_names(df: "daft.DataFrame", table: FileStoreTable) -> None:
input_names = list(df.column_names)
target_names = list(table.field_names)
if len(set(input_names)) != len(input_names):
raise ValueError(
f"Cannot write to Paimon with duplicate input field names: {input_names}"
)
missing = [name for name in target_names if name not in input_names]
extra = [name for name in input_names if name not in target_names]
if missing or extra:
details = []
if missing:
details.append(f"missing fields: {missing}")
if extra:
details.append(f"extra fields: {extra}")
raise ValueError(f"Paimon write schema mismatch: {'; '.join(details)}")
def _pick_internal_column(existing_names, preferred: str) -> str:
existing = set(existing_names)
if preferred not in existing:
return preferred
suffix = 1
while f"{preferred}_{suffix}" in existing:
suffix += 1
return f"{preferred}_{suffix}"
def read_paimon(
table_identifier: str,
catalog_options: Dict[str, str],
*,
snapshot_id: Optional[int] = None,
tag_name: Optional[str] = None,
timestamp: Optional[TimeTravelTimestamp] = None,
io_config=None,
) -> "daft.DataFrame":
"""Read a Paimon table into a lazy Daft DataFrame.
Returns a lazy DataFrame backed by Daft's optimizer. Use standard
DataFrame operations (.select, .where, .limit) for projection,
filtering, and limit — they are automatically pushed down into the
Paimon scan via Daft's DataSource protocol.
Args:
table_identifier: Full table name, e.g. ``"db_name.table_name"``.
catalog_options: Options passed to ``CatalogFactory.create()``,
e.g. ``{"warehouse": "/path/to/warehouse"}``.
snapshot_id: Optional snapshot id to time-travel to. Mutually
exclusive with ``tag_name`` and ``timestamp``.
tag_name: Optional tag name to time-travel to. Mutually
exclusive with ``snapshot_id`` and ``timestamp``.
timestamp: Optional timestamp to time-travel to the latest snapshot
at or before it. Accepts an ``int`` (epoch milliseconds) or
``datetime`` (mapped to ``scan.timestamp-millis``), or a ``str``
such as ``'2026-07-09 10:00:00'`` (mapped to ``scan.timestamp``).
A naive ``datetime`` is interpreted in the local timezone.
Mutually exclusive with ``snapshot_id`` and ``tag_name``.
io_config: Optional Daft IOConfig for accessing object storage.
If None, will be inferred from the catalog options.
Returns:
A lazy ``daft.DataFrame`` backed by this Paimon table.
"""
_validate_single_time_travel(snapshot_id, tag_name, timestamp)
from pypaimon.catalog.catalog_factory import CatalogFactory
catalog = CatalogFactory.create(catalog_options)
table = catalog.get_table(table_identifier)
return _read_table(
table, catalog_options=catalog_options,
io_config=io_config, snapshot_id=snapshot_id, tag_name=tag_name,
timestamp=timestamp,
)
def explain_paimon_scan(
table_identifier: str,
catalog_options: Dict[str, str],
*,
filters: Any = None,
partition_filters: Any = None,
columns: list[str] | None = None,
limit: int | None = None,
snapshot_id: Optional[int] = None,
tag_name: Optional[str] = None,
timestamp: Optional[TimeTravelTimestamp] = None,
io_config=None,
verbose: bool = False,
) -> "PaimonScanExplain":
"""Explain a Paimon scan through Daft's reader-routing layer.
The optional ``filters`` argument accepts a Daft expression or a list of
Daft expressions. Lists are treated as conjunctions, matching how multiple
pushed filters reach Daft datasources.
"""
from pypaimon.catalog.catalog_factory import CatalogFactory
catalog = CatalogFactory.create(catalog_options)
table = catalog.get_table(table_identifier)
return _explain_table(
table,
catalog_options=catalog_options,
io_config=io_config,
snapshot_id=snapshot_id,
tag_name=tag_name,
timestamp=timestamp,
filters=filters,
partition_filters=partition_filters,
columns=columns,
limit=limit,
verbose=verbose,
)
def write_paimon(
df: "daft.DataFrame",
table_identifier: str,
catalog_options: Dict[str, str],
*,
mode: str = "append",
) -> "daft.DataFrame":
"""Write a Daft DataFrame to a Paimon table.
Args:
df: The Daft DataFrame to write.
table_identifier: Full table name, e.g. ``"db_name.table_name"``.
catalog_options: Options passed to ``CatalogFactory.create()``.
mode: Write mode — ``"append"`` or ``"overwrite"``.
Returns:
A summary ``daft.DataFrame`` with columns
(operation, rows, file_size, file_name).
"""
from pypaimon.catalog.catalog_factory import CatalogFactory
catalog = CatalogFactory.create(catalog_options)
table = catalog.get_table(table_identifier)
return _write_table(df, table, mode=mode)