| ################################################################################ |
| # 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) |