Skip to content

Commit b85ec28

Browse files
FrancescAltedclaude
andcommitted
Fix Arrow string_view ingestion and correct pandas interop claims
CTable.from_arrow() raised TypeError on Arrow's string_view/binary_view layout (Polars' default string export via the PyCapsule protocol), so ingesting a bare Polars DataFrame with any string column failed. Added _is_arrow_string_type()/_is_arrow_binary_type() helpers and used them everywhere an Arrow type is inspected during schema inference. Also corrected the "pandas >= 2.2 consumes a CTable directly" claim in 4.9.0 release material: the plain pd.DataFrame(t) constructor does not use the Arrow PyCapsule protocol at all and silently produces a broken DataFrame. The working API is pandas.DataFrame.from_arrow(t), added in pandas 3.0. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent ae5bc05 commit b85ec28

6 files changed

Lines changed: 102 additions & 48 deletions

File tree

ANNOUNCE.rst

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,17 +13,21 @@ a fast, compact layer underneath the tools you already use.
1313
The main highlights are:
1414

1515
- **Arrow PyCapsule interchange**: ``CTable.__arrow_c_stream__`` lets
16-
pyarrow, DuckDB, Polars, and pandas >= 2.2 consume a ``CTable`` directly as
17-
a stream of record batches, with bounded memory — no ``to_arrow()`` copy
18-
step needed::
16+
pyarrow, DuckDB, and Polars consume a ``CTable`` directly as a stream of
17+
record batches, with bounded memory — no ``to_arrow()`` copy step
18+
needed::
1919

2020
import duckdb, blosc2
2121
t = blosc2.CTable.open("trips.b2z")
2222
duckdb.sql("SELECT company, avg(fare) FROM t GROUP BY company").show()
2323

24-
``CTable.from_arrow()`` now accepts any object implementing the same
25-
protocol on ingest too — a pyarrow Table, a Polars DataFrame, a Parquet
26-
reader — in addition to the existing ``(schema, batches)`` form.
24+
pandas >= 3.0 gets the same treatment via the new
25+
``pandas.DataFrame.from_arrow(t)`` classmethod (the plain ``pd.DataFrame(t)``
26+
constructor doesn't use this protocol). ``CTable.from_arrow()`` now
27+
accepts any object implementing the same protocol on ingest too — a
28+
pyarrow Table, a Polars DataFrame, a Parquet reader — in addition to the
29+
existing ``(schema, batches)`` form, including Arrow's ``string_view``
30+
layout (Polars' default string export type).
2731

2832
- **``blosc2.utf8()``: string columns that speak Arrow's layout**. Instead of
2933
a blosc2-specific encoding, ``utf8()`` stores text exactly as Arrow does —

RELEASE_NOTES.md

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,25 +4,29 @@
44

55
This release is about cooperation: `CTable` now speaks the tabular
66
ecosystem's own protocols instead of asking it to speak blosc2's. Arrow
7-
tools (pyarrow, DuckDB, Polars, pandas >= 2.2) can consume or produce a
8-
`CTable` directly through the Arrow PyCapsule interface; a new `utf8()`
9-
string column stores text in Arrow's own offsets+bytes layout and reads
10-
back as NumPy `StringDType`; and `engine=blosc2.jit` now runs correctly
11-
inside pandas 3 itself. Alongside that, `CTable` gained a proper
12-
missing-data story (`fillna`/`dropna`, null-safe arithmetic and
13-
comparisons) and a pandas-3-style chaining API (`assign()`/`col()`,
14-
UDF aggregations, `CTable.apply()`).
7+
tools (pyarrow, DuckDB, Polars, and pandas >= 3.0 via `DataFrame.from_arrow()`)
8+
can consume or produce a `CTable` directly through the Arrow PyCapsule
9+
interface; a new `utf8()` string column stores text in Arrow's own
10+
offsets+bytes layout and reads back as NumPy `StringDType`; and
11+
`engine=blosc2.jit` now runs correctly inside pandas 3 itself. Alongside
12+
that, `CTable` gained a proper missing-data story (`fillna`/`dropna`,
13+
null-safe arithmetic and comparisons) and a pandas-3-style chaining API
14+
(`assign()`/`col()`, UDF aggregations, `CTable.apply()`).
1515

1616
### New features
1717

1818
#### Ecosystem interop
1919

2020
- **Arrow PyCapsule interchange**: `CTable.__arrow_c_stream__` lets
21-
pyarrow, DuckDB, Polars, and pandas >= 2.2 consume a `CTable` directly
22-
as a stream of record batches, with bounded memory — no
23-
`to_arrow()`/copy step required. `CTable.from_arrow()` now accepts any
24-
object implementing the same protocol on ingest (single-argument
25-
form), in addition to the existing `(schema, batches)` form.
21+
pyarrow, DuckDB, and Polars consume a `CTable` directly as a stream of
22+
record batches, with bounded memory — no `to_arrow()`/copy step
23+
required. pandas >= 3.0 can do the same via the new
24+
`pandas.DataFrame.from_arrow()` classmethod (the plain `pd.DataFrame(t)`
25+
constructor does not use this protocol). `CTable.from_arrow()` now
26+
accepts any object implementing the same protocol on ingest
27+
(single-argument form), in addition to the existing `(schema, batches)`
28+
form — including Arrow's `string_view` layout (Polars' default string
29+
export type), which raised `TypeError` before this release.
2630
- **`blosc2.utf8()`**: a new column type for high-cardinality/free-text
2731
strings, storing each column as two companion NDArrays — int64 row
2832
offsets plus a UTF-8 byte blob — the same layout Arrow uses for
@@ -118,6 +122,13 @@ UDF aggregations, `CTable.apply()`).
118122
- Fixed a `NameError` when `nan`/`inf` scalars appeared in lazy
119123
expressions; `ShapeInferencer` no longer ignores user-provided shapes
120124
for `nan`/`inf`.
125+
- Fixed `CTable.from_arrow()` raising `TypeError: No blosc2 spec for Arrow
126+
type DataType(string_view)` on any Arrow `string_view`/`binary_view`
127+
column — the layout Polars exports by default through the PyCapsule
128+
protocol. `string_view`/`binary_view` now import exactly like
129+
`string`/`large_string`/`binary`/`large_binary` everywhere a column's
130+
Arrow type is inspected (schema inference, null-sentinel selection,
131+
list/struct/dictionary value types).
121132

122133
## Changes from 4.8.0 to 4.8.1
123134

doc/getting_started/tutorials/13.ctable-basics.ipynb

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1890,8 +1890,9 @@
18901890
"### 8.3 Zero-copy Arrow interchange (new in 4.9.0)\n",
18911891
"\n",
18921892
"`to_arrow()`/`from_arrow()` above go through a full `pyarrow.Table` copy. The Arrow **PyCapsule interchange protocol**\n",
1893-
"skips that step: `CTable.__arrow_c_stream__` lets any tool that speaks the protocol — pyarrow, DuckDB, Polars, pandas >= 2.2 —\n",
1894-
"read a `CTable` directly as a stream of record batches, with bounded memory."
1893+
"skips that step: `CTable.__arrow_c_stream__` lets any tool that speaks the protocol — pyarrow, DuckDB, Polars —\n",
1894+
"read a `CTable` directly as a stream of record batches, with bounded memory. pandas >= 3.0 gets the same treatment\n",
1895+
"via the new `pandas.DataFrame.from_arrow(t)` classmethod (the plain `pd.DataFrame(t)` constructor does not use this protocol)."
18951896
]
18961897
},
18971898
{

doc/reference/ctable.rst

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -621,14 +621,18 @@ well as import/export paths for CSV, Arrow, and Parquet data.
621621

622622
CTable also implements the `Arrow PyCapsule interchange protocol
623623
<https://arrow.apache.org/docs/format/CDataInterface/PyCapsuleInterface.html>`_
624-
via :meth:`~blosc2.CTable.__arrow_c_stream__`, so pyarrow, DuckDB, Polars, and
625-
pandas >= 2.2 can consume a CTable directly as a stream of record batches
626-
(``pa.table(ct)``, ``duckdb.sql("SELECT ... FROM ct")``, ``pl.DataFrame(ct)``),
627-
and :meth:`~blosc2.CTable.from_arrow` accepts any object implementing that
628-
protocol on ingest. Strict zero-copy is not possible — the underlying data is
629-
compressed, so decompression is unavoidably a copy — but there is no
630-
intermediate materialization: batches are decompressed and handed to the
631-
consumer one at a time, so memory use stays bounded regardless of table size.
624+
via :meth:`~blosc2.CTable.__arrow_c_stream__`, so pyarrow, DuckDB, and Polars
625+
can consume a CTable directly as a stream of record batches
626+
(``pa.table(ct)``, ``duckdb.sql("SELECT ... FROM ct")``, ``pl.DataFrame(ct)``).
627+
pandas >= 3.0 can do the same via the new ``pandas.DataFrame.from_arrow(ct)``
628+
classmethod (the plain ``pd.DataFrame(ct)`` constructor does not use this
629+
protocol). :meth:`~blosc2.CTable.from_arrow` accepts any object implementing
630+
the protocol on ingest, including Arrow's ``string_view`` layout (Polars'
631+
default string export type). Strict zero-copy is not possible — the
632+
underlying data is compressed, so decompression is unavoidably a copy — but
633+
there is no intermediate materialization: batches are decompressed and
634+
handed to the consumer one at a time, so memory use stays bounded regardless
635+
of table size.
632636

633637
.. automethod:: CTable.load
634638
.. automethod:: CTable.open

src/blosc2/ctable.py

Lines changed: 35 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,28 @@
9191
)
9292

9393

94+
def _is_arrow_string_type(pa, pa_type) -> bool:
95+
"""True for any Arrow string-like type, including the view-based layout.
96+
97+
``string_view`` (Arrow's variable-length view layout, e.g. Polars'
98+
default export type for string columns via the PyCapsule interface) is
99+
not one of ``string``/``large_string``/``utf8``/``large_utf8`` but is
100+
handled identically everywhere those are.
101+
"""
102+
if pa_type in (pa.string(), pa.large_string(), pa.utf8(), pa.large_utf8()):
103+
return True
104+
is_string_view = getattr(pa.types, "is_string_view", None)
105+
return bool(is_string_view is not None and is_string_view(pa_type))
106+
107+
108+
def _is_arrow_binary_type(pa, pa_type) -> bool:
109+
"""True for any Arrow binary-like type, including the view-based layout."""
110+
if pa.types.is_binary(pa_type) or pa.types.is_large_binary(pa_type):
111+
return True
112+
is_binary_view = getattr(pa.types, "is_binary_view", None)
113+
return bool(is_binary_view is not None and is_binary_view(pa_type))
114+
115+
94116
@dataclass(frozen=True)
95117
class NullPolicy:
96118
"""Default sentinels for inferred CTable scalar nulls.
@@ -165,9 +187,9 @@ def sentinel_for_arrow_type(self, pa, pa_type):
165187
return self.float_value
166188
if pa_type == pa.bool_():
167189
return self.bool_value
168-
if pa_type in (pa.string(), pa.large_string(), pa.utf8(), pa.large_utf8()):
190+
if _is_arrow_string_type(pa, pa_type):
169191
return self.string_value
170-
if pa.types.is_binary(pa_type) or pa.types.is_large_binary(pa_type):
192+
if _is_arrow_binary_type(pa, pa_type):
171193
return self.bytes_value
172194
if pa.types.is_timestamp(pa_type):
173195
return self.timestamp_value
@@ -6844,7 +6866,9 @@ def _arrow_type_needs_object_fallback(pa, pa_type) -> bool:
68446866
"""True when *pa_type* has no typed CTable mapping."""
68456867
if pa.types.is_dictionary(pa_type):
68466868
vt = pa_type.value_type
6847-
return vt not in (pa.string(), pa.large_string(), pa.utf8(), pa.large_utf8())
6869+
return not _is_arrow_string_type(pa, vt)
6870+
if _is_arrow_string_type(pa, pa_type):
6871+
return False
68486872
if pa_type in (
68496873
pa.int8(),
68506874
pa.int16(),
@@ -6857,13 +6881,9 @@ def _arrow_type_needs_object_fallback(pa, pa_type) -> bool:
68576881
pa.float32(),
68586882
pa.float64(),
68596883
pa.bool_(),
6860-
pa.string(),
6861-
pa.large_string(),
6862-
pa.utf8(),
6863-
pa.large_utf8(),
68646884
):
68656885
return False
6866-
if pa.types.is_binary(pa_type) or pa.types.is_large_binary(pa_type):
6886+
if _is_arrow_binary_type(pa, pa_type):
68676887
return False
68686888
if pa.types.is_timestamp(pa_type):
68696889
return False
@@ -6915,7 +6935,7 @@ def _arrow_type_to_spec( # noqa: C901
69156935

69166936
if pa.types.is_dictionary(pa_type):
69176937
vt = pa_type.value_type
6918-
if vt in (pa.string(), pa.large_string(), pa.utf8(), pa.large_utf8()):
6938+
if _is_arrow_string_type(pa, vt):
69196939
index_type = pa_type.index_type
69206940
# Accept signed and unsigned integer index types; validate fit in int32.
69216941
if not (pa.types.is_integer(index_type) or pa.types.is_unsigned_integer(index_type)):
@@ -6988,7 +7008,7 @@ def _arrow_type_to_spec( # noqa: C901
69887008
item_arrow_col = None
69897009
nullable = True
69907010
item_string_max_length = string_max_length
6991-
if pa_type.value_type in (pa.string(), pa.large_string(), pa.utf8(), pa.large_utf8()):
7011+
if _is_arrow_string_type(pa, pa_type.value_type):
69927012
item_string_max_length = max(string_max_length or 1, 1_000_000)
69937013
item_spec = CTable._arrow_type_to_spec(
69947014
pa,
@@ -7009,7 +7029,7 @@ def _arrow_type_to_spec( # noqa: C901
70097029
)
70107030
child_col = combined.field(field.name)
70117031
child_string_max_length = string_max_length
7012-
if field.type in (pa.string(), pa.large_string(), pa.utf8(), pa.large_utf8()):
7032+
if _is_arrow_string_type(pa, field.type):
70137033
child_string_max_length = max(string_max_length or 1, 1_000_000)
70147034
fields[field.name] = CTable._arrow_type_to_spec(
70157035
pa,
@@ -7021,7 +7041,7 @@ def _arrow_type_to_spec( # noqa: C901
70217041
)
70227042
return b2s.struct(fields, nullable=nullable)
70237043

7024-
if pa_type in (pa.string(), pa.large_string(), pa.utf8(), pa.large_utf8()):
7044+
if _is_arrow_string_type(pa, pa_type):
70257045
if string_max_length is None:
70267046
from blosc2.utf8_array import have_string_dtype
70277047

@@ -7036,7 +7056,7 @@ def _arrow_type_to_spec( # noqa: C901
70367056
max_length = max(string_max_length, len(null_value) if null_value is not None else 1, 1)
70377057
return b2s.string(max_length=max_length, null_value=null_value)
70387058

7039-
if pa.types.is_binary(pa_type) or pa.types.is_large_binary(pa_type):
7059+
if _is_arrow_binary_type(pa, pa_type):
70407060
if string_max_length is None:
70417061
# No fixed-width threshold given: store as variable-length scalar bytes.
70427062
return b2s.vlbytes(nullable=nullable)
@@ -7102,12 +7122,8 @@ def _compiled_columns_from_arrow(
71027122
and not field_is_dictionary
71037123
and column_string_max_length is None
71047124
and (
7105-
pa.types.is_binary(field.type)
7106-
or pa.types.is_large_binary(field.type)
7107-
or (
7108-
not have_string_dtype()
7109-
and (pa.types.is_string(field.type) or pa.types.is_large_string(field.type))
7110-
)
7125+
_is_arrow_binary_type(pa, field.type)
7126+
or (not have_string_dtype() and _is_arrow_string_type(pa, field.type))
71117127
)
71127128
)
71137129
field_needs_object_fallback = cls._arrow_type_needs_object_fallback(pa, field.type)

tests/ctable/test_arrow_interop.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,24 @@ def test_from_arrow_string_default_is_utf8():
323323
assert list(t["name"][:]) == ["hi", "hello world", "!"]
324324

325325

326+
def test_from_arrow_string_view_imports_as_utf8():
327+
"""Arrow's view-based string layout (Polars' default PyCapsule export type)
328+
329+
is not one of string/large_string/utf8/large_utf8, but must still import
330+
like them instead of raising "No blosc2 spec for Arrow type".
331+
"""
332+
if not hasattr(pa.types, "is_string_view"):
333+
pytest.skip("this pyarrow version has no string_view type")
334+
at = pa.table({"name": pa.array(["hi", None, "!"], type=pa.string())}).cast(
335+
pa.schema([pa.field("name", pa.string_view(), nullable=True)])
336+
)
337+
assert pa.types.is_string_view(at.schema.field("name").type)
338+
t = CTable.from_arrow(at.schema, at.to_batches())
339+
if hasattr(np.dtypes, "StringDType"):
340+
assert t["name"].is_utf8
341+
assert list(t["name"].fillna("<null>")) == ["hi", "<null>", "!"]
342+
343+
326344
def test_from_arrow_string_fixed_width_with_max_length():
327345
"""Passing string_max_length gives a fixed-width NDArray string column."""
328346
at = pa.table({"name": pa.array(["hi", "hello world", "!"], type=pa.string())})

0 commit comments

Comments
 (0)