Skip to content

Commit 1fe7c73

Browse files
kounelisagisclaude
andcommitted
Fix .df roundtrip for str/bytes dimensions and tighten StringDtype handling
- _update_df_from_meta: casting to the zero-width '<U0'/'|S0' dtypes stored in the metadata breaks on pandas 3 (str columns turn into object, bytes into fixed-width '|S<n>' that cannot be used as an index). Replace it with an explicit bytes<->str conversion restoring the type the dimension was written with, and skip no-op casts so that dropping `copy=False` (removed in pandas 3) does not introduce extra copies on pandas 2. - ColumnInfo: distinguish the two StringDtype variants by `na_value` (per the pandas migration guide) and reuse from_dtype in from_values instead of duplicating the mapping. - _df_to_np_arrays: restore unconditional nullmap creation; the data scan guard was unnecessary and the explicit vectorized nullmap is cheaper than the per-element fallback in the write path. - Add a str/bytes dimension .df roundtrip regression test covering both the arrow and non-arrow read paths. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent c40e134 commit 1fe7c73

3 files changed

Lines changed: 50 additions & 29 deletions

File tree

tiledb/dataframe_.py

Lines changed: 21 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import json
33
import os
44
import warnings
5-
from dataclasses import dataclass
5+
from dataclasses import dataclass, replace
66
from typing import Any, List, Optional, Union
77

88
import numpy as np
@@ -162,15 +162,13 @@ def from_values(cls, array_like, varlen_types=()):
162162
f"{inferred_dtype} inferred dtype not supported (column {array_like.name})"
163163
)
164164
elif hasattr(array_like, "dtype") and isinstance(array_like.dtype, StringDtype):
165-
# Explicit pd.StringDtype() (name="string") is always nullable;
166-
# auto-inferred str (name="str") depends on data
167-
explicit = array_like.dtype.name == "string"
168-
return cls(
169-
np.dtype(np.str_),
170-
repr="string" if explicit else None,
171-
var=True,
172-
nullable=explicit or bool(array_like.isna().any()),
173-
)
165+
info = cls.from_dtype(array_like.dtype, array_like.name)
166+
# the NaN-backed "str" dtype holds missing values without being a
167+
# nullable dtype; write it as a nullable attribute only when
168+
# missing values are actually present
169+
if not info.nullable and array_like.isna().any():
170+
info = replace(info, nullable=True)
171+
return info
174172
elif hasattr(array_like, "dtype") and isinstance(
175173
array_like.dtype, CategoricalDtype
176174
):
@@ -200,6 +198,7 @@ def from_categorical(cls, cat, dtype, column_name):
200198

201199
@classmethod
202200
def from_dtype(cls, dtype, column_name, varlen_types=()):
201+
from pandas import NA, StringDtype
203202
from pandas.api import types as pd_types
204203

205204
if isinstance(dtype, str) and dtype == "ascii":
@@ -211,13 +210,17 @@ def from_dtype(cls, dtype, column_name, varlen_types=()):
211210
dtype = pd_types.pandas_dtype(dtype)
212211
# Note: be careful if you rearrange the order of the following checks
213212

214-
# pandas StringDtype (auto-inferred 'str' and explicit 'string')
215-
from pandas import StringDtype
216-
213+
# pandas string types: the NA-backed "string" dtype maps to a nullable
214+
# attribute, while the NaN-backed "str" dtype (the default for strings
215+
# since pandas 3) behaves like plain str/object columns
217216
if isinstance(dtype, StringDtype):
218-
repr_val = "string" if dtype.name == "string" else None
219-
nullable = dtype.name == "string"
220-
return cls(np.dtype(np.str_), repr=repr_val, var=True, nullable=nullable)
217+
nullable = dtype.na_value is NA
218+
return cls(
219+
np.dtype(np.str_),
220+
repr="string" if nullable else None,
221+
var=True,
222+
nullable=nullable,
223+
)
221224

222225
# extension types
223226
if pd_types.is_extension_array_dtype(dtype):
@@ -520,8 +523,8 @@ def _df_to_np_arrays(df, column_infos, fillna):
520523
if not column_info.var:
521524
to_numpy_kwargs.update(dtype=column_info.dtype)
522525

523-
if column_info.nullable and column.isna().any():
524-
# Only create nullmap if data actually has nulls
526+
if column_info.nullable:
527+
# use default 0/empty for the dtype
525528
to_numpy_kwargs.update(na_value=column_info.dtype.type())
526529
nullmaps[name] = (~column.isna()).to_numpy(dtype=np.uint8)
527530

tiledb/multirange_indexing.py

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -889,17 +889,22 @@ def _update_df_from_meta(
889889
if name in df:
890890
col_dtypes[name] = dtype
891891

892-
if col_dtypes:
893-
# '<U0' is stored in __pandas_index_dims metadata for var-length string
894-
# dimensions (str(np.dtype(np.str_)) == '<U0>'). Applying astype('<U0')
895-
# was a no-op on pandas 2 but on pandas 3 it forces StringDtype back to
896-
# object, breaking the roundtrip. The string data already has the correct
897-
# dtype from pandas' own inference, so we skip it here.
898-
col_dtypes = {
899-
name: dtype for name, dtype in col_dtypes.items() if dtype != "<U0"
900-
}
901-
if col_dtypes:
902-
df = df.astype(col_dtypes)
892+
for name, dtype in col_dtypes.items():
893+
# str/bytes dimensions are always written as ASCII bytes and, depending
894+
# on the read path, come back as either bytes or str; restore the type
895+
# they were written with. astype with the zero-width '<U0'/'|S0' dtypes
896+
# stored in the metadata used to do this on pandas < 3, but on
897+
# pandas >= 3 it produces object or fixed-width bytes columns instead.
898+
if dtype == "<U0":
899+
if len(df) and isinstance(df[name].iat[0], bytes):
900+
df[name] = df[name].str.decode("utf-8")
901+
elif dtype == "|S0":
902+
if len(df) and not isinstance(df[name].iat[0], bytes):
903+
df[name] = df[name].str.encode("utf-8")
904+
# skip columns that already have their target dtype: casting them
905+
# would needlessly copy the data on pandas < 3
906+
elif str(df[name].dtype) != dtype:
907+
df[name] = df[name].astype(dtype)
903908

904909
if index_col:
905910
if index_col is not True:

tiledb/tests/test_pandas_dataframe.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -626,6 +626,19 @@ def test_dataframe_index_to_sparse_dims(self):
626626
res_df = pd.DataFrame(res, index=index)
627627
tm.assert_frame_equal(new_df, res_df, check_like=True)
628628

629+
@pytest.mark.parametrize("index_data", [["aa", "b"], [b"aa", b"b"]])
630+
def test_dataframe_str_dim_df_roundtrip(self, index_data):
631+
# both str and bytes dimensions are stored as ASCII bytes; on read the
632+
# type they were written with must be restored, on both read paths
633+
uri = self.path("df_str_dim_df_roundtrip")
634+
635+
df = pd.DataFrame({"data": [1.0, 2.0]}, index=pd.Index(index_data, name="idx"))
636+
tiledb.from_pandas(uri, df, sparse=True)
637+
638+
with tiledb.open(uri) as A:
639+
tm.assert_frame_equal(A.df[:], df)
640+
tm.assert_frame_equal(A.query(use_arrow=False).df[:], df)
641+
629642
def test_dataframe_set_index_dims(self):
630643
uri = self.path("df_set_index_dims")
631644

0 commit comments

Comments
 (0)