Skip to content

Commit 78d0fe6

Browse files
timtreisclaudeLucaMarconato
authored
Migrate supported Python to 3.12, 3.13, 3.14 (#1151)
* Migrate supported Python to 3.12, 3.13, 3.14 Drop Python 3.11 (anndata>=0.12 already requires >=3.12, so 3.11 was effectively broken) and add 3.14. - pyproject.toml: requires-python ">=3.12", ruff target-version py312 - .mypy.ini: python_version 3.12 - test.yaml: matrix 3.12/3.13/3.14; repoint bleeding-edge deps job to 3.14 and drop the obsolete requires-python sed hack Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Adopt PEP 695 type aliases for py312 (ruff UP040) The target-version bump to py312 enables ruff UP040. Rewrite the four explicit TypeAlias declarations to the `type` keyword and drop the now unused TypeAlias imports. Annotation-only aliases (the repo uses `from __future__ import annotations`), so no runtime behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Fix infinite recursion in dataloader.__getattr__ The module __getattr__ fell through to `getattr(spatialdata.dataloader, attr_name)` for any unknown name, re-entering itself indefinitely (RecursionError) instead of raising AttributeError per PEP 562. This was latent until the docs build hit it: the PEP 695 `type` aliases live in private modules, so sphinx-autodoc-typehints probes every `spatialdata.*` submodule with getattr() looking for a public re-export, tripping the recursion and failing the RTD build. Raise AttributeError for unknown names; drop the now-unused `import spatialdata` and tighten the return type to type[ImageTilesDataset]. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Pin ome_zarr<0.18.0 ome_zarr 0.18 switched to the NGFF 0.5 layout: channel metadata moved out of the `omero` block, so overwrite_channel_names() in _io/_utils.py gets None and crashes (~48 IO test failures). This breaks main independently of the Python bump. Pin as a stopgap until NGFF 0.5 is supported. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Support ome_zarr 0.18 by writing omero channel metadata ourselves ome_zarr 0.18 refactored the functional write_image/write_multiscale entrypoints (ome/ome-zarr-py#515) to read omero from the top-level metadata dict; spatialdata passes it nested under metadata["metadata"], so 0.18 silently dropped it. Effects: write_channel_names() crashed (omero block absent) and plain write->read lost channel names entirely (["r","g","b"] came back as [0,1,2]). Instead of depending on ome-zarr-py to emit omero, write it ourselves: - _write_raster() now calls overwrite_channel_names() after every image write, so the omero block is always present (idempotent on 0.17). - overwrite_channel_names() defaults to an empty omero block when none exists yet. Pin ome_zarr>=0.18 so CI resolves the same version a fresh install gets (uv otherwise lands on 0.17, hiding 0.18 regressions). Verified: full tests/io suite (227) passes on 0.18. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Clean up: drop redundant omero metadata build, lean comments overwrite_channel_names() now writes the omero channel block on every image write, so building the same metadata to pass into the ome-zarr-py writer (which 0.18 ignores anyway) was dead duplication. Remove it along with the now-unused get_channel_names import, and tighten comments. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Require integer (or bool) dtype for labels ome_zarr 0.18's label writer auto-parses unique label values and validates each `label-value` as an integer (via ome-zarr-models), which rejected spatialdata's float-dtype labels. Float labels are meaningless for segmentation masks and inconsistent with the rest of the codebase (fixtures, rasterize, relabel_sequential all assume integers), so the correct fix is to enforce it: Labels{2,3}DModel.parse now rejects non-integer/bool data with a clear error. Tests that fed float data to label models (reusing image-style generators) now use integers; test_rasterize_bins_invalid casts a parsed integer label to float to still exercise rasterize_bins' own guard. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Relax ome_zarr pin to >=0.16 Our omero self-write and integer-label fixes work across 0.16/0.17/0.18 (verified), so keep the wider lower bound for ecosystem co-installability rather than forcing >=0.18. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * improve labels validation logic --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Luca Marconato <m.lucalmer@gmail.com>
1 parent af51100 commit 78d0fe6

15 files changed

Lines changed: 55 additions & 66 deletions

File tree

.github/workflows/test.yaml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,12 @@ jobs:
1919
fail-fast: false
2020
matrix:
2121
include:
22-
- {os: windows-latest, python: "3.11", dask-version: "2026.3.0", name: "min dask"}
22+
- {os: windows-latest, python: "3.12", dask-version: "2026.3.0", name: "min dask"}
2323
- {os: windows-latest, python: "3.14", dask-version: "latest"}
24-
- {os: ubuntu-latest, python: "3.11", dask-version: "latest"}
24+
- {os: ubuntu-latest, python: "3.12", dask-version: "latest"}
25+
- {os: ubuntu-latest, python: "3.13", dask-version: "latest"}
2526
- {os: ubuntu-latest, python: "3.14", dask-version: "latest"}
26-
- {os: macos-latest, python: "3.11", dask-version: "latest"}
27+
- {os: macos-latest, python: "3.12", dask-version: "latest"}
2728
- {os: macos-latest, python: "3.14", prerelease: "allow", name: "prerelease"}
2829
env:
2930
OS: ${{ matrix.os }}
@@ -41,7 +42,6 @@ jobs:
4142
- name: Install dependencies
4243
run: |
4344
if [[ "${PRERELEASE}" == "allow" ]]; then
44-
sed -i '' 's/requires-python.*//' pyproject.toml # otherwise uv complains that anndata requires python>=3.12 and we only do >=3.11 😱
4545
uv add git+https://github.com/scverse/anndata.git
4646
uv add pandas>=3.dev0
4747
fi

.mypy.ini

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
[mypy]
2-
python_version = 3.11
2+
python_version = 3.12
33

44
ignore_errors = False
55
warn_redundant_casts = True

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ maintainers = [
1515
urls.Documentation = "https://spatialdata.scverse.org/en/latest"
1616
urls.Source = "https://github.com/scverse/spatialdata.git"
1717
urls.Home-page = "https://github.com/scverse/spatialdata.git"
18-
requires-python = ">=3.11"
18+
requires-python = ">=3.12"
1919
dynamic= [
2020
"version" # allow version to be set by git tags
2121
]
@@ -145,7 +145,7 @@ exclude = [
145145

146146
]
147147
line-length = 120
148-
target-version = "py311"
148+
target-version = "py312"
149149

150150
[tool.ruff.lint]
151151
ignore = [

src/spatialdata/_core/operations/rasterize_bins.py

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -87,14 +87,10 @@ def rasterize_bins(
8787
table = sdata.tables[table_name]
8888
if not isinstance(element, GeoDataFrame | DaskDataFrame | DataArray):
8989
raise ValueError("The bins should be a GeoDataFrame, a DaskDataFrame or a DataArray.")
90-
if isinstance(element, DataArray):
91-
if "c" in element.dims:
92-
raise ValueError(
93-
"If bins is a DataArray, it should hold labels; found a image element instead, with"
94-
f" 'c': {element.dims}."
95-
)
96-
if not np.issubdtype(element.dtype, np.integer):
97-
raise ValueError(f"If bins is a DataArray, it should hold integers. Found dtype {element.dtype}.")
90+
if isinstance(element, DataArray) and "c" in element.dims:
91+
raise ValueError(
92+
f"If bins is a DataArray, it should hold labels; found a image element instead, with 'c': {element.dims}."
93+
)
9894

9995
_, region_key, instance_key = get_table_keys(table)
10096
if not table.obs[region_key].dtype == "category":

src/spatialdata/_core/query/spatial_query.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -391,7 +391,7 @@ def _bounding_box_mask_points(
391391
axes: tuple[str, ...],
392392
min_coordinate: list[Number] | ArrayLike,
393393
max_coordinate: list[Number] | ArrayLike,
394-
) -> list[ArrayLike]:
394+
) -> list[np.ndarray]:
395395
"""Compute a mask that is true for the points inside axis-aligned bounding boxes.
396396
397397
Parameters

src/spatialdata/_io/_utils.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -154,8 +154,8 @@ def overwrite_channel_names(group: zarr.Group, element: DataArray | DataTree) ->
154154
channel_names = element["scale0"]["image"].coords["c"].data.tolist()
155155

156156
channel_metadata = [{"label": name} for name in channel_names]
157-
# This is required here as we do not use the load node API of ome-zarr
158-
omero_meta = group.attrs.get("omero", None) or group.attrs.get("ome", {}).get("omero")
157+
# We don't use the ome-zarr load node API, and ome-zarr-py >= 0.18 emits no `omero` block, so default to empty.
158+
omero_meta = group.attrs.get("omero") or group.attrs.get("ome", {}).get("omero") or {}
159159
omero_meta["channels"] = channel_metadata
160160
if ome_meta := group.attrs.get("ome", None):
161161
ome_meta["omero"] = omero_meta

src/spatialdata/_io/io_raster.py

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020

2121
from spatialdata._io._utils import (
2222
_get_transformations_from_ngff_dict,
23+
overwrite_channel_names,
2324
overwrite_coordinate_transformations_raster,
2425
)
2526
from spatialdata._io.format import (
@@ -28,7 +29,6 @@
2829
get_ome_zarr_format,
2930
)
3031
from spatialdata._utils import get_pyramid_levels
31-
from spatialdata.models._utils import get_channel_names
3232
from spatialdata.models.models import ATTRS_KEY
3333
from spatialdata.models.pyramids_utils import dask_arrays_to_datatree
3434
from spatialdata.transformations._utils import (
@@ -301,13 +301,6 @@ def _write_raster(
301301
metadata["name"] = name
302302
metadata["label_metadata"] = label_metadata
303303

304-
# convert channel names to channel metadata in omero
305-
if raster_type == "image":
306-
metadata["metadata"] = {"omero": {"channels": []}}
307-
channels = get_channel_names(raster_data)
308-
for c in channels:
309-
metadata["metadata"]["omero"]["channels"].append({"label": c}) # type: ignore[union-attr, index, call-overload]
310-
311304
if isinstance(raster_data, DataArray):
312305
_write_raster_dataarray(
313306
raster_type,
@@ -334,6 +327,9 @@ def _write_raster(
334327
raise ValueError("Not a valid labels object")
335328

336329
group = group["labels"][name] if raster_type == "labels" else group
330+
if raster_type == "image":
331+
# ome-zarr-py >= 0.18 no longer writes the omero channel metadata, so we write it ourselves.
332+
overwrite_channel_names(group, raster_data)
337333
if ATTRS_KEY not in group.attrs:
338334
group.attrs[ATTRS_KEY] = {}
339335
attrs = group.attrs[ATTRS_KEY]

src/spatialdata/_types.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from __future__ import annotations
22

3-
from typing import Any, TypeAlias
3+
from typing import Any
44

55
import numpy as np
66
from xarray import DataArray, DataTree
@@ -12,5 +12,5 @@
1212
ArrayLike = NDArray[np.floating[Any]]
1313
IntArrayLike = NDArray[np.integer[Any]]
1414

15-
Raster_T: TypeAlias = DataArray | DataTree
15+
type Raster_T = DataArray | DataTree
1616
ColorLike = tuple[float, ...] | str
Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
from __future__ import annotations
22

3-
from typing import TYPE_CHECKING, Any
4-
5-
import spatialdata
3+
from typing import TYPE_CHECKING
64

75
if TYPE_CHECKING:
86
from spatialdata.dataloader.datasets import ImageTilesDataset
@@ -12,10 +10,10 @@
1210
]
1311

1412

15-
def __getattr__(attr_name: str) -> ImageTilesDataset | Any:
13+
def __getattr__(attr_name: str) -> type[ImageTilesDataset]:
1614
if attr_name == "ImageTilesDataset":
1715
from spatialdata.dataloader.datasets import ImageTilesDataset
1816

1917
return ImageTilesDataset
2018

21-
return getattr(spatialdata.dataloader, attr_name)
19+
raise AttributeError(f"module {__name__!r} has no attribute {attr_name!r}")

src/spatialdata/models/_utils.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import warnings
44
from functools import singledispatch
5-
from typing import TYPE_CHECKING, Any, TypeAlias
5+
from typing import TYPE_CHECKING, Any
66

77
import dask.dataframe as dd
88
import geopandas
@@ -17,7 +17,7 @@
1717
from spatialdata._utils import _check_match_length_channels_c_dim
1818
from spatialdata.transformations.transformations import BaseTransformation
1919

20-
SpatialElement: TypeAlias = DataArray | DataTree | GeoDataFrame | DaskDataFrame
20+
type SpatialElement = DataArray | DataTree | GeoDataFrame | DaskDataFrame
2121
TRANSFORM_KEY = "transform"
2222
DEFAULT_COORDINATE_SYSTEM = "global"
2323
ValidAxis_t = str

0 commit comments

Comments
 (0)