From 1c1606c6bd05b46409cde523640f7549fca8edc0 Mon Sep 17 00:00:00 2001 From: Ajayrama Kumaraswamy Date: Mon, 27 Jul 2026 18:26:24 +0200 Subject: [PATCH 01/17] chore: added prek to dev deps --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 03181eadb..14e8040ca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,6 +64,7 @@ extra = [ [dependency-groups] dev = [ "bump2version", + "prek>=0.4.11", ] test = [ "pytest", From 2672c013bba9485028055580519eb3bd4dbebc1c Mon Sep 17 00:00:00 2001 From: Ajayrama Kumaraswamy Date: Mon, 27 Jul 2026 18:28:03 +0200 Subject: [PATCH 02/17] feat: writing table to zarr using AnnData.write_zarr for table version 2 --- src/spatialdata/_io/exceptions.py | 16 +++++++++++++ src/spatialdata/_io/io_table.py | 40 +++++++++++++++++++++++++------ 2 files changed, 49 insertions(+), 7 deletions(-) create mode 100644 src/spatialdata/_io/exceptions.py diff --git a/src/spatialdata/_io/exceptions.py b/src/spatialdata/_io/exceptions.py new file mode 100644 index 000000000..d12414451 --- /dev/null +++ b/src/spatialdata/_io/exceptions.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from ome_zarr.format import Format + + +class FormatVersionUnknownError(ValueError): + """Exception raised when an unknown element format is encountered.""" + + def __init__(self, element_type: str, version_encountered: Format): + self.element_type = element_type + self.version_encountered = version_encountered + self.message = ( + f"Encountered unknown element format version " + f"`{self.version_encountered}` for element of type `{self.element_type}`" + ) + super().__init__(self.message) diff --git a/src/spatialdata/_io/io_table.py b/src/spatialdata/_io/io_table.py index 3eb4b0927..d1a0aedd4 100644 --- a/src/spatialdata/_io/io_table.py +++ b/src/spatialdata/_io/io_table.py @@ -9,6 +9,7 @@ from anndata._io.specs import write_elem as write_adata from ome_zarr.format import Format +from spatialdata._io.exceptions import FormatVersionUnknownError from spatialdata._io.format import ( CurrentTablesFormat, TablesFormats, @@ -62,10 +63,35 @@ def write_table( else: region, region_key, instance_key = (None, None, None) - write_adata(group, name, table) - tables_group = group[name] - tables_group.attrs["spatialdata-encoding-type"] = group_type - tables_group.attrs["region"] = region - tables_group.attrs["region_key"] = region_key - tables_group.attrs["instance_key"] = instance_key - tables_group.attrs["version"] = element_format.spatialdata_format_version + # Ensure the table group exists + table_group = group.require_group(name=name) + + assert element_format in TablesFormats.values(), FormatVersionUnknownError( + element_type="table", version_encountered=element_format + ) + + if element_format == TablesFormatV02(): + # solution of passing path directly roughly based on: + # https://github.com/scverse/anndata/issues/1548#issuecomment-2199801855 + + # Write the table to the path of the table group + table.write_zarr(store=str(table_group.store_path), consolidate_metadata=False) + # anndata writes to zarr v3 by default, no way to specify, breaks our support for zarr v2 + # hence the workaround with if-else ladder + group = zarr.open_group(group.store_path, mode="a", use_consolidated=False) + table_group = group[name] + elif element_format == TablesFormatV01(): + write_adata(group, name, table) + table_group = group[name] + else: + raise NotImplementedError( + "This should be unreachable, please raise an issue on Github with this error message " + "and a minimum example that works standalone" + ) + # should be unreachable + + table_group.attrs["spatialdata-encoding-type"] = group_type + table_group.attrs["region"] = region + table_group.attrs["region_key"] = region_key + table_group.attrs["instance_key"] = instance_key + table_group.attrs["version"] = element_format.spatialdata_format_version From a88accbdf8662986d9c992dc8d88f90259e28fcf Mon Sep 17 00:00:00 2001 From: Ajayrama Kumaraswamy Date: Tue, 28 Jul 2026 14:22:19 +0200 Subject: [PATCH 03/17] chore: added ruff to dev deps --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 14e8040ca..cc2b573c4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,6 +65,7 @@ extra = [ dev = [ "bump2version", "prek>=0.4.11", + "ruff>=0.16.0", ] test = [ "pytest", From 26a928fa54f8b354aefcbfd41416838a6ce21e10 Mon Sep 17 00:00:00 2001 From: Ajayrama Kumaraswamy Date: Tue, 28 Jul 2026 15:14:37 +0200 Subject: [PATCH 04/17] fix: using internal resolve store function + categorical when writing tables to zarr v2 --- src/spatialdata/_io/io_table.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/spatialdata/_io/io_table.py b/src/spatialdata/_io/io_table.py index d1a0aedd4..8384a4704 100644 --- a/src/spatialdata/_io/io_table.py +++ b/src/spatialdata/_io/io_table.py @@ -9,6 +9,7 @@ from anndata._io.specs import write_elem as write_adata from ome_zarr.format import Format +from spatialdata._io._utils import _resolve_zarr_store from spatialdata._io.exceptions import FormatVersionUnknownError from spatialdata._io.format import ( CurrentTablesFormat, @@ -74,13 +75,17 @@ def write_table( # solution of passing path directly roughly based on: # https://github.com/scverse/anndata/issues/1548#issuecomment-2199801855 + # resolve the store from the group + # needed by `AnnData.write_zarr` below to directly write into the path of the group + resolved_store = _resolve_zarr_store(table_group) + # Write the table to the path of the table group - table.write_zarr(store=str(table_group.store_path), consolidate_metadata=False) + table.write_zarr(store=resolved_store, consolidate_metadata=False) # anndata writes to zarr v3 by default, no way to specify, breaks our support for zarr v2 # hence the workaround with if-else ladder - group = zarr.open_group(group.store_path, mode="a", use_consolidated=False) table_group = group[name] elif element_format == TablesFormatV01(): + table.strings_to_categoricals() write_adata(group, name, table) table_group = group[name] else: From 247cdc699bb173267f4adcf444cdf83f11d05888 Mon Sep 17 00:00:00 2001 From: Ajayrama Kumaraswamy Date: Tue, 28 Jul 2026 19:32:56 +0200 Subject: [PATCH 05/17] fix: test no longer expectes 'nan' after table round trip instead of pd.NA/np.nan --- tests/io/test_readwrite.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/tests/io/test_readwrite.py b/tests/io/test_readwrite.py index 034c01d37..7a8d403ee 100644 --- a/tests/io/test_readwrite.py +++ b/tests/io/test_readwrite.py @@ -16,7 +16,6 @@ import zarr from anndata import AnnData from numpy.random import default_rng -from packaging.version import Version from shapely import MultiPolygon, Polygon from upath import UPath from xarray import DataArray @@ -1294,8 +1293,7 @@ def test_sdata_with_nan_in_obs(tmp_path: Path) -> None: Regression test for https://github.com/scverse/spatialdata/issues/399 Previously this raised TypeError: expected unicode string, found nan. - Now the write succeeds, though NaN values in object-dtype columns are - converted to the string "nan" after round-trip. + Now the write succeeds, and NaN values are preserved round trip """ from spatialdata.models import TableModel @@ -1329,8 +1327,5 @@ def test_sdata_with_nan_in_obs(tmp_path: Path) -> None: assert r1.iloc[0] == "string" assert r2.iloc[1] == 3 - if Version(pd.__version__) >= Version("3"): - assert pd.isna(r1.iloc[1]) - else: # After round-trip, NaN in object-dtype column becomes string "nan" on pandas 2 - assert r1.iloc[1] == "nan" + assert pd.isna(r1.iloc[1]) assert np.isnan(r2.iloc[0]) From 75dcba925de716fada9f2c83093a4821ef52c05f Mon Sep 17 00:00:00 2001 From: Ajayrama Kumaraswamy Date: Tue, 28 Jul 2026 19:35:00 +0200 Subject: [PATCH 06/17] feat: added hatch env configs for testing version combinations of anndata/pandas --- pyproject.toml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index cc2b573c4..5104a9fba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -246,3 +246,24 @@ memray-flame = "memray flamegraph --temporal" [tool.pixi.environments] profiling = { features = ["profiling"], solve-group = "default" } + +[tool.hatch.envs.test] +dependency-groups = ["test"] + +[tool.hatch.envs.test-anndata-pandas] +template = "test" +extra-dependencies = ["zarr>=3"] +scripts.test-readwrite = ["pip list|grep anndata && pip list|grep pandas && pytest tests/io/test_readwrite.py"] +scripts.test-all = ["pip list|grep anndata && pip list|grep pandas && pytest ."] + +[[tool.hatch.envs.test-anndata-pandas.matrix]] +anndata-pandas = ["0.13-2", "0.13-3"] + +[tool.hatch.envs.test-anndata-pandas.overrides] +matrix.anndata-pandas.extra-dependencies = [ + # every option when if is True gets included + {value="anndata~=0.13", if = ["0.13-2"]}, + {value="pandas>=2.3,<3", if = ["0.13-2"]}, + {value="anndata~=0.13", if = ["0.13-3"]}, + {value="pandas~=3.0", if = ["0.13-3"]}, +] From 129bf9e315390998aa5a7269a30f62da3fc0989a Mon Sep 17 00:00:00 2001 From: Ajayrama Kumaraswamy Date: Fri, 31 Jul 2026 16:49:30 +0200 Subject: [PATCH 07/17] feat: update hatch config for testing pandas/anndata versions --- pyproject.toml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 5104a9fba..91139c400 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -253,17 +253,20 @@ dependency-groups = ["test"] [tool.hatch.envs.test-anndata-pandas] template = "test" extra-dependencies = ["zarr>=3"] +scripts.test = ["pip list|grep anndata && pip list|grep pandas && pytest {args}"] scripts.test-readwrite = ["pip list|grep anndata && pip list|grep pandas && pytest tests/io/test_readwrite.py"] scripts.test-all = ["pip list|grep anndata && pip list|grep pandas && pytest ."] [[tool.hatch.envs.test-anndata-pandas.matrix]] -anndata-pandas = ["0.13-2", "0.13-3"] +anndata-pandas = ["0.13-2", "0.13-3", "0.12-2"] [tool.hatch.envs.test-anndata-pandas.overrides] matrix.anndata-pandas.extra-dependencies = [ - # every option when if is True gets included + # every option where the if-condition is True gets included {value="anndata~=0.13", if = ["0.13-2"]}, {value="pandas>=2.3,<3", if = ["0.13-2"]}, {value="anndata~=0.13", if = ["0.13-3"]}, {value="pandas~=3.0", if = ["0.13-3"]}, + {value="anndata>=0.12,<0.13", if = ["0.12-2"]}, + {value="pandas>=2.3,<3", if = ["0.12-2"]}, ] From 5f4afca3fd813e342ec8ac50e87b38ae88092207 Mon Sep 17 00:00:00 2001 From: Ajayrama Kumaraswamy Date: Fri, 31 Jul 2026 16:52:19 +0200 Subject: [PATCH 08/17] feat: added DeprecationWarnings when writing to zarr v2 --- src/spatialdata/_io/exceptions.py | 10 ++++++++++ src/spatialdata/_io/io_points.py | 6 ++++++ src/spatialdata/_io/io_raster.py | 12 ++++++++++++ src/spatialdata/_io/io_shapes.py | 7 +++++++ src/spatialdata/_io/io_table.py | 26 +++++++++++++------------- 5 files changed, 48 insertions(+), 13 deletions(-) diff --git a/src/spatialdata/_io/exceptions.py b/src/spatialdata/_io/exceptions.py index d12414451..66f5802b7 100644 --- a/src/spatialdata/_io/exceptions.py +++ b/src/spatialdata/_io/exceptions.py @@ -14,3 +14,13 @@ def __init__(self, element_type: str, version_encountered: Format): f"`{self.version_encountered}` for element of type `{self.element_type}`" ) super().__init__(self.message) + + +class WritingToZarrV2DeprecationWarning(DeprecationWarning): + """Warning raised when writing to zarr v2 format.""" + + message = ( + "Writing to zarr v2 format is currently deprecated in spatialdata " + "and will be removed in a future version. " + "Please consider writing to zarr v3." + ) diff --git a/src/spatialdata/_io/io_points.py b/src/spatialdata/_io/io_points.py index 03ef33389..bb203cad2 100644 --- a/src/spatialdata/_io/io_points.py +++ b/src/spatialdata/_io/io_points.py @@ -1,5 +1,6 @@ from __future__ import annotations +import warnings from pathlib import Path import zarr @@ -12,6 +13,7 @@ _write_metadata, overwrite_coordinate_transformations_non_raster, ) +from spatialdata._io.exceptions import WritingToZarrV2DeprecationWarning from spatialdata._io.format import CurrentPointsFormat, PointsFormats, _parse_version from spatialdata.models import get_axes_names from spatialdata.transformations._utils import ( @@ -65,6 +67,10 @@ def write_points( element_format The format of the points element used to store it. """ + if element_format.zarr_format == 2: + warnings.warn( + message=WritingToZarrV2DeprecationWarning.message, category=WritingToZarrV2DeprecationWarning, stacklevel=2 + ) axes = get_axes_names(points) transformations = _get_transformations(points) assert transformations is not None # mypy: validate_element() in _write_element guarantees this diff --git a/src/spatialdata/_io/io_raster.py b/src/spatialdata/_io/io_raster.py index 276f016bd..b9a2964f0 100644 --- a/src/spatialdata/_io/io_raster.py +++ b/src/spatialdata/_io/io_raster.py @@ -1,5 +1,6 @@ from __future__ import annotations +import warnings from collections.abc import Sequence from pathlib import Path from typing import Any, Literal, TypeGuard, cast @@ -23,6 +24,7 @@ overwrite_channel_names, overwrite_coordinate_transformations_raster, ) +from spatialdata._io.exceptions import WritingToZarrV2DeprecationWarning from spatialdata._io.format import ( CurrentRasterFormat, RasterFormatType, @@ -581,6 +583,11 @@ def write_image( raster_compressor: dict[Literal["lz4", "zstd"], int] | None = None, **metadata: str | JSONDict | list[JSONDict], ) -> None: + if element_format.zarr_format == 2: + warnings.warn( + message=WritingToZarrV2DeprecationWarning.message, category=WritingToZarrV2DeprecationWarning, stacklevel=2 + ) + _write_raster( raster_type="image", raster_data=image, @@ -603,6 +610,11 @@ def write_labels( raster_compressor: dict[Literal["lz4", "zstd"], int] | None = None, **metadata: JSONDict, ) -> None: + if element_format.zarr_format == 2: + warnings.warn( + message=WritingToZarrV2DeprecationWarning.message, category=WritingToZarrV2DeprecationWarning, stacklevel=2 + ) + _write_raster( raster_type="labels", raster_data=labels, diff --git a/src/spatialdata/_io/io_shapes.py b/src/spatialdata/_io/io_shapes.py index 3b6e18e39..f8528868d 100644 --- a/src/spatialdata/_io/io_shapes.py +++ b/src/spatialdata/_io/io_shapes.py @@ -1,5 +1,6 @@ from __future__ import annotations +import warnings from pathlib import Path from typing import Any, Literal @@ -15,6 +16,7 @@ _write_metadata, overwrite_coordinate_transformations_non_raster, ) +from spatialdata._io.exceptions import WritingToZarrV2DeprecationWarning from spatialdata._io.format import ( CurrentShapesFormat, ShapesFormats, @@ -93,6 +95,11 @@ def write_shapes( Whether to use the WKB or geoarrow encoding for GeoParquet. See :meth:`geopandas.GeoDataFrame.to_parquet` for details. If None, uses the value from :attr:`spatialdata.settings.shapes_geometry_encoding`. """ + if element_format.zarr_format == 2: + warnings.warn( + message=WritingToZarrV2DeprecationWarning.message, category=WritingToZarrV2DeprecationWarning, stacklevel=2 + ) + from spatialdata.config import settings if geometry_encoding is None: diff --git a/src/spatialdata/_io/io_table.py b/src/spatialdata/_io/io_table.py index 8384a4704..da6ef9b5c 100644 --- a/src/spatialdata/_io/io_table.py +++ b/src/spatialdata/_io/io_table.py @@ -1,5 +1,7 @@ from __future__ import annotations +import warnings +from importlib.metadata import version from pathlib import Path import numpy as np @@ -10,7 +12,7 @@ from ome_zarr.format import Format from spatialdata._io._utils import _resolve_zarr_store -from spatialdata._io.exceptions import FormatVersionUnknownError +from spatialdata._io.exceptions import FormatVersionUnknownError, WritingToZarrV2DeprecationWarning from spatialdata._io.format import ( CurrentTablesFormat, TablesFormats, @@ -58,6 +60,11 @@ def write_table( group_type: str = "ngff:regions_table", element_format: Format = CurrentTablesFormat(), ) -> None: + if element_format.zarr_format == 2: + warnings.warn( + message=WritingToZarrV2DeprecationWarning.message, category=WritingToZarrV2DeprecationWarning, stacklevel=2 + ) + if TableModel.ATTRS_KEY in table.uns: region, region_key, instance_key = get_table_keys(table) TableModel.validate(table) @@ -71,29 +78,22 @@ def write_table( element_type="table", version_encountered=element_format ) - if element_format == TablesFormatV02(): - # solution of passing path directly roughly based on: + if element_format.zarr_format == 3 and version("anndata") >= "0.13": + # `write_zarr` in anndata v0.13 and above can only write to zarr v3 + # solution of passing resolved store directly roughly based on: # https://github.com/scverse/anndata/issues/1548#issuecomment-2199801855 # resolve the store from the group - # needed by `AnnData.write_zarr` below to directly write into the path of the group resolved_store = _resolve_zarr_store(table_group) # Write the table to the path of the table group table.write_zarr(store=resolved_store, consolidate_metadata=False) - # anndata writes to zarr v3 by default, no way to specify, breaks our support for zarr v2 - # hence the workaround with if-else ladder + table_group = group[name] - elif element_format == TablesFormatV01(): + else: table.strings_to_categoricals() write_adata(group, name, table) table_group = group[name] - else: - raise NotImplementedError( - "This should be unreachable, please raise an issue on Github with this error message " - "and a minimum example that works standalone" - ) - # should be unreachable table_group.attrs["spatialdata-encoding-type"] = group_type table_group.attrs["region"] = region From 9d5c7dcd0d9cd10765b3e4666efe6b5824663e71 Mon Sep 17 00:00:00 2001 From: Ajayrama Kumaraswamy Date: Mon, 10 Aug 2026 14:26:13 +0200 Subject: [PATCH 09/17] fix: if-raise instead of assert --- src/spatialdata/_io/io_table.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/spatialdata/_io/io_table.py b/src/spatialdata/_io/io_table.py index da6ef9b5c..7135cb5dd 100644 --- a/src/spatialdata/_io/io_table.py +++ b/src/spatialdata/_io/io_table.py @@ -74,9 +74,8 @@ def write_table( # Ensure the table group exists table_group = group.require_group(name=name) - assert element_format in TablesFormats.values(), FormatVersionUnknownError( - element_type="table", version_encountered=element_format - ) + if element_format not in TablesFormats.values(): + raise FormatVersionUnknownError(element_type="table", version_encountered=element_format) if element_format.zarr_format == 3 and version("anndata") >= "0.13": # `write_zarr` in anndata v0.13 and above can only write to zarr v3 From 20d9c55e55efc72cee3621c56b607f98f4b5c401 Mon Sep 17 00:00:00 2001 From: Ajayrama Kumaraswamy Date: Mon, 10 Aug 2026 16:41:18 +0200 Subject: [PATCH 10/17] refac + doc: pyproject.toml --- pyproject.toml | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 91139c400..8c33e5e01 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,8 +64,6 @@ extra = [ [dependency-groups] dev = [ "bump2version", - "prek>=0.4.11", - "ruff>=0.16.0", ] test = [ "pytest", @@ -258,15 +256,18 @@ scripts.test-readwrite = ["pip list|grep anndata && pip list|grep pandas && pyte scripts.test-all = ["pip list|grep anndata && pip list|grep pandas && pytest ."] [[tool.hatch.envs.test-anndata-pandas.matrix]] -anndata-pandas = ["0.13-2", "0.13-3", "0.12-2"] +anndata-pandas = [ + "0.13-2", + "0.13-3", + "0.12-2" + # pandas v2 is supported only for anndata<0.13 +] [tool.hatch.envs.test-anndata-pandas.overrides] matrix.anndata-pandas.extra-dependencies = [ # every option where the if-condition is True gets included - {value="anndata~=0.13", if = ["0.13-2"]}, - {value="pandas>=2.3,<3", if = ["0.13-2"]}, - {value="anndata~=0.13", if = ["0.13-3"]}, - {value="pandas~=3.0", if = ["0.13-3"]}, + {value="anndata~=0.13", if = ["0.13-2", "0.13-3"]}, {value="anndata>=0.12,<0.13", if = ["0.12-2"]}, - {value="pandas>=2.3,<3", if = ["0.12-2"]}, + {value="pandas>=2.3,<3", if = ["0.13-2", "0.12-2"]}, + {value="pandas~=3.0", if = ["0.13-3"]}, ] From 1a3c318bf6ab68ccb1154f4a6955d78d3ed4c989 Mon Sep 17 00:00:00 2001 From: Ajayrama Kumaraswamy Date: Mon, 10 Aug 2026 18:39:14 +0200 Subject: [PATCH 11/17] fix: made version comparison of anndata more robust --- src/spatialdata/_io/io_table.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/spatialdata/_io/io_table.py b/src/spatialdata/_io/io_table.py index 7135cb5dd..ef85bd353 100644 --- a/src/spatialdata/_io/io_table.py +++ b/src/spatialdata/_io/io_table.py @@ -10,6 +10,7 @@ from anndata import read_zarr as read_anndata_zarr from anndata._io.specs import write_elem as write_adata from ome_zarr.format import Format +from packaging.version import Version from spatialdata._io._utils import _resolve_zarr_store from spatialdata._io.exceptions import FormatVersionUnknownError, WritingToZarrV2DeprecationWarning @@ -77,7 +78,7 @@ def write_table( if element_format not in TablesFormats.values(): raise FormatVersionUnknownError(element_type="table", version_encountered=element_format) - if element_format.zarr_format == 3 and version("anndata") >= "0.13": + if element_format.zarr_format == 3 and Version(version("anndata")) >= Version("0.13"): # `write_zarr` in anndata v0.13 and above can only write to zarr v3 # solution of passing resolved store directly roughly based on: # https://github.com/scverse/anndata/issues/1548#issuecomment-2199801855 From 74ab61f53c55609bdc9aac1db6364ba588a8bf10 Mon Sep 17 00:00:00 2001 From: Ajayrama Kumaraswamy Date: Wed, 12 Aug 2026 12:01:40 +0200 Subject: [PATCH 12/17] fix: doc string --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 8c33e5e01..2f08e955f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -260,7 +260,7 @@ anndata-pandas = [ "0.13-2", "0.13-3", "0.12-2" - # pandas v2 is supported only for anndata<0.13 + # support for pandas>=3 is available only in anndata>=0.13 ] [tool.hatch.envs.test-anndata-pandas.overrides] From 2d742adde3c51af359e6fdd17746ddca5043a32d Mon Sep 17 00:00:00 2001 From: Ajayrama Kumaraswamy Date: Wed, 12 Aug 2026 12:05:08 +0200 Subject: [PATCH 13/17] fix: made converting table strings to categorical optional + non-default --- src/spatialdata/_core/spatialdata.py | 7 +++++++ src/spatialdata/_io/io_table.py | 11 +++++++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/spatialdata/_core/spatialdata.py b/src/spatialdata/_core/spatialdata.py index fb55ab086..ef185d84f 100644 --- a/src/spatialdata/_core/spatialdata.py +++ b/src/spatialdata/_core/spatialdata.py @@ -1114,6 +1114,7 @@ def write( sdata_formats: SpatialDataFormatType | list[SpatialDataFormatType] | None = None, shapes_geometry_encoding: Literal["WKB", "geoarrow"] | None = None, raster_compressor: dict[Literal["lz4", "zstd"], int] | None = None, + convert_table_strings_to_categoricals: bool = False, ) -> None: """ Write the `SpatialData` object to a Zarr store. @@ -1194,6 +1195,7 @@ def write( parsed_formats=parsed, shapes_geometry_encoding=shapes_geometry_encoding, raster_compressor=raster_compressor, + convert_table_strings_to_categoricals=convert_table_strings_to_categoricals, ) if self.path != file_path and update_sdata_path: @@ -1212,6 +1214,7 @@ def _write_element( parsed_formats: dict[str, SpatialDataFormatType] | None = None, shapes_geometry_encoding: Literal["WKB", "geoarrow"] | None = None, raster_compressor: dict[Literal["lz4", "zstd"], int] | None = None, + convert_table_strings_to_categoricals: bool = False, ) -> None: from spatialdata._io.io_zarr import _get_groups_for_element @@ -1279,6 +1282,7 @@ def _write_element( group=element_type_group, name=element_name, element_format=parsed_formats["tables"], + convert_strings_to_categoricals=convert_table_strings_to_categoricals, ) else: raise ValueError(f"Unknown element type: {element_type}") @@ -1290,6 +1294,7 @@ def write_element( sdata_formats: SpatialDataFormatType | list[SpatialDataFormatType] | None = None, shapes_geometry_encoding: Literal["WKB", "geoarrow"] | None = None, raster_compressor: dict[Literal["lz4", "zstd"], int] | None = None, + convert_table_strings_to_categoricals: bool = False, ) -> None: """ Write a single element, or a list of elements, to the Zarr store used for backing. @@ -1332,6 +1337,7 @@ def write_element( sdata_formats=sdata_formats, shapes_geometry_encoding=shapes_geometry_encoding, raster_compressor=raster_compressor, + convert_table_strings_to_categoricals=convert_table_strings_to_categoricals, ) return @@ -1368,6 +1374,7 @@ def write_element( parsed_formats=parsed_formats, shapes_geometry_encoding=shapes_geometry_encoding, raster_compressor=raster_compressor, + convert_table_strings_to_categoricals=convert_table_strings_to_categoricals, ) # After every write, metadata should be consolidated, otherwise this can lead to IO problems like when deleting. if self.has_consolidated_metadata(): diff --git a/src/spatialdata/_io/io_table.py b/src/spatialdata/_io/io_table.py index ef85bd353..a31e3e974 100644 --- a/src/spatialdata/_io/io_table.py +++ b/src/spatialdata/_io/io_table.py @@ -60,6 +60,7 @@ def write_table( name: str, group_type: str = "ngff:regions_table", element_format: Format = CurrentTablesFormat(), + convert_strings_to_categoricals: bool = False, ) -> None: if element_format.zarr_format == 2: warnings.warn( @@ -87,11 +88,17 @@ def write_table( resolved_store = _resolve_zarr_store(table_group) # Write the table to the path of the table group - table.write_zarr(store=resolved_store, consolidate_metadata=False) + table.write_zarr( + store=resolved_store, + consolidate_metadata=False, + convert_strings_to_categoricals=convert_strings_to_categoricals, + ) table_group = group[name] else: - table.strings_to_categoricals() + if convert_strings_to_categoricals: + table.strings_to_categoricals() + write_adata(group, name, table) table_group = group[name] From 5ab01485ad997f27771bf70b7be34cd0634f6a1f Mon Sep 17 00:00:00 2001 From: Ajayrama Kumaraswamy Date: Wed, 12 Aug 2026 16:53:29 +0200 Subject: [PATCH 14/17] feat: documentation strings --- src/spatialdata/_core/spatialdata.py | 8 +++++++- src/spatialdata/_io/io_table.py | 19 +++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/spatialdata/_core/spatialdata.py b/src/spatialdata/_core/spatialdata.py index ef185d84f..1baa1c33d 100644 --- a/src/spatialdata/_core/spatialdata.py +++ b/src/spatialdata/_core/spatialdata.py @@ -1167,6 +1167,9 @@ def write( compression level which should be inclusive between 0 and 9. For compression, `lz4` and `zstd` are supported. If not specified, the compression will be `lz4` with compression level 5. Bytes are automatically ordered for more efficient compression. + convert_table_strings_to_categoricals + If True, convert string columns of all tables to categoricals before writing. + Note that this will have a side effect of modifying string columns into categoricals in place. """ from spatialdata._io._utils import _resolve_zarr_store, _validate_compressor_args from spatialdata._io.format import _parse_formats @@ -1313,11 +1316,14 @@ def write_element( shapes_geometry_encoding Whether to use the WKB or geoarrow encoding for GeoParquet. See :meth:`geopandas.GeoDataFrame.to_parquet` for details. If None, uses the value from :attr:`spatialdata.settings.shapes_geometry_encoding`. - raster_compressor + raster_compressor A lenght-1 dictionary with as key the type of compression to use for images and labels and as value the compression level which should be inclusive between 0 and 9. For compression, `lz4` and `zstd` are supported. If not specified, the compression will be `lz4` with compression level 5. Bytes are automatically ordered for more efficient compression. + convert_table_strings_to_categoricals + If True, and if element to be written is a table, convert string columns to categoricals before writing. + Note that this will have a side effect of modifying string columns into categoricals in place. Notes ----- diff --git a/src/spatialdata/_io/io_table.py b/src/spatialdata/_io/io_table.py index a31e3e974..d9f021631 100644 --- a/src/spatialdata/_io/io_table.py +++ b/src/spatialdata/_io/io_table.py @@ -62,6 +62,25 @@ def write_table( element_format: Format = CurrentTablesFormat(), convert_strings_to_categoricals: bool = False, ) -> None: + """ + Write a table to a Zarr store. + + Parameters + ---------- + table + The table to write. + group + The table will be written into a subgroup of this group + name + The name of the subgroup of `group` to which table is to be written. + group_type + The type of the group. + element_format + The format to use for writing the table. + convert_strings_to_categoricals + If True, convert string columns to categoricals before writing. + Note that this will have a side effect of modifying dtypes of the input table in place. + """ if element_format.zarr_format == 2: warnings.warn( message=WritingToZarrV2DeprecationWarning.message, category=WritingToZarrV2DeprecationWarning, stacklevel=2 From ef2901c56a8568537d01bccc0aa98f8269152c62 Mon Sep 17 00:00:00 2001 From: Ajayrama Kumaraswamy Date: Wed, 12 Aug 2026 19:56:08 +0200 Subject: [PATCH 15/17] feat: revised tests to work with new write parameter --- tests/io/test_readwrite.py | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/tests/io/test_readwrite.py b/tests/io/test_readwrite.py index 7a8d403ee..609bdc12f 100644 --- a/tests/io/test_readwrite.py +++ b/tests/io/test_readwrite.py @@ -4,6 +4,7 @@ import os import tempfile from collections.abc import Callable +from importlib.metadata import version from pathlib import Path from typing import Any, Literal @@ -16,6 +17,8 @@ import zarr from anndata import AnnData from numpy.random import default_rng +from packaging.version import Version +from pandas.testing import assert_series_equal from shapely import MultiPolygon, Polygon from upath import UPath from xarray import DataArray @@ -1288,7 +1291,8 @@ def test_read_sdata(tmp_path: Path, points: SpatialData) -> None: assert_spatial_data_objects_are_identical(sdata_from_path, sdata_from_zarr_group) -def test_sdata_with_nan_in_obs(tmp_path: Path) -> None: +@pytest.mark.parametrize("convert_strings_to_categoricals", (True, False)) +def test_sdata_with_nan_in_obs(tmp_path: Path, convert_strings_to_categoricals: bool) -> None: """Test writing SpatialData with mixed string/NaN values in obs works correctly. Regression test for https://github.com/scverse/spatialdata/issues/399 @@ -1317,8 +1321,17 @@ def test_sdata_with_nan_in_obs(tmp_path: Path) -> None: assert sdata["table"].obs["column_only_region1"].iloc[1] is np.nan assert np.isnan(sdata["table"].obs["column_only_region2"].iloc[0]) + dtypes_before_writing = sdata["table"].obs.dtypes.copy() + path = tmp_path / "data.zarr" - sdata.write(path) + sdata.write(path, convert_table_strings_to_categoricals=convert_strings_to_categoricals) + + if convert_strings_to_categoricals: + expected_dtypes = dtypes_before_writing.copy() + expected_dtypes["column_only_region1"] = "category" + assert_series_equal(sdata["table"].obs.dtypes, expected_dtypes) + else: + assert_series_equal(sdata["table"].obs.dtypes, dtypes_before_writing) sdata2 = SpatialData.read(path) assert "column_only_region1" in sdata2["table"].obs.columns @@ -1327,5 +1340,12 @@ def test_sdata_with_nan_in_obs(tmp_path: Path) -> None: assert r1.iloc[0] == "string" assert r2.iloc[1] == 3 - assert pd.isna(r1.iloc[1]) assert np.isnan(r2.iloc[0]) + + if Version(version("pandas")) >= Version("3"): + assert pd.isna(r1.iloc[1]) + else: # After round-trip, NaN in object-dtype column becomes string + if convert_strings_to_categoricals: + assert pd.isna(r1.iloc[1]) + else: + assert r1.iloc[1] == "nan" From 08b811ca7cdfcca0a470e1435bdc8ffa38831f36 Mon Sep 17 00:00:00 2001 From: Ajayrama Kumaraswamy Date: Wed, 12 Aug 2026 20:10:44 +0200 Subject: [PATCH 16/17] fix: removed unnecessary redeclaration of table group --- src/spatialdata/_io/io_table.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/spatialdata/_io/io_table.py b/src/spatialdata/_io/io_table.py index d9f021631..931572389 100644 --- a/src/spatialdata/_io/io_table.py +++ b/src/spatialdata/_io/io_table.py @@ -113,13 +113,11 @@ def write_table( convert_strings_to_categoricals=convert_strings_to_categoricals, ) - table_group = group[name] else: if convert_strings_to_categoricals: table.strings_to_categoricals() write_adata(group, name, table) - table_group = group[name] table_group.attrs["spatialdata-encoding-type"] = group_type table_group.attrs["region"] = region From 14c23b0d665d363a7a43fcc31e1d8e631efdef6e Mon Sep 17 00:00:00 2001 From: Ajayrama Kumaraswamy Date: Fri, 14 Aug 2026 14:43:15 +0200 Subject: [PATCH 17/17] fix: doc string improvements --- pyproject.toml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 2f08e955f..eebd22b06 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -266,8 +266,16 @@ anndata-pandas = [ [tool.hatch.envs.test-anndata-pandas.overrides] matrix.anndata-pandas.extra-dependencies = [ # every option where the if-condition is True gets included + + # anndata 0.13 {value="anndata~=0.13", if = ["0.13-2", "0.13-3"]}, + + # anndata 0.12 {value="anndata>=0.12,<0.13", if = ["0.12-2"]}, + + # pandas 2 {value="pandas>=2.3,<3", if = ["0.13-2", "0.12-2"]}, + + # pandas 3 {value="pandas~=3.0", if = ["0.13-3"]}, ]