Skip to content

Commit 286a7de

Browse files
committed
feat: support arrow pycapsule streams
1 parent 9d36e23 commit 286a7de

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
@@ -150,7 +150,7 @@
150150
from pyiceberg.table.name_mapping import NameMapping, apply_name_mapping
151151
from pyiceberg.table.puffin import PuffinFile
152152
from pyiceberg.transforms import IdentityTransform, TruncateTransform
153-
from pyiceberg.typedef import EMPTY_DICT, Properties, Record, TableVersion
153+
from pyiceberg.typedef import EMPTY_DICT, ArrowStreamExportable, Properties, Record, TableVersion
154154
from pyiceberg.types import (
155155
BinaryType,
156156
BooleanType,
@@ -2690,30 +2690,45 @@ def bin_pack_arrow_table(tbl: pa.Table, target_file_size: int) -> Iterator[list[
26902690
"""Bin-pack ``tbl`` into groups of RecordBatches, each ~``target_file_size``.
26912691
26922692
Note:
2693-
``target_file_size`` is measured in **uncompressed in-memory** Arrow bytes
2694-
(``Table.nbytes`` / ``RecordBatch.nbytes``), not compressed on-disk Parquet
2695-
bytes. The resulting Parquet file after compression (zstd by default,
2696-
plus dictionary/RLE encoding) is typically 3-10× smaller than
2697-
``target_file_size``. This is a coarse proxy for the spec-defined
2693+
``target_file_size`` is measured in **uncompressed in-memory** Arrow
2694+
bytes, not compressed on-disk Parquet bytes. The size estimate uses
2695+
``nbytes`` when available and falls back to referenced buffer size for
2696+
Arrow view types that do not support ``nbytes``. The resulting Parquet
2697+
file after compression (zstd by default, plus dictionary/RLE encoding)
2698+
is typically 3-10× smaller than ``target_file_size``. This is a coarse
2699+
proxy for the spec-defined
26982700
``write.target-file-size-bytes`` and will be tightened to true on-disk
26992701
bytes once the writer is switched to a rolling-``ParquetWriter`` with
27002702
``OutputStream.tell()`` (#2998).
27012703
"""
27022704
from pyiceberg.utils.bin_packing import PackingIterator
27032705

2704-
avg_row_size_bytes = tbl.nbytes / tbl.num_rows
2706+
avg_row_size_bytes = _arrow_data_size(tbl) / tbl.num_rows
27052707
target_rows_per_file = max(1, int(target_file_size / avg_row_size_bytes))
27062708
batches = tbl.to_batches(max_chunksize=target_rows_per_file)
27072709
bin_packed_record_batches = PackingIterator(
27082710
items=batches,
27092711
target_weight=target_file_size,
27102712
lookback=len(batches), # ignore lookback
2711-
weight_func=lambda x: x.nbytes,
2713+
weight_func=_arrow_data_size,
27122714
largest_bin_first=False,
27132715
)
27142716
return bin_packed_record_batches
27152717

27162718

2719+
def _arrow_data_size(data: pa.Table | pa.RecordBatch) -> int:
2720+
"""Estimate Arrow data size for writer bin-packing.
2721+
2722+
``nbytes`` is the better logical-size estimate, but PyArrow can raise for
2723+
view types such as ``string_view`` exported by libraries like Polars. Fall
2724+
back to total referenced buffer size so those streams can still be written.
2725+
"""
2726+
try:
2727+
return data.nbytes
2728+
except pyarrow.lib.ArrowTypeError:
2729+
return data.get_total_buffer_size()
2730+
2731+
27172732
def bin_pack_record_batches(batches: Iterable[pa.RecordBatch], target_file_size: int) -> Iterator[list[pa.RecordBatch]]:
27182733
"""Microbatch a single-pass stream of RecordBatches into target-sized groups.
27192734
@@ -2729,9 +2744,11 @@ def bin_pack_record_batches(batches: Iterable[pa.RecordBatch], target_file_size:
27292744
27302745
Note:
27312746
``target_file_size`` is measured in **uncompressed in-memory** Arrow
2732-
bytes (``RecordBatch.nbytes``), not compressed on-disk Parquet bytes.
2733-
The resulting Parquet file after compression is typically 3-10×
2734-
smaller than ``target_file_size``. Matches the existing
2747+
bytes, not compressed on-disk Parquet bytes. The size estimate uses
2748+
``nbytes`` when available and falls back to referenced buffer size for
2749+
Arrow view types that do not support ``nbytes``. The resulting Parquet
2750+
file after compression is typically 3-10× smaller than
2751+
``target_file_size``. Matches the existing
27352752
:func:`bin_pack_arrow_table` semantics; both will be tightened to true
27362753
on-disk bytes once the writer is switched to a rolling-
27372754
``ParquetWriter`` with ``OutputStream.tell()`` (#2998).
@@ -2740,7 +2757,7 @@ def bin_pack_record_batches(batches: Iterable[pa.RecordBatch], target_file_size:
27402757
buffer_bytes = 0
27412758
for batch in batches:
27422759
buffer.append(batch)
2743-
buffer_bytes += batch.nbytes
2760+
buffer_bytes += _arrow_data_size(batch)
27442761
if buffer_bytes >= target_file_size:
27452762
yield buffer
27462763
buffer = []
@@ -3043,3 +3060,23 @@ def _get_field_from_arrow_table(arrow_table: pa.Table, field_path: str) -> pa.Ar
30433060
field_array = arrow_table[path_parts[0]]
30443061
# Navigate into the struct using the remaining path parts
30453062
return pc.struct_field(field_array, path_parts[1:])
3063+
3064+
3065+
def _coerce_arrow_input(df: pa.Table | pa.RecordBatchReader | ArrowStreamExportable) -> pa.Table | pa.RecordBatchReader:
3066+
"""Normalize Arrow write input to a pa.Table or pa.RecordBatchReader.
3067+
3068+
Native pyarrow inputs pass through unchanged; any object implementing the
3069+
Arrow PyCapsule stream interface (``__arrow_c_stream__``) is imported as a
3070+
streaming RecordBatchReader.
3071+
"""
3072+
if isinstance(df, (pa.Table, pa.RecordBatchReader)):
3073+
return df
3074+
3075+
# Any object implementing the Arrow PyCapsule stream interface.
3076+
if hasattr(df, "__arrow_c_stream__"):
3077+
return pa.RecordBatchReader.from_stream(df)
3078+
3079+
raise ValueError(
3080+
f"Expected pa.Table, pa.RecordBatchReader, or an object implementing the "
3081+
f"Arrow PyCapsule interface (__arrow_c_stream__), got: {df!r}"
3082+
)

pyiceberg/table/__init__.py

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@
8989
from pyiceberg.transforms import IdentityTransform
9090
from pyiceberg.typedef import (
9191
EMPTY_DICT,
92+
ArrowStreamExportable,
9293
IcebergBaseModel,
9394
IcebergRootModel,
9495
Identifier,
@@ -459,7 +460,7 @@ def update_statistics(self) -> UpdateStatistics:
459460

460461
def append(
461462
self,
462-
df: pa.Table | pa.RecordBatchReader,
463+
df: pa.Table | pa.RecordBatchReader | ArrowStreamExportable,
463464
snapshot_properties: dict[str, str] = EMPTY_DICT,
464465
branch: str | None = MAIN_BRANCH,
465466
) -> None:
@@ -512,10 +513,9 @@ def append(
512513
except ModuleNotFoundError as e:
513514
raise ModuleNotFoundError("For writes PyArrow needs to be installed") from e
514515

515-
from pyiceberg.io.pyarrow import _check_pyarrow_schema_compatible, _dataframe_to_data_files
516+
from pyiceberg.io.pyarrow import _check_pyarrow_schema_compatible, _coerce_arrow_input, _dataframe_to_data_files
516517

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

520520
downcast_ns_timestamp_to_us = Config().get_bool(DOWNCAST_NS_TIMESTAMP_TO_US_ON_WRITE) or False
521521
_check_pyarrow_schema_compatible(
@@ -605,7 +605,7 @@ def dynamic_partition_overwrite(
605605

606606
def overwrite(
607607
self,
608-
df: pa.Table | pa.RecordBatchReader,
608+
df: pa.Table | pa.RecordBatchReader | ArrowStreamExportable,
609609
overwrite_filter: BooleanExpression | str = ALWAYS_TRUE,
610610
snapshot_properties: dict[str, str] = EMPTY_DICT,
611611
case_sensitive: bool = True,
@@ -669,10 +669,9 @@ def overwrite(
669669
except ModuleNotFoundError as e:
670670
raise ModuleNotFoundError("For writes PyArrow needs to be installed") from e
671671

672-
from pyiceberg.io.pyarrow import _check_pyarrow_schema_compatible, _dataframe_to_data_files
672+
from pyiceberg.io.pyarrow import _check_pyarrow_schema_compatible, _coerce_arrow_input, _dataframe_to_data_files
673673

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

677676
downcast_ns_timestamp_to_us = Config().get_bool(DOWNCAST_NS_TIMESTAMP_TO_US_ON_WRITE) or False
678677
_check_pyarrow_schema_compatible(
@@ -1534,7 +1533,7 @@ def upsert(
15341533

15351534
def append(
15361535
self,
1537-
df: pa.Table | pa.RecordBatchReader,
1536+
df: pa.Table | pa.RecordBatchReader | ArrowStreamExportable,
15381537
snapshot_properties: dict[str, str] = EMPTY_DICT,
15391538
branch: str | None = MAIN_BRANCH,
15401539
) -> None:
@@ -1569,7 +1568,7 @@ def dynamic_partition_overwrite(
15691568

15701569
def overwrite(
15711570
self,
1572-
df: pa.Table | pa.RecordBatchReader,
1571+
df: pa.Table | pa.RecordBatchReader | ArrowStreamExportable,
15731572
overwrite_filter: BooleanExpression | str = ALWAYS_TRUE,
15741573
snapshot_properties: dict[str, str] = EMPTY_DICT,
15751574
case_sensitive: bool = True,
@@ -1778,6 +1777,10 @@ def __datafusion_table_provider__(self, session: Any | None = None) -> IcebergDa
17781777
).__datafusion_table_provider__
17791778
return provider(session)
17801779

1780+
def __arrow_c_stream__(self, requested_schema: object | None = None) -> object:
1781+
"""Export this Table as an Arrow C stream (PyCapsule interface)."""
1782+
return self.scan().to_arrow_batch_reader().__arrow_c_stream__(requested_schema)
1783+
17811784

17821785
class StaticTable(Table):
17831786
"""Load a table directly from a metadata file (i.e., without using a catalog)."""
@@ -2323,6 +2326,10 @@ def to_arrow_batch_reader(self, dictionary_columns: tuple[str, ...] = ()) -> pa.
23232326
self, self.projection(), self.plan_files(), dictionary_columns=dictionary_columns
23242327
)
23252328

2329+
def __arrow_c_stream__(self, requested_schema: object | None = None) -> object:
2330+
"""Export this scan's result as an Arrow C stream (PyCapsule interface)."""
2331+
return self.to_arrow_batch_reader().__arrow_c_stream__(requested_schema)
2332+
23262333
def count(self) -> int:
23272334
from pyiceberg.io.pyarrow import ArrowScan
23282335

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)