From 7b4f822340551804f9c4604a4941fde48e81ae25 Mon Sep 17 00:00:00 2001 From: Agisilaos Kounelis Date: Thu, 5 Feb 2026 17:52:17 +0200 Subject: [PATCH 01/10] Add support for pandas3 --- tiledb/dataframe_.py | 31 ++++++++++++++++++++++--------- tiledb/dense_array.py | 8 ++++++++ tiledb/multirange_indexing.py | 6 +++++- tiledb/sparse_array.py | 9 +++++++++ tiledb/tests/datatypes.py | 8 +++++++- 5 files changed, 51 insertions(+), 11 deletions(-) diff --git a/tiledb/dataframe_.py b/tiledb/dataframe_.py index d7983853b9..e2274932e5 100644 --- a/tiledb/dataframe_.py +++ b/tiledb/dataframe_.py @@ -154,7 +154,7 @@ class ColumnInfo: @classmethod def from_values(cls, array_like, varlen_types=()): - from pandas import CategoricalDtype + from pandas import CategoricalDtype, StringDtype from pandas.api import types as pd_types if pd_types.is_object_dtype(array_like): @@ -171,6 +171,16 @@ def from_values(cls, array_like, varlen_types=()): raise NotImplementedError( f"{inferred_dtype} inferred dtype not supported (column {array_like.name})" ) + elif hasattr(array_like, "dtype") and isinstance(array_like.dtype, StringDtype): + # Explicit pd.StringDtype() (name="string") is always nullable; + # auto-inferred str (name="str") depends on data + explicit = array_like.dtype.name == "string" + return cls( + np.dtype(np.str_), + repr="string" if explicit else None, + var=True, + nullable=explicit or bool(array_like.isna().any()), + ) elif hasattr(array_like, "dtype") and isinstance( array_like.dtype, CategoricalDtype ): @@ -211,6 +221,14 @@ def from_dtype(cls, dtype, column_name, varlen_types=()): dtype = pd_types.pandas_dtype(dtype) # Note: be careful if you rearrange the order of the following checks + # pandas StringDtype (auto-inferred 'str' and explicit 'string') + from pandas import StringDtype + + if isinstance(dtype, StringDtype): + repr_val = "string" if dtype.name == "string" else None + nullable = dtype.name == "string" + return cls(np.dtype(np.str_), repr=repr_val, var=True, nullable=nullable) + # extension types if pd_types.is_extension_array_dtype(dtype): if libtiledb_version() < (2, 10) and pd_types.is_bool_dtype(dtype): @@ -255,12 +273,7 @@ def from_dtype(cls, dtype, column_name, varlen_types=()): # datetime types if pd_types.is_datetime64_any_dtype(dtype): - if dtype == "datetime64[ns]": - return cls(dtype) - else: - raise NotImplementedError( - f"Only 'datetime64[ns]' datetime dtype is supported (column {column_name})" - ) + return cls(dtype) # string types # don't use pd_types.is_string_dtype() because it includes object types too @@ -526,8 +539,8 @@ def _df_to_np_arrays(df, column_infos, fillna): if not column_info.var: to_numpy_kwargs.update(dtype=column_info.dtype) - if column_info.nullable: - # use default 0/empty for the dtype + if column_info.nullable and column.isna().any(): + # Only create nullmap if data actually has nulls to_numpy_kwargs.update(na_value=column_info.dtype.type()) nullmaps[name] = (~column.isna()).to_numpy(dtype=np.uint8) diff --git a/tiledb/dense_array.py b/tiledb/dense_array.py index 76ea6fef2b..584b2fe060 100644 --- a/tiledb/dense_array.py +++ b/tiledb/dense_array.py @@ -479,6 +479,14 @@ def _setitem_impl(self, selection, val, nullmaps: dict): try: if attr.isvar: + # Capture null mask before np.asarray() loses pandas NA info + if ( + attr.isnullable + and name not in nullmaps + and hasattr(attr_val, "isna") + ): + nullmaps[name] = (~attr_val.isna()).to_numpy(dtype=np.uint8) + # ensure that the value is array-convertible, for example: pandas.Series attr_val = np.asarray(attr_val) if attr.isnullable and name not in nullmaps: diff --git a/tiledb/multirange_indexing.py b/tiledb/multirange_indexing.py index b4004cefaf..841b997064 100644 --- a/tiledb/multirange_indexing.py +++ b/tiledb/multirange_indexing.py @@ -889,7 +889,11 @@ def _update_df_from_meta( col_dtypes[name] = dtype if col_dtypes: - df = df.astype(col_dtypes, copy=False) + # Use str instead of ' Date: Thu, 5 Feb 2026 17:52:34 +0200 Subject: [PATCH 02/10] Add and modify tests --- tiledb/tests/test_pandas_dataframe.py | 57 ++++++++++++--------------- 1 file changed, 25 insertions(+), 32 deletions(-) diff --git a/tiledb/tests/test_pandas_dataframe.py b/tiledb/tests/test_pandas_dataframe.py index fa4350d8d6..9812b12eb8 100644 --- a/tiledb/tests/test_pandas_dataframe.py +++ b/tiledb/tests/test_pandas_dataframe.py @@ -204,32 +204,34 @@ def test_implemented(self, type_specs, info_dtype, info_repr, info_nullable): def test_object_dtype(self): self.assertColumnInfo( - ColumnInfo.from_values(pd.Series(["hello", "world"])), np.dtype(" Date: Thu, 5 Feb 2026 18:04:28 +0200 Subject: [PATCH 03/10] Remove pandas version restriction --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 01f2051874..d306a6f334 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,7 +57,7 @@ test = [ "hypothesis", "psutil", "pyarrow", - "pandas<3", + "pandas", "dask[distributed]", ] From 24280d3f767e057307f1e99d5e210c4b1aa1f422 Mon Sep 17 00:00:00 2001 From: Agisilaos Kounelis Date: Thu, 5 Feb 2026 18:05:16 +0200 Subject: [PATCH 04/10] Simplify pandas dependency checks --- examples/incomplete_iteration.py | 13 +++---------- examples/parallel_csv_ingestion.py | 13 +++---------- tiledb/dataframe_.py | 23 +++-------------------- tiledb/tests/common.py | 11 +++-------- 4 files changed, 12 insertions(+), 48 deletions(-) diff --git a/examples/incomplete_iteration.py b/examples/incomplete_iteration.py index 4166cff7d9..aa458a3c84 100644 --- a/examples/incomplete_iteration.py +++ b/examples/incomplete_iteration.py @@ -40,21 +40,14 @@ def check_dataframe_deps(): - pd_error = """Pandas version >= 1.0 and < 3.0 required for dataframe functionality. - Please `pip install pandas>=1.0,<3.0` to proceed.""" + pd_error = """Pandas is required for dataframe functionality. + Please `pip install pandas` to proceed.""" try: - import pandas as pd + import pandas except ImportError: raise Exception(pd_error) - from packaging.version import Version - - if Version(pd.__version__) < Version("1.0") or Version(pd.__version__) >= Version( - "3.0.0.dev0" - ): - raise Exception(pd_error) - # Name of the array to create. array_name = "incomplete_iteration" diff --git a/examples/parallel_csv_ingestion.py b/examples/parallel_csv_ingestion.py index 2038df7093..799f210f92 100644 --- a/examples/parallel_csv_ingestion.py +++ b/examples/parallel_csv_ingestion.py @@ -49,21 +49,14 @@ def check_dataframe_deps(): - pd_error = """Pandas version >= 1.0 and < 3.0 required for dataframe functionality. - Please `pip install pandas>=1.0,<3.0` to proceed.""" + pd_error = """Pandas is required for dataframe functionality. + Please `pip install pandas` to proceed.""" try: - import pandas as pd + import pandas except ImportError: raise Exception(pd_error) - from packaging.version import Version - - if Version(pd.__version__) < Version("1.0") or Version(pd.__version__) >= Version( - "3.0.0.dev0" - ): - raise Exception(pd_error) - def generate_csvs(csv_folder, count=9, min_length=1, max_length=109): def make_dataframe(col_size): diff --git a/tiledb/dataframe_.py b/tiledb/dataframe_.py index e2274932e5..4d1da98c66 100644 --- a/tiledb/dataframe_.py +++ b/tiledb/dataframe_.py @@ -15,31 +15,14 @@ def check_dataframe_deps(): - pd_error = """Pandas version >= 1.0 and < 3.0 required for dataframe functionality. - Please `pip install pandas>=1.0,<3.0` to proceed.""" - pa_error = """PyArrow version >= 1.0 is suggested for dataframe functionality. - Please `pip install pyarrow>=1.0`.""" + pd_error = """Pandas is required for dataframe functionality. + Please `pip install pandas` to proceed.""" try: - import pandas as pd + import pandas except ImportError: raise Exception(pd_error) - from packaging.version import Version - - if Version(pd.__version__) < Version("1.0") or Version(pd.__version__) >= Version( - "3.0.0.dev0" - ): - raise Exception(pd_error) - - try: - import pyarrow as pa - - if Version(pa.__version__) < Version("1.0"): - warnings.warn(pa_error) - except ImportError: - warnings.warn(pa_error) - # Note: 'None' is used to indicate optionality for many of these options # For example, if the `sparse` argument is unspecified we will default diff --git a/tiledb/tests/common.py b/tiledb/tests/common.py index 087089b363..de1555411c 100644 --- a/tiledb/tests/common.py +++ b/tiledb/tests/common.py @@ -26,17 +26,12 @@ def has_pandas(): try: - import pandas as pd - except ImportError: - return False + import pandas - if Version(pd.__version__) < Version("1.0") or Version(pd.__version__) >= Version( - "3.0.0.dev0" - ): + return True + except ImportError: return False - return True - def has_pyarrow(): try: From 67a5e50ebaa945135d0f16a2c5db8bec6551c94b Mon Sep 17 00:00:00 2001 From: Agisilaos Kounelis Date: Thu, 5 Feb 2026 18:06:35 +0200 Subject: [PATCH 05/10] Update test skip reason messages --- tiledb/tests/test_enumeration.py | 4 ++-- tiledb/tests/test_examples.py | 7 +++---- tiledb/tests/test_fixes.py | 6 +++--- tiledb/tests/test_hypothesis.py | 2 +- tiledb/tests/test_libtiledb.py | 8 ++++---- tiledb/tests/test_multi_index.py | 16 ++++++++-------- tiledb/tests/test_pandas_dataframe.py | 2 +- tiledb/tests/test_query_condition.py | 4 ++-- 8 files changed, 24 insertions(+), 25 deletions(-) diff --git a/tiledb/tests/test_enumeration.py b/tiledb/tests/test_enumeration.py index 00ed043fd0..7d7c8e5382 100644 --- a/tiledb/tests/test_enumeration.py +++ b/tiledb/tests/test_enumeration.py @@ -111,7 +111,7 @@ def test_array_schema_enumeration(self): @pytest.mark.skipif( not has_pyarrow() or not has_pandas(), - reason="pyarrow>=1.0 and/or pandas>=1.0,<3.0 not installed", + reason="pyarrow>=1.0 and/or pandas not installed", ) @pytest.mark.parametrize("sparse", [True, False]) @pytest.mark.parametrize("pass_df", [True, False]) @@ -185,7 +185,7 @@ def test_enum_dtypes(self, dtype, values): assert enmr.dtype == enmr.values().dtype == dtype assert_array_equal(enmr.values(), values) - @pytest.mark.skipif(not has_pandas(), reason="pandas>=1.0,<3.0 not installed") + @pytest.mark.skipif(not has_pandas(), reason="pandas not installed") def test_from_pandas_dtype_mismatch(self): import pandas as pd diff --git a/tiledb/tests/test_examples.py b/tiledb/tests/test_examples.py index 1c3f18a899..10793e36d4 100644 --- a/tiledb/tests/test_examples.py +++ b/tiledb/tests/test_examples.py @@ -43,7 +43,7 @@ def test_examples(self, path): ] ] if not has_pandas() and path in requires_pd: - pytest.mark.skip("pandas>=1.0,<3.0 not installed") + pytest.mark.skip("pandas not installed") else: with tempfile.TemporaryDirectory() as tmpdir: try: @@ -73,10 +73,9 @@ def test_docs(self, capsys): if failures: stderr = capsys.readouterr().out if "No module named 'pandas'" in stderr or ( - "Pandas version >= 1.0 and < 3.0 required for dataframe functionality" - in stderr + "Pandas is required for dataframe functionality" in stderr and not has_pandas() ): - pytest.skip("pandas>=1.0,<3.0 not installed") + pytest.skip("pandas not installed") else: pytest.fail(stderr) diff --git a/tiledb/tests/test_fixes.py b/tiledb/tests/test_fixes.py index 73d5611f5b..aa82d4f2e3 100644 --- a/tiledb/tests/test_fixes.py +++ b/tiledb/tests/test_fixes.py @@ -91,7 +91,7 @@ def test_ch8292(self): buffers = list(*q._get_buffers().values()) assert buffers[0].nbytes == max_val - @pytest.mark.skipif(not has_pandas(), reason="pandas>=1.0,<3.0 not installed") + @pytest.mark.skipif(not has_pandas(), reason="pandas not installed") def test_ch10282_concurrent_multi_index(self): """Test concurrent access to a single tiledb.Array using Array.multi_index and Array.df. We pass an array and slice @@ -230,7 +230,7 @@ def test_fix_stats_error_messages(self): @pytest.mark.skipif( not has_pandas() and has_pyarrow(), - reason="pandas>=1.0,<3.0 or pyarrow>=1.0 not installed", + reason="pandas or pyarrow>=1.0 not installed", ) def test_py1078_df_all_empty_strings(self): uri = self.path() @@ -246,7 +246,7 @@ def test_py1078_df_all_empty_strings(self): with tiledb.open(uri) as arr: tm.assert_frame_equal(arr.df[:], df) - @pytest.mark.skipif(not has_pandas(), reason="pandas>=1.0,<3.0 not installed") + @pytest.mark.skipif(not has_pandas(), reason="pandas not installed") @pytest.mark.parametrize("is_sparse", [True, False]) def test_sc1430_nonexisting_timestamp(self, is_sparse): path = self.path("nonexisting_timestamp") diff --git a/tiledb/tests/test_hypothesis.py b/tiledb/tests/test_hypothesis.py index 6494f420da..c73bb876cd 100644 --- a/tiledb/tests/test_hypothesis.py +++ b/tiledb/tests/test_hypothesis.py @@ -13,7 +13,7 @@ tm = pd._testing -@pytest.mark.skipif(not has_pandas(), reason="pandas>=1.0,<3.0 not installed") +@pytest.mark.skipif(not has_pandas(), reason="pandas not installed") @pytest.mark.parametrize("mode", ["np", "df"]) @hp.settings(deadline=None, verbosity=hp.Verbosity.verbose) @hp.given(st.binary()) diff --git a/tiledb/tests/test_libtiledb.py b/tiledb/tests/test_libtiledb.py index 06fa2f0107..0f89d495a8 100644 --- a/tiledb/tests/test_libtiledb.py +++ b/tiledb/tests/test_libtiledb.py @@ -418,7 +418,7 @@ def test_array_delete(self): @pytest.mark.skipif( not has_pyarrow() or not has_pandas(), - reason="pyarrow>=1.0 and/or pandas>=1.0,<3.0 not installed", + reason="pyarrow>=1.0 and/or pandas not installed", ) @pytest.mark.parametrize("sparse", [True, False]) @pytest.mark.parametrize("pass_df", [True, False]) @@ -1784,7 +1784,7 @@ def test_query_real_multi_index(self, fx_sparse_cell_order): "coords" not in T.query(coords=False).multi_index[-10.0:5.0] ) - @pytest.mark.skipif(not has_pandas(), reason="pandas>=1.0,<3.0 not installed") + @pytest.mark.skipif(not has_pandas(), reason="pandas not installed") @pytest.mark.parametrize("dtype", ["u1", "u2", "u4", "u8", "i1", "i2", "i4", "i8"]) def test_sparse_index_dtypes(self, dtype): path = self.path() @@ -1805,7 +1805,7 @@ def test_sparse_index_dtypes(self, dtype): assert B[data[1]]["attr"] == data[1] assert B.multi_index[data[0]]["attr"] == data[0] - @pytest.mark.skipif(not has_pandas(), reason="pandas>=1.0,<3.0 not installed") + @pytest.mark.skipif(not has_pandas(), reason="pandas not installed") @pytest.mark.skipif( tiledb.libtiledb.version() < (2, 10), reason="TILEDB_BOOL introduced in libtiledb 2.10", @@ -3755,7 +3755,7 @@ def test_query_return_incomplete_error(self, sparse): with self.assertRaises(tiledb.TileDBError): A.query(return_incomplete=True)[:] - @pytest.mark.skipif(not has_pandas(), reason="pandas>=1.0,<3.0 not installed") + @pytest.mark.skipif(not has_pandas(), reason="pandas not installed") @pytest.mark.parametrize( "use_arrow, return_arrow, indexer", [ diff --git a/tiledb/tests/test_multi_index.py b/tiledb/tests/test_multi_index.py index 6f8c7ef166..1a6f713ca8 100644 --- a/tiledb/tests/test_multi_index.py +++ b/tiledb/tests/test_multi_index.py @@ -141,7 +141,7 @@ def make_arr(ndim): class TestMultiRange(DiskTestCase): @pytest.mark.skipif( not has_pyarrow() or not has_pandas(), - reason="pyarrow>=1.0 and/or pandas>=1.0,<3.0 not installed", + reason="pyarrow>=1.0 and/or pandas not installed", ) def test_return_arrow_indexers(self): uri = self.path("multirange_behavior_sparse") @@ -183,7 +183,7 @@ def test_return_arrow_indexers(self): @pytest.mark.skipif( not has_pyarrow() or not has_pandas(), - reason="pyarrow>=1.0 and/or pandas>=1.0,<3.0 not installed", + reason="pyarrow>=1.0 and/or pandas not installed", ) @pytest.mark.parametrize("sparse", [True, False]) def test_return_large_arrow_table(self, sparse): @@ -727,7 +727,7 @@ def test_fix_473_sparse_index_bug(self): np.array([], dtype=np.uint64), ) - @pytest.mark.skipif(not has_pandas(), reason="pandas>=1.0,<3.0 not installed") + @pytest.mark.skipif(not has_pandas(), reason="pandas not installed") def test_fixed_multi_attr_df(self): uri = self.path("test_fixed_multi_attr_df") dom = tiledb.Domain( @@ -761,7 +761,7 @@ def test_fixed_multi_attr_df(self): result = A.query(attrs=["111"], use_arrow=False) assert_array_equal(result.df[0]["111"], data_111) - @pytest.mark.skipif(not has_pandas(), reason="pandas>=1.0,<3.0 not installed") + @pytest.mark.skipif(not has_pandas(), reason="pandas not installed") def test_var_multi_attr_df(self): uri = self.path("test_var_multi_attr_df") dom = tiledb.Domain( @@ -845,7 +845,7 @@ def test_multi_index_open_timestamp_with_empty_nonempty_domain(self): assert A.nonempty_domain() is None assert_array_equal(A.multi_index[:][""], A[:][""]) - @pytest.mark.skipif(not has_pandas(), reason="pandas>=1.0,<3.0 not installed") + @pytest.mark.skipif(not has_pandas(), reason="pandas not installed") def test_multi_index_query_args(self): uri = self.path("test_multi_index_query_args") schema = tiledb.ArraySchema( @@ -871,7 +871,7 @@ def test_multi_index_query_args(self): assert_array_equal(q.multi_index[:]["a"], q.df[:]["a"]) assert all(q[:]["a"] >= 5) - @pytest.mark.skipif(not has_pandas(), reason="pandas>=1.0,<3.0 not installed") + @pytest.mark.skipif(not has_pandas(), reason="pandas not installed") def test_multi_index_timing(self): path = self.path("test_multi_index_timing") attr_name = "a" @@ -886,7 +886,7 @@ def test_multi_index_timing(self): assert "py.getitem_time.pandas_index_update_time :" in internal_stats tiledb.stats_disable() - @pytest.mark.skipif(not has_pandas(), reason="pandas>=1.0,<3.0 not installed") + @pytest.mark.skipif(not has_pandas(), reason="pandas not installed") def test_fixed_width_char(self): uri = self.path("test_fixed_width_char") schema = tiledb.ArraySchema( @@ -903,7 +903,7 @@ def test_fixed_width_char(self): with tiledb.open(uri, mode="r") as A: assert all(A.query(use_arrow=True).df[:][""] == data) - @pytest.mark.skipif(not has_pandas(), reason="pandas>=1.0,<3.0 not installed") + @pytest.mark.skipif(not has_pandas(), reason="pandas not installed") def test_empty_idx(self): uri = self.path("test_empty_idx") diff --git a/tiledb/tests/test_pandas_dataframe.py b/tiledb/tests/test_pandas_dataframe.py index 9812b12eb8..67e6dfdcb4 100644 --- a/tiledb/tests/test_pandas_dataframe.py +++ b/tiledb/tests/test_pandas_dataframe.py @@ -29,7 +29,7 @@ from .datatypes import RaggedDtype if not has_pandas(): - pytest.skip("pandas>=1.0,<3.0 not installed", allow_module_level=True) + pytest.skip("pandas not installed", allow_module_level=True) else: import pandas as pd diff --git a/tiledb/tests/test_query_condition.py b/tiledb/tests/test_query_condition.py index dca40825ed..815767b5c5 100644 --- a/tiledb/tests/test_query_condition.py +++ b/tiledb/tests/test_query_condition.py @@ -615,7 +615,7 @@ def test_not_supported_operators(self, expression_and_message, sparse): with self.assertRaisesRegex(tiledb.TileDBError, message): A.query(cond=expression)[:] - @pytest.mark.skipif(not has_pandas(), reason="pandas>=1.0,<3.0 not installed") + @pytest.mark.skipif(not has_pandas(), reason="pandas not installed") def test_dense_datetime(self): import pandas as pd @@ -823,7 +823,7 @@ def test_attribute_with_dot(self): result = A.query(cond='attr("at.tr.thr.ee") in [1, 2, 3]')[:] assert_array_equal(result["at.tr.thr.ee"], A[1:4]["at.tr.thr.ee"]) - @pytest.mark.skipif(not has_pandas(), reason="pandas>=1.0,<3.0 not installed") + @pytest.mark.skipif(not has_pandas(), reason="pandas not installed") def test_do_not_return_attrs(self): with tiledb.open(self.create_input_array_UIDSA(sparse=True)) as A: cond = None From 4a2f08882667289cccf48e186c4bc39e7b34e503 Mon Sep 17 00:00:00 2001 From: Agisilaos Kounelis Date: Thu, 5 Feb 2026 19:03:29 +0200 Subject: [PATCH 06/10] Add step to re-run tests with pandas 2 in CI --- .github/workflows/ci.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5802d7fda1..9884102931 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -156,6 +156,13 @@ jobs: cd $RUNNER_TEMP pytest -vv --showlocals $PROJECT_CWD + - name: "Re-run tests with pandas 2" + run: | + PROJECT_CWD=$PWD + pip install "pandas>=2,<3" + cd $RUNNER_TEMP + pytest -vv --showlocals $PROJECT_CWD + - name: "Re-run tests without pandas" run: | PROJECT_CWD=$PWD From d5e10532feec121fc08071f61dabcdae82869672 Mon Sep 17 00:00:00 2001 From: Agisilaos Kounelis Date: Thu, 5 Feb 2026 20:22:40 +0200 Subject: [PATCH 07/10] Easy fixes --- tiledb/dataframe_.py | 7 +++++++ tiledb/tests/test_pandas_dataframe.py | 8 +++----- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/tiledb/dataframe_.py b/tiledb/dataframe_.py index 4d1da98c66..5062d755de 100644 --- a/tiledb/dataframe_.py +++ b/tiledb/dataframe_.py @@ -17,12 +17,19 @@ def check_dataframe_deps(): pd_error = """Pandas is required for dataframe functionality. Please `pip install pandas` to proceed.""" + pa_error = """PyArrow is suggested for dataframe functionality. + Please `pip install pyarrow`.""" try: import pandas except ImportError: raise Exception(pd_error) + try: + import pyarrow + except ImportError: + warnings.warn(pa_error) + # Note: 'None' is used to indicate optionality for many of these options # For example, if the `sparse` argument is unspecified we will default diff --git a/tiledb/tests/test_pandas_dataframe.py b/tiledb/tests/test_pandas_dataframe.py index 67e6dfdcb4..144abe98b3 100644 --- a/tiledb/tests/test_pandas_dataframe.py +++ b/tiledb/tests/test_pandas_dataframe.py @@ -204,12 +204,10 @@ def test_implemented(self, type_specs, info_dtype, info_repr, info_nullable): def test_object_dtype(self): self.assertColumnInfo( - ColumnInfo.from_values(pd.Series(["hello", "world"], dtype=object)), - np.dtype(" Date: Thu, 5 Feb 2026 20:29:46 +0200 Subject: [PATCH 08/10] Comment --- tiledb/multirange_indexing.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tiledb/multirange_indexing.py b/tiledb/multirange_indexing.py index 841b997064..8685f086b2 100644 --- a/tiledb/multirange_indexing.py +++ b/tiledb/multirange_indexing.py @@ -889,11 +889,16 @@ def _update_df_from_meta( col_dtypes[name] = dtype if col_dtypes: - # Use str instead of ''). Applying astype(' Date: Fri, 6 Feb 2026 00:44:59 +0200 Subject: [PATCH 09/10] Remove `copy` arg --- tiledb/multirange_indexing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tiledb/multirange_indexing.py b/tiledb/multirange_indexing.py index 8685f086b2..2bad9852e6 100644 --- a/tiledb/multirange_indexing.py +++ b/tiledb/multirange_indexing.py @@ -898,7 +898,7 @@ def _update_df_from_meta( name: dtype for name, dtype in col_dtypes.items() if dtype != " Date: Tue, 7 Jul 2026 14:47:00 +0300 Subject: [PATCH 10/10] Fix .df roundtrip for str/bytes dimensions and tighten StringDtype handling - _update_df_from_meta: casting to the zero-width '' 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. --- tiledb/dataframe_.py | 39 ++++++++++++++------------- tiledb/multirange_indexing.py | 27 +++++++++++-------- tiledb/tests/test_pandas_dataframe.py | 13 +++++++++ 3 files changed, 50 insertions(+), 29 deletions(-) diff --git a/tiledb/dataframe_.py b/tiledb/dataframe_.py index 5062d755de..808bc89bdb 100644 --- a/tiledb/dataframe_.py +++ b/tiledb/dataframe_.py @@ -2,7 +2,7 @@ import json import os import warnings -from dataclasses import dataclass +from dataclasses import dataclass, replace from typing import Any, List, Optional, Union import numpy as np @@ -162,15 +162,13 @@ def from_values(cls, array_like, varlen_types=()): f"{inferred_dtype} inferred dtype not supported (column {array_like.name})" ) elif hasattr(array_like, "dtype") and isinstance(array_like.dtype, StringDtype): - # Explicit pd.StringDtype() (name="string") is always nullable; - # auto-inferred str (name="str") depends on data - explicit = array_like.dtype.name == "string" - return cls( - np.dtype(np.str_), - repr="string" if explicit else None, - var=True, - nullable=explicit or bool(array_like.isna().any()), - ) + info = cls.from_dtype(array_like.dtype, array_like.name) + # the NaN-backed "str" dtype holds missing values without being a + # nullable dtype; write it as a nullable attribute only when + # missing values are actually present + if not info.nullable and array_like.isna().any(): + info = replace(info, nullable=True) + return info elif hasattr(array_like, "dtype") and isinstance( array_like.dtype, CategoricalDtype ): @@ -200,6 +198,7 @@ def from_categorical(cls, cat, dtype, column_name): @classmethod def from_dtype(cls, dtype, column_name, varlen_types=()): + from pandas import NA, StringDtype from pandas.api import types as pd_types if isinstance(dtype, str) and dtype == "ascii": @@ -211,13 +210,17 @@ def from_dtype(cls, dtype, column_name, varlen_types=()): dtype = pd_types.pandas_dtype(dtype) # Note: be careful if you rearrange the order of the following checks - # pandas StringDtype (auto-inferred 'str' and explicit 'string') - from pandas import StringDtype - + # pandas string types: the NA-backed "string" dtype maps to a nullable + # attribute, while the NaN-backed "str" dtype (the default for strings + # since pandas 3) behaves like plain str/object columns if isinstance(dtype, StringDtype): - repr_val = "string" if dtype.name == "string" else None - nullable = dtype.name == "string" - return cls(np.dtype(np.str_), repr=repr_val, var=True, nullable=nullable) + nullable = dtype.na_value is NA + return cls( + np.dtype(np.str_), + repr="string" if nullable else None, + var=True, + nullable=nullable, + ) # extension types if pd_types.is_extension_array_dtype(dtype): @@ -529,8 +532,8 @@ def _df_to_np_arrays(df, column_infos, fillna): if not column_info.var: to_numpy_kwargs.update(dtype=column_info.dtype) - if column_info.nullable and column.isna().any(): - # Only create nullmap if data actually has nulls + if column_info.nullable: + # use default 0/empty for the dtype to_numpy_kwargs.update(na_value=column_info.dtype.type()) nullmaps[name] = (~column.isna()).to_numpy(dtype=np.uint8) diff --git a/tiledb/multirange_indexing.py b/tiledb/multirange_indexing.py index 2bad9852e6..737780cf68 100644 --- a/tiledb/multirange_indexing.py +++ b/tiledb/multirange_indexing.py @@ -888,17 +888,22 @@ def _update_df_from_meta( if name in df: col_dtypes[name] = dtype - if col_dtypes: - # ''). Applying astype('= 3 it produces object or fixed-width bytes columns instead. + if dtype == "