diff --git a/pyproject.toml b/pyproject.toml index 03181ead..91139c40 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,6 +64,8 @@ extra = [ [dependency-groups] dev = [ "bump2version", + "prek>=0.4.11", + "ruff>=0.16.0", ] test = [ "pytest", @@ -244,3 +246,27 @@ 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 = ["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", "0.12-2"] + +[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.12,<0.13", if = ["0.12-2"]}, + {value="pandas>=2.3,<3", if = ["0.12-2"]}, +] diff --git a/src/spatialdata/_io/exceptions.py b/src/spatialdata/_io/exceptions.py new file mode 100644 index 00000000..66f5802b --- /dev/null +++ b/src/spatialdata/_io/exceptions.py @@ -0,0 +1,26 @@ +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) + + +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 03ef3338..bb203cad 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 276f016b..b9a2964f 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 3b6e18e3..f8528868 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 3eb4b092..da6ef9b5 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 @@ -9,6 +11,8 @@ 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, WritingToZarrV2DeprecationWarning from spatialdata._io.format import ( CurrentTablesFormat, TablesFormats, @@ -56,16 +60,43 @@ 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) 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.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 + 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_group = group[name] + else: + 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 + table_group.attrs["region_key"] = region_key + table_group.attrs["instance_key"] = instance_key + table_group.attrs["version"] = element_format.spatialdata_format_version diff --git a/tests/io/test_readwrite.py b/tests/io/test_readwrite.py index 034c01d3..7a8d403e 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])