Skip to content

Commit e1952d8

Browse files
author
Abanoub Doss
committed
feat: support arrow pycapsule streams
1 parent 5da8186 commit e1952d8

6 files changed

Lines changed: 303 additions & 23 deletions

File tree

pyiceberg/io/pyarrow.py

Lines changed: 49 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@
149149
from pyiceberg.table.name_mapping import NameMapping, apply_name_mapping
150150
from pyiceberg.table.puffin import PuffinFile
151151
from pyiceberg.transforms import IdentityTransform, TruncateTransform
152-
from pyiceberg.typedef import EMPTY_DICT, Properties, Record, TableVersion
152+
from pyiceberg.typedef import EMPTY_DICT, ArrowStreamExportable, Properties, Record, TableVersion
153153
from pyiceberg.types import (
154154
BinaryType,
155155
BooleanType,
@@ -2680,30 +2680,45 @@ def bin_pack_arrow_table(tbl: pa.Table, target_file_size: int) -> Iterator[list[
26802680
"""Bin-pack ``tbl`` into groups of RecordBatches, each ~``target_file_size``.
26812681
26822682
Note:
2683-
``target_file_size`` is measured in **uncompressed in-memory** Arrow bytes
2684-
(``Table.nbytes`` / ``RecordBatch.nbytes``), not compressed on-disk Parquet
2685-
bytes. The resulting Parquet file after compression (zstd by default,
2686-
plus dictionary/RLE encoding) is typically 3-10× smaller than
2687-
``target_file_size``. This is a coarse proxy for the spec-defined
2683+
``target_file_size`` is measured in **uncompressed in-memory** Arrow
2684+
bytes, not compressed on-disk Parquet bytes. The size estimate uses
2685+
``nbytes`` when available and falls back to referenced buffer size for
2686+
Arrow view types that do not support ``nbytes``. The resulting Parquet
2687+
file after compression (zstd by default, plus dictionary/RLE encoding)
2688+
is typically 3-10× smaller than ``target_file_size``. This is a coarse
2689+
proxy for the spec-defined
26882690
``write.target-file-size-bytes`` and will be tightened to true on-disk
26892691
bytes once the writer is switched to a rolling-``ParquetWriter`` with
26902692
``OutputStream.tell()`` (#2998).
26912693
"""
26922694
from pyiceberg.utils.bin_packing import PackingIterator
26932695

2694-
avg_row_size_bytes = tbl.nbytes / tbl.num_rows
2696+
avg_row_size_bytes = _arrow_data_size(tbl) / tbl.num_rows
26952697
target_rows_per_file = max(1, int(target_file_size / avg_row_size_bytes))
26962698
batches = tbl.to_batches(max_chunksize=target_rows_per_file)
26972699
bin_packed_record_batches = PackingIterator(
26982700
items=batches,
26992701
target_weight=target_file_size,
27002702
lookback=len(batches), # ignore lookback
2701-
weight_func=lambda x: x.nbytes,
2703+
weight_func=_arrow_data_size,
27022704
largest_bin_first=False,
27032705
)
27042706
return bin_packed_record_batches
27052707

27062708

2709+
def _arrow_data_size(data: pa.Table | pa.RecordBatch) -> int:
2710+
"""Estimate Arrow data size for writer bin-packing.
2711+
2712+
``nbytes`` is the better logical-size estimate, but PyArrow can raise for
2713+
view types such as ``string_view`` exported by libraries like Polars. Fall
2714+
back to total referenced buffer size so those streams can still be written.
2715+
"""
2716+
try:
2717+
return data.nbytes
2718+
except pyarrow.lib.ArrowTypeError:
2719+
return data.get_total_buffer_size()
2720+
2721+
27072722
def bin_pack_record_batches(batches: Iterable[pa.RecordBatch], target_file_size: int) -> Iterator[list[pa.RecordBatch]]:
27082723
"""Microbatch a single-pass stream of RecordBatches into target-sized groups.
27092724
@@ -2719,9 +2734,11 @@ def bin_pack_record_batches(batches: Iterable[pa.RecordBatch], target_file_size:
27192734
27202735
Note:
27212736
``target_file_size`` is measured in **uncompressed in-memory** Arrow
2722-
bytes (``RecordBatch.nbytes``), not compressed on-disk Parquet bytes.
2723-
The resulting Parquet file after compression is typically 3-10×
2724-
smaller than ``target_file_size``. Matches the existing
2737+
bytes, not compressed on-disk Parquet bytes. The size estimate uses
2738+
``nbytes`` when available and falls back to referenced buffer size for
2739+
Arrow view types that do not support ``nbytes``. The resulting Parquet
2740+
file after compression is typically 3-10× smaller than
2741+
``target_file_size``. Matches the existing
27252742
:func:`bin_pack_arrow_table` semantics; both will be tightened to true
27262743
on-disk bytes once the writer is switched to a rolling-
27272744
``ParquetWriter`` with ``OutputStream.tell()`` (#2998).
@@ -2730,7 +2747,7 @@ def bin_pack_record_batches(batches: Iterable[pa.RecordBatch], target_file_size:
27302747
buffer_bytes = 0
27312748
for batch in batches:
27322749
buffer.append(batch)
2733-
buffer_bytes += batch.nbytes
2750+
buffer_bytes += _arrow_data_size(batch)
27342751
if buffer_bytes >= target_file_size:
27352752
yield buffer
27362753
buffer = []
@@ -3033,3 +3050,23 @@ def _get_field_from_arrow_table(arrow_table: pa.Table, field_path: str) -> pa.Ar
30333050
field_array = arrow_table[path_parts[0]]
30343051
# Navigate into the struct using the remaining path parts
30353052
return pc.struct_field(field_array, path_parts[1:])
3053+
3054+
3055+
def _coerce_arrow_input(df: pa.Table | pa.RecordBatchReader | ArrowStreamExportable) -> pa.Table | pa.RecordBatchReader:
3056+
"""Normalize Arrow write input to a pa.Table or pa.RecordBatchReader.
3057+
3058+
Native pyarrow inputs pass through unchanged; any object implementing the
3059+
Arrow PyCapsule stream interface (``__arrow_c_stream__``) is imported as a
3060+
streaming RecordBatchReader.
3061+
"""
3062+
if isinstance(df, (pa.Table, pa.RecordBatchReader)):
3063+
return df
3064+
3065+
# Any object implementing the Arrow PyCapsule stream interface.
3066+
if hasattr(df, "__arrow_c_stream__"):
3067+
return pa.RecordBatchReader.from_stream(df)
3068+
3069+
raise ValueError(
3070+
f"Expected pa.Table, pa.RecordBatchReader, or an object implementing the "
3071+
f"Arrow PyCapsule interface (__arrow_c_stream__), got: {df!r}"
3072+
)

pyiceberg/table/__init__.py

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@
8282
from pyiceberg.transforms import IdentityTransform
8383
from pyiceberg.typedef import (
8484
EMPTY_DICT,
85+
ArrowStreamExportable,
8586
IcebergBaseModel,
8687
IcebergRootModel,
8788
Identifier,
@@ -452,7 +453,7 @@ def update_statistics(self) -> UpdateStatistics:
452453

453454
def append(
454455
self,
455-
df: pa.Table | pa.RecordBatchReader,
456+
df: pa.Table | pa.RecordBatchReader | ArrowStreamExportable,
456457
snapshot_properties: dict[str, str] = EMPTY_DICT,
457458
branch: str | None = MAIN_BRANCH,
458459
) -> None:
@@ -505,10 +506,9 @@ def append(
505506
except ModuleNotFoundError as e:
506507
raise ModuleNotFoundError("For writes PyArrow needs to be installed") from e
507508

508-
from pyiceberg.io.pyarrow import _check_pyarrow_schema_compatible, _dataframe_to_data_files
509+
from pyiceberg.io.pyarrow import _check_pyarrow_schema_compatible, _coerce_arrow_input, _dataframe_to_data_files
509510

510-
if not isinstance(df, (pa.Table, pa.RecordBatchReader)):
511-
raise ValueError(f"Expected pa.Table or pa.RecordBatchReader, got: {df}")
511+
df = _coerce_arrow_input(df)
512512

513513
downcast_ns_timestamp_to_us = Config().get_bool(DOWNCAST_NS_TIMESTAMP_TO_US_ON_WRITE) or False
514514
_check_pyarrow_schema_compatible(
@@ -598,7 +598,7 @@ def dynamic_partition_overwrite(
598598

599599
def overwrite(
600600
self,
601-
df: pa.Table | pa.RecordBatchReader,
601+
df: pa.Table | pa.RecordBatchReader | ArrowStreamExportable,
602602
overwrite_filter: BooleanExpression | str = ALWAYS_TRUE,
603603
snapshot_properties: dict[str, str] = EMPTY_DICT,
604604
case_sensitive: bool = True,
@@ -662,10 +662,9 @@ def overwrite(
662662
except ModuleNotFoundError as e:
663663
raise ModuleNotFoundError("For writes PyArrow needs to be installed") from e
664664

665-
from pyiceberg.io.pyarrow import _check_pyarrow_schema_compatible, _dataframe_to_data_files
665+
from pyiceberg.io.pyarrow import _check_pyarrow_schema_compatible, _coerce_arrow_input, _dataframe_to_data_files
666666

667-
if not isinstance(df, (pa.Table, pa.RecordBatchReader)):
668-
raise ValueError(f"Expected pa.Table or pa.RecordBatchReader, got: {df}")
667+
df = _coerce_arrow_input(df)
669668

670669
downcast_ns_timestamp_to_us = Config().get_bool(DOWNCAST_NS_TIMESTAMP_TO_US_ON_WRITE) or False
671670
_check_pyarrow_schema_compatible(
@@ -1472,7 +1471,7 @@ def upsert(
14721471

14731472
def append(
14741473
self,
1475-
df: pa.Table | pa.RecordBatchReader,
1474+
df: pa.Table | pa.RecordBatchReader | ArrowStreamExportable,
14761475
snapshot_properties: dict[str, str] = EMPTY_DICT,
14771476
branch: str | None = MAIN_BRANCH,
14781477
) -> None:
@@ -1507,7 +1506,7 @@ def dynamic_partition_overwrite(
15071506

15081507
def overwrite(
15091508
self,
1510-
df: pa.Table | pa.RecordBatchReader,
1509+
df: pa.Table | pa.RecordBatchReader | ArrowStreamExportable,
15111510
overwrite_filter: BooleanExpression | str = ALWAYS_TRUE,
15121511
snapshot_properties: dict[str, str] = EMPTY_DICT,
15131512
case_sensitive: bool = True,
@@ -1716,6 +1715,10 @@ def __datafusion_table_provider__(self, session: Any | None = None) -> IcebergDa
17161715
).__datafusion_table_provider__
17171716
return provider(session)
17181717

1718+
def __arrow_c_stream__(self, requested_schema: object | None = None) -> object:
1719+
"""Export this Table as an Arrow C stream (PyCapsule interface)."""
1720+
return self.scan().to_arrow_batch_reader().__arrow_c_stream__(requested_schema)
1721+
17191722

17201723
class StaticTable(Table):
17211724
"""Load a table directly from a metadata file (i.e., without using a catalog)."""
@@ -2252,6 +2255,10 @@ def to_arrow_batch_reader(self) -> pa.RecordBatchReader:
22522255
batches,
22532256
).cast(target_schema)
22542257

2258+
def __arrow_c_stream__(self, requested_schema: object | None = None) -> object:
2259+
"""Export this scan's result as an Arrow C stream (PyCapsule interface)."""
2260+
return self.to_arrow_batch_reader().__arrow_c_stream__(requested_schema)
2261+
22552262
def to_pandas(self, **kwargs: Any) -> pd.DataFrame:
22562263
"""Read a Pandas DataFrame eagerly from this Iceberg table.
22572264

pyiceberg/typedef.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,19 @@ def __setitem__(self, pos: int, value: Any) -> None:
112112
"""Assign a value to a StructProtocol."""
113113

114114

115+
@runtime_checkable
116+
class ArrowStreamExportable(Protocol): # pragma: no cover
117+
"""Any object implementing the Arrow PyCapsule stream interface.
118+
119+
Covers pa.Table, pa.RecordBatchReader, and third-party producers
120+
(polars, arro3, nanoarrow, ...) without depending on any of them.
121+
"""
122+
123+
@abstractmethod
124+
def __arrow_c_stream__(self, requested_schema: object | None = None) -> object:
125+
"""Export the object as an Arrow C stream PyCapsule."""
126+
127+
115128
class IcebergBaseModel(BaseModel):
116129
"""
117130
This class extends the Pydantic BaseModel to set default values by overriding them.

tests/catalog/test_catalog_behaviors.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1318,7 +1318,7 @@ def test_append_invalid_input_type_raises(catalog: Catalog) -> None:
13181318
identifier = f"default.append_invalid_input_{catalog.name}"
13191319
pa_table = _simple_arrow_table()
13201320
tbl = catalog.create_table(identifier=identifier, schema=pa_table.schema)
1321-
with pytest.raises(ValueError, match="Expected pa.Table or pa.RecordBatchReader"):
1321+
with pytest.raises(ValueError, match="Expected pa.Table, pa.RecordBatchReader, or an object implementing"):
13221322
tbl.append("not an arrow object")
13231323

13241324

tests/io/test_pyarrow.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2435,6 +2435,17 @@ def test_bin_pack_arrow_table_target_size_smaller_than_row(arrow_table_with_null
24352435
assert sum(batch.num_rows for bin_ in bin_packed for batch in bin_) == arrow_table_with_null.num_rows
24362436

24372437

2438+
def test_bin_pack_arrow_table_with_string_view() -> None:
2439+
if not hasattr(pa, "string_view"):
2440+
pytest.skip("pyarrow does not support string_view")
2441+
2442+
table = pa.table({"region": pa.array(["ca", "mx"], type=pa.string_view())})
2443+
2444+
bins = list(bin_pack_arrow_table(table, target_file_size=1))
2445+
2446+
assert sum(batch.num_rows for bin_ in bins for batch in bin_) == table.num_rows
2447+
2448+
24382449
def test_bin_pack_record_batches_single_bin(arrow_table_with_null: pa.Table) -> None:
24392450
batches = arrow_table_with_null.to_batches()
24402451
bins = list(bin_pack_record_batches(iter(batches), target_file_size=arrow_table_with_null.nbytes * 10))

0 commit comments

Comments
 (0)