| # 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. |
| |
| """File Format API for writing Iceberg data files.""" |
| |
| from __future__ import annotations |
| |
| from abc import ABC, abstractmethod |
| from dataclasses import dataclass |
| from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable |
| |
| from pyiceberg.io import OutputFile |
| from pyiceberg.manifest import FileFormat |
| from pyiceberg.partitioning import PartitionField, PartitionSpec, partition_record_value |
| from pyiceberg.schema import Schema |
| from pyiceberg.typedef import Properties, Record |
| from pyiceberg.types import NestedField |
| |
| if TYPE_CHECKING: |
| import pyarrow as pa |
| |
| from pyiceberg.io.pyarrow import StatsAggregator |
| |
| |
| @dataclass(frozen=True) |
| class DataFileStatistics: |
| record_count: int |
| column_sizes: dict[int, int] |
| value_counts: dict[int, int] |
| null_value_counts: dict[int, int] |
| nan_value_counts: dict[int, int] |
| column_aggregates: dict[int, StatsAggregator] |
| split_offsets: list[int] |
| |
| def _partition_value(self, partition_field: PartitionField, schema: Schema) -> Any: |
| if partition_field.source_id not in self.column_aggregates: |
| return None |
| |
| source_field = schema.find_field(partition_field.source_id) |
| iceberg_transform = partition_field.transform |
| |
| if not iceberg_transform.preserves_order: |
| raise ValueError( |
| f"Cannot infer partition value from parquet metadata for a non-linear Partition Field: " |
| f"{partition_field.name} with transform {partition_field.transform}" |
| ) |
| |
| transform_func = iceberg_transform.transform(source_field.field_type) |
| |
| lower_value = transform_func( |
| partition_record_value( |
| partition_field=partition_field, |
| value=self.column_aggregates[partition_field.source_id].current_min, |
| schema=schema, |
| ) |
| ) |
| upper_value = transform_func( |
| partition_record_value( |
| partition_field=partition_field, |
| value=self.column_aggregates[partition_field.source_id].current_max, |
| schema=schema, |
| ) |
| ) |
| if lower_value != upper_value: |
| raise ValueError( |
| f"Cannot infer partition value from parquet metadata as there are more than one partition values " |
| f"for Partition Field: {partition_field.name}. {lower_value=}, {upper_value=}" |
| ) |
| |
| return lower_value |
| |
| def partition(self, partition_spec: PartitionSpec, schema: Schema) -> Record: |
| return Record(*[self._partition_value(field, schema) for field in partition_spec.fields]) |
| |
| def to_serialized_dict(self) -> dict[str, Any]: |
| lower_bounds = {} |
| upper_bounds = {} |
| |
| for k, agg in self.column_aggregates.items(): |
| _min = agg.min_as_bytes() |
| if _min is not None: |
| lower_bounds[k] = _min |
| _max = agg.max_as_bytes() |
| if _max is not None: |
| upper_bounds[k] = _max |
| return { |
| "record_count": self.record_count, |
| "column_sizes": self.column_sizes, |
| "value_counts": self.value_counts, |
| "null_value_counts": self.null_value_counts, |
| "nan_value_counts": self.nan_value_counts, |
| "lower_bounds": lower_bounds, |
| "upper_bounds": upper_bounds, |
| "split_offsets": self.split_offsets, |
| } |
| |
| |
| class FileFormatWriter(ABC): |
| """Writes data to a single file in a specific format.""" |
| |
| _result: DataFileStatistics | None = None |
| |
| @abstractmethod |
| def write(self, table: pa.Table) -> None: |
| """Write a batch of data. May be called multiple times.""" |
| |
| @abstractmethod |
| def close(self) -> DataFileStatistics: |
| """Finalize the file and return statistics.""" |
| |
| def result(self) -> DataFileStatistics: |
| """Return statistics from a previous close() call.""" |
| if self._result is None: |
| raise RuntimeError("Writer has not been closed yet") |
| return self._result |
| |
| def __enter__(self) -> FileFormatWriter: |
| """Enter the context manager.""" |
| return self |
| |
| def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: |
| """Exit the context manager, closing the writer and caching statistics.""" |
| if exc_type is not None: |
| try: |
| self.close() |
| except Exception: |
| pass |
| return |
| self._result = self.close() |
| |
| |
| @runtime_checkable |
| class FileFormatModel(Protocol): |
| """Represents a file format's capabilities. Creates writers.""" |
| |
| @property |
| def format(self) -> FileFormat: ... |
| |
| def file_extension(self) -> str: |
| """Return file extension without dot, e.g. 'parquet', 'orc'.""" |
| ... |
| |
| def create_writer( |
| self, |
| output_file: OutputFile, |
| file_schema: Schema, |
| properties: Properties, |
| ) -> FileFormatWriter: ... |
| |
| def add_field_metadata(self, field: NestedField, metadata: dict[bytes, bytes], include_field_ids: bool) -> None: |
| """Add format-specific Arrow field metadata.""" |
| ... |
| |
| |
| class FileFormatFactory: |
| """Registry of FileFormatModel implementations.""" |
| |
| _registry: dict[FileFormat, FileFormatModel] = {} |
| |
| @classmethod |
| def register(cls, model: FileFormatModel) -> None: |
| if model.format in cls._registry: |
| existing = cls._registry[model.format] |
| raise ValueError( |
| f"Cannot register {type(model).__name__}: {type(existing).__name__} is already registered for {model.format}" |
| ) |
| cls._registry[model.format] = model |
| |
| @classmethod |
| def get(cls, file_format: FileFormat) -> FileFormatModel: |
| if file_format not in cls._registry: |
| raise ValueError(f"No writer registered for {file_format}. Available: {list(cls._registry.keys())}") |
| return cls._registry[file_format] |
| |
| @classmethod |
| def available_formats(cls) -> list[FileFormat]: |
| return list(cls._registry.keys()) |