From 0ef2263e981cd91427bb284563c596ce0f572f75 Mon Sep 17 00:00:00 2001 From: Tomatokeftes <129113023+Tomatokeftes@users.noreply.github.com> Date: Thu, 30 Jul 2026 23:19:34 +0200 Subject: [PATCH] build!: drop Python 3.11 and upgrade the spatialdata cluster spatialdata 0.8.0 and anndata 0.13 both require Python >= 3.12, so the cluster cannot move while 3.11 is supported. Dropping 3.11 unblocks all of it and makes the pandas 3 string-dtype coercion dead code. Pins, all exercised together: python >=3.12, <3.14 dask >=2026.3.0, <2026.8 (2026.7.1) spatialdata >=0.8.0, <0.9 (0.8.0) ome-zarr >=0.16.0, <0.19 (0.18.0) anndata >=0.13.2, <0.14 (0.13.2) zarr unchanged (3.1.3) The anndata floor is 0.13.2 rather than 0.13.0 deliberately. 0.13.0 imports typing_extensions.sentinel but declares no floor for typing-extensions, so a resolver prefers it, pairs it with 4.14.1 and yields an environment that installs cleanly then fails at import. 0.13.2 declares the floor correctly. Removes _coerce_table_strings_to_object and its call in _save_output: anndata 0.13 serializes pandas' str dtype natively. The three tests asserting the coercion's behaviour go with it; the write-path tests under infer_string stay, now covering anndata's handling, and the xfail removal trigger becomes a plain assertion so a regression in anndata is still caught. Also updates the CI matrices, classifiers, mypy python_version, and the docs and README that still advertised 3.11 (several also predated 3.13 support). Unit 641 passed / 11 skipped. Integration 4 failed / 14 passed, the same four that fail on main for unrelated reasons (a 'linear' resampling method the code rejects, and a COO region mismatch). mkdocs --strict clean. BREAKING CHANGE: Python 3.11 is no longer supported. The minimum is now 3.12, required by spatialdata 0.8.0 and anndata 0.13. --- .github/workflows/complexity-monitoring.yml | 2 +- .github/workflows/release.yml | 4 +- .github/workflows/tests.yml | 4 +- README.md | 2 +- docs/contributing.md | 2 +- docs/getting-started.md | 2 +- docs/index.md | 2 +- docs/tutorial.md | 2 +- poetry.lock | 256 ++++++++++-------- pyproject.toml | 57 ++-- .../converters/test_pandas3_string_dtypes.py | 133 ++------- .../spatialdata/base_spatialdata_converter.py | 72 ----- 12 files changed, 196 insertions(+), 342 deletions(-) diff --git a/.github/workflows/complexity-monitoring.yml b/.github/workflows/complexity-monitoring.yml index dd8ec454..ce3a438e 100644 --- a/.github/workflows/complexity-monitoring.yml +++ b/.github/workflows/complexity-monitoring.yml @@ -25,7 +25,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.11' + python-version: '3.12' - name: Install Poetry uses: snok/install-poetry@v1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2e3cd4ea..2dfe54e3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,7 +39,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.11' + python-version: '3.12' - name: Install dependencies run: | @@ -108,7 +108,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.11' + python-version: '3.12' - name: Install build tools run: | diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1c3d3605..ce7f4e84 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -11,7 +11,7 @@ jobs: strategy: matrix: os: [ubuntu-latest, windows-latest] - python-version: ['3.11', '3.12', '3.13'] + python-version: ['3.12', '3.13'] steps: - uses: actions/checkout@v4 @@ -46,7 +46,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ['3.11', '3.13'] + python-version: ['3.12', '3.13'] steps: - uses: actions/checkout@v4 diff --git a/README.md b/README.md index 922f93bf..5818277e 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![Tests](https://img.shields.io/github/actions/workflow/status/M4i-Imaging-Mass-Spectrometry/thyra/tests.yml?branch=main&logo=github)](https://github.com/M4i-Imaging-Mass-Spectrometry/thyra/actions/workflows/tests.yml) [![PyPI](https://img.shields.io/pypi/v/thyra?logo=pypi&logoColor=white)](https://pypi.org/project/thyra/) -[![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/) +[![Python 3.12+](https://img.shields.io/badge/python-3.12+-blue.svg)](https://www.python.org/downloads/) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) [![Docs](https://img.shields.io/badge/docs-mkdocs-blue)](https://M4i-Imaging-Mass-Spectrometry.github.io/thyra) diff --git a/docs/contributing.md b/docs/contributing.md index 7958ccb0..b77b9d3c 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -15,7 +15,7 @@ Thank you for your interest in contributing to Thyra! This document provides gui ### Prerequisites -- Python 3.11 or 3.12 +- Python 3.12 or 3.13 - Poetry for dependency management - Git diff --git a/docs/getting-started.md b/docs/getting-started.md index 7602413d..ad7f482a 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -17,7 +17,7 @@ ``` !!! note "Requirements" - Python 3.11 or 3.12. Windows, macOS, and Linux are all supported. + Python 3.12 or 3.13. Windows, macOS, and Linux are all supported. Bruker readers require the vendor SDK DLLs (bundled for Windows). --- diff --git a/docs/index.md b/docs/index.md index 6e0608d7..3802c36b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -4,7 +4,7 @@ [![PyPI](https://img.shields.io/pypi/v/thyra?logo=pypi&logoColor=white)](https://pypi.org/project/thyra/) [![Tests](https://img.shields.io/github/actions/workflow/status/M4i-Imaging-Mass-Spectrometry/thyra/tests.yml?branch=main&logo=github)](https://github.com/M4i-Imaging-Mass-Spectrometry/thyra/actions/workflows/tests.yml) -[![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/) +[![Python 3.12+](https://img.shields.io/badge/python-3.12+-blue.svg)](https://www.python.org/downloads/) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) **Thyra** (from Greek *thyra*, meaning "door" or "portal") converts Mass Spectrometry Imaging (MSI) data into the standardized [SpatialData](https://spatialdata.scverse.org/)/Zarr format -- your portal to spatial omics analysis workflows. diff --git a/docs/tutorial.md b/docs/tutorial.md index c7662358..1a06900f 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -23,7 +23,7 @@ paper. pip install thyra ``` -Thyra requires Python 3.11 or 3.12. Check what you have: +Thyra requires Python 3.12 or 3.13. Check what you have: ```bash thyra --version diff --git a/poetry.lock b/poetry.lock index 0ee7a186..413a4651 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand. [[package]] name = "aiobotocore" @@ -180,38 +180,36 @@ typing-extensions = {version = ">=4.2", markers = "python_version < \"3.13\""} [[package]] name = "anndata" -version = "0.12.2" +version = "0.13.2" description = "Annotated data." optional = false -python-versions = ">=3.11" +python-versions = ">=3.12" groups = ["main"] files = [ - {file = "anndata-0.12.2-py3-none-any.whl", hash = "sha256:aa3c28b77e53a004b584780ca76652aa51694ca2ad32d27ce22e07227254a62a"}, - {file = "anndata-0.12.2.tar.gz", hash = "sha256:ecb3e0613585f5b464d0d3cfb0043a981b2eb92efaa90aae9042dd3e289c4a0a"}, + {file = "anndata-0.13.2-py3-none-any.whl", hash = "sha256:1930305dc3a32ab5433dee2bc441cd264f9eeed22cc5a224b9f9d69e74d4fdf0"}, + {file = "anndata-0.13.2.tar.gz", hash = "sha256:f40106369a08204bc89915759113124f12abb19c3d011f7503e627e9ef8f29c9"}, ] [package.dependencies] array-api-compat = ">=1.7.1" -h5py = ">=3.8" +h5py = ">=3.11" legacy-api-wrap = "*" natsort = "*" -numpy = ">=1.26" +numpy = ">=2.1" packaging = ">=24.2" -pandas = ">=2.1.0,<2.1.2 || >2.1.2" -scipy = ">=1.12" -zarr = ">=2.18.7,<3.0.dev0 || >=3.1.dev0" +pandas = ">=2.3" +scipy = ">=1.14,<1.17.0 || >1.17.0" +scverse-misc = {version = ">=0.1.0", extras = ["settings"]} +typing-extensions = {version = ">=4.16", markers = "python_version <= \"3.14\""} +zarr = ">=3.1" [package.extras] cu11 = ["cupy-cuda11x"] cu12 = ["cupy-cuda12x"] -dask = ["dask[array] (>=2023.5.1,<2024.8.dev0 || >=2024.10.dev0,<2025.2.0)"] -dev = ["hatch-vcs", "towncrier (>=24.8.0)"] -dev-doc = ["towncrier (>=24.8.0)"] -doc = ["awkward (>=2.3)", "dask[array] (>=2023.5.1,<2024.8.dev0 || >=2024.10.dev0,<2025.2.0)", "ipython", "myst-nb", "myst-parser", "scanpydoc[theme,typehints] (>=0.15.3)", "sphinx (>=8.2.1)", "sphinx-autodoc-typehints (>=2.2.0)", "sphinx-book-theme (>=1.1.0)", "sphinx-copybutton", "sphinx-design (>=0.5.0)", "sphinx-issues (>=5.0.1)", "sphinx-toolbox (>=3.8.0)", "sphinxext-opengraph", "towncrier (>=24.8.0)"] +cu13 = ["cupy-cuda13x"] +dask = ["dask[array] (>=2024.5.1,<2024.8.dev0 || >=2024.10.dev0,<2025.2.dev0 || >=2025.9.dev0)"] gpu = ["cupy"] -lazy = ["aiohttp", "dask[array] (>=2023.5.1,<2024.8.dev0 || >=2024.10.dev0,<2025.2.0)", "requests", "xarray (>=2025.6.1)"] -test = ["aiohttp", "awkward (>=2.3.2)", "boltons", "dask[array] (>=2023.5.1,<2024.8.dev0 || >=2024.10.dev0,<2025.2.0)", "dask[distributed]", "filelock", "httpx (<1.0)", "joblib", "loompy (>=3.0.5)", "matplotlib", "openpyxl", "pyarrow (<21)", "pytest (>=8.2,<8.3.4)", "pytest-cov", "pytest-memray", "pytest-mock", "pytest-randomly", "pytest-xdist[psutil]", "requests", "scanpy (>=1.10)", "scikit-learn", "xarray (>=2025.6.1)"] -test-min = ["awkward (>=2.3.2)", "boltons", "dask[array] (>=2023.5.1,<2024.8.dev0 || >=2024.10.dev0,<2025.2.0)", "dask[distributed]", "filelock", "httpx (<1.0)", "joblib", "loompy (>=3.0.5)", "matplotlib", "openpyxl", "pyarrow (<21)", "pytest (>=8.2,<8.3.4)", "pytest-cov", "pytest-memray", "pytest-mock", "pytest-randomly", "pytest-xdist[psutil]", "scanpy (>=1.10)", "scikit-learn"] +lazy = ["aiohttp", "dask[array] (>=2024.5.1,<2024.8.dev0 || >=2024.10.dev0,<2025.2.dev0 || >=2025.9.dev0)", "requests", "xarray (>=2025.06.1)"] [[package]] name = "annotated-types" @@ -219,7 +217,7 @@ version = "0.7.0" description = "Reusable constraint types to use with typing.Annotated" optional = false python-versions = ">=3.8" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, @@ -1207,35 +1205,34 @@ tests = ["pytest", "pytest-cov", "pytest-xdist"] [[package]] name = "dask" -version = "2026.1.1" +version = "2026.7.1" description = "Parallel PyData with Task Scheduling" optional = false python-versions = ">=3.10" groups = ["main"] files = [ - {file = "dask-2026.1.1-py3-none-any.whl", hash = "sha256:146b0ef2918eb581e06139183a88801b4a8c52d7c37758a91f8c3b75c54b0e15"}, - {file = "dask-2026.1.1.tar.gz", hash = "sha256:12b1dbb0d6e92f287feb4076871600b2fba3a843d35ff214776ada5e9e7a1529"}, + {file = "dask-2026.7.1-py3-none-any.whl", hash = "sha256:985ffd6c5e9d7979ede515e84ae8d39b647d6aa64f77600f15714ff65f578fe6"}, + {file = "dask-2026.7.1.tar.gz", hash = "sha256:5727484427665f051e86bf87d021a64d6411141cdc8a20bfe3c1ad2968cc06b7"}, ] [package.dependencies] click = ">=8.1" cloudpickle = ">=3.0.0" -fsspec = ">=2021.9.0" -importlib_metadata = {version = ">=4.13.0", markers = "python_version < \"3.12\""} +fsspec = ">=2021.09.0" numpy = {version = ">=1.24", optional = true, markers = "extra == \"array\""} packaging = ">=20.0" pandas = {version = ">=2.0", optional = true, markers = "extra == \"dataframe\""} partd = ">=1.4.0" -pyarrow = {version = ">=14.0.1", optional = true, markers = "extra == \"dataframe\""} -pyyaml = ">=5.3.1" +pyarrow = {version = ">=16.0", optional = true, markers = "extra == \"dataframe\""} +pyyaml = ">=5.4.1" toolz = ">=0.12.0" [package.extras] array = ["numpy (>=1.24)"] -complete = ["dask[array,dataframe,diagnostics,distributed]", "lz4 (>=4.3.2)", "pyarrow (>=14.0.1)"] -dataframe = ["dask[array]", "pandas (>=2.0)", "pyarrow (>=14.0.1)"] +complete = ["dask[array,dataframe,diagnostics,distributed]", "lz4 (>=4.3.2)"] +dataframe = ["dask[array]", "pandas (>=2.0)", "pyarrow (>=16.0)"] diagnostics = ["bokeh (>=3.1.0)", "jinja2 (>=2.10.3)"] -distributed = ["distributed (>=2026.1.1,<2026.1.2)"] +distributed = ["distributed (>=2026.7.1,<2026.7.2)"] test = ["pandas[test]", "pre-commit", "pytest", "pytest-cov", "pytest-mock", "pytest-rerunfailures", "pytest-timeout", "pytest-xdist"] [[package]] @@ -1387,20 +1384,20 @@ files = [ [[package]] name = "distributed" -version = "2026.1.1" +version = "2026.7.1" description = "Distributed scheduler for Dask" optional = false python-versions = ">=3.10" groups = ["main"] files = [ - {file = "distributed-2026.1.1-py3-none-any.whl", hash = "sha256:506759b1ed88e45e12ba65e2a429de9911862db55d27dd8bb293c6268430374e"}, - {file = "distributed-2026.1.1.tar.gz", hash = "sha256:3d2709a43912797df3c345af3bb333bbf1a386ec1e9e6a134e5f050521373dbd"}, + {file = "distributed-2026.7.1-py3-none-any.whl", hash = "sha256:64eaf4406a04ad26acdc5b8e32fad6bac522d07539225a530bab49610b0114a9"}, + {file = "distributed-2026.7.1.tar.gz", hash = "sha256:f7c48ab6961734874521574d75898466703bce93f22f0cff5872c7e4ac6425d4"}, ] [package.dependencies] click = ">=8.0" cloudpickle = ">=3.0.0" -dask = ">=2026.1.1,<2026.1.2" +dask = ">=2026.7.1,<2026.7.2" jinja2 = ">=2.10.3" locket = ">=1.0.0" msgpack = ">=1.0.2" @@ -1411,7 +1408,6 @@ sortedcontainers = ">=2.0.5" tblib = ">=1.6.0,<3.2.0 || >3.2.0,<3.2.1 || >3.2.1" toolz = ">=0.12.0" tornado = ">=6.2.0" -urllib3 = ">=1.26.5" zict = ">=3.0.0" [[package]] @@ -2057,31 +2053,6 @@ rawpy = ["numpy (>2)", "rawpy"] test = ["fsspec[github]", "pytest", "pytest-cov"] tifffile = ["tifffile"] -[[package]] -name = "importlib-metadata" -version = "8.7.0" -description = "Read metadata from Python packages" -optional = false -python-versions = ">=3.9" -groups = ["main"] -markers = "python_version == \"3.11\"" -files = [ - {file = "importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd"}, - {file = "importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000"}, -] - -[package.dependencies] -zipp = ">=3.20" - -[package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] -cover = ["pytest-cov"] -doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -enabler = ["pytest-enabler (>=2.2)"] -perf = ["ipython"] -test = ["flufl.flake8", "importlib_resources (>=1.3) ; python_version < \"3.9\"", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] -type = ["pytest-mypy"] - [[package]] name = "importlib-resources" version = "6.5.2" @@ -2171,7 +2142,6 @@ prompt_toolkit = ">=3.0.41,<3.1.0" pygments = ">=2.4.0" stack_data = "*" traitlets = ">=5.13.0" -typing_extensions = {version = ">=4.6", markers = "python_version < \"3.12\""} [package.extras] all = ["ipython[doc,matplotlib,test,test-extra]"] @@ -2344,7 +2314,7 @@ fqdn = {version = "*", optional = true, markers = "extra == \"format-nongpl\""} idna = {version = "*", optional = true, markers = "extra == \"format-nongpl\""} isoduration = {version = "*", optional = true, markers = "extra == \"format-nongpl\""} jsonpointer = {version = ">1.13", optional = true, markers = "extra == \"format-nongpl\""} -jsonschema-specifications = ">=2023.3.6" +jsonschema-specifications = ">=2023.03.6" referencing = ">=0.28.4" rfc3339-validator = {version = "*", optional = true, markers = "extra == \"format-nongpl\""} rfc3986-validator = {version = ">0.1.0", optional = true, markers = "extra == \"format-nongpl\""} @@ -2525,7 +2495,6 @@ jupyter-events = ">=0.11.0" jupyter-server-terminals = ">=0.4.4" nbconvert = ">=6.4.4" nbformat = ">=5.3.0" -overrides = {version = ">=5.0", markers = "python_version < \"3.12\""} packaging = ">=22.0" prometheus-client = ">=0.9" pywinpty = {version = ">=2.0.1", markers = "os_name == \"nt\""} @@ -4133,22 +4102,23 @@ files = [ [[package]] name = "ome-zarr" -version = "0.15.0" +version = "0.18.0" description = "Implementation of images in Zarr files." optional = false -python-versions = ">3.10" +python-versions = ">3.11" groups = ["main"] files = [ - {file = "ome_zarr-0.15.0-py3-none-any.whl", hash = "sha256:99789b0c4c27fbd197ab1a8bb3ae58b1c7b4e72d0d6b95e9dda6e6416623d3eb"}, - {file = "ome_zarr-0.15.0.tar.gz", hash = "sha256:e9479d3a9849c194a616355290f6ae574a7d4402559c5f7005be20cf81945435"}, + {file = "ome_zarr-0.18.0-py3-none-any.whl", hash = "sha256:aaf6ea6ac5790623290210efc566ad49f92da170b50b07e9113b6b06a0870cc5"}, + {file = "ome_zarr-0.18.0.tar.gz", hash = "sha256:5cc0fc682b0c177fa32e54f4479b2436f79850a171e83ad4c6b08fec32e66cd0"}, ] [package.dependencies] aiohttp = "*" -dask = ">=2025.12.0,<=2026.1.1" +dask = ">=2025.2.0,<2025.12.dev0 || >=2025.13.dev0,<2026.1.dev0 || >=2026.3.dev0" Deprecated = "*" -fsspec = {version = ">=0.8,<2021.7.0 || >2021.7.0,<2023.9.0 || >2023.9.0", extras = ["s3"]} +fsspec = {version = ">=0.8,<2021.07.0 || >2021.07.0,<2023.9.0 || >2023.9.0", extras = ["s3"]} numpy = "*" +ome-zarr-models = ">=1.6" rangehttpserver = "*" requests = "*" scikit-image = ">=0.19.0" @@ -4156,18 +4126,25 @@ toolz = "*" zarr = ">=3.0.0" [[package]] -name = "overrides" -version = "7.7.0" -description = "A decorator to automatically detect mismatch when overriding a method." +name = "ome-zarr-models" +version = "1.7" +description = "A minimal Python package for reading OME-Zarr (meta)data " optional = false -python-versions = ">=3.6" -groups = ["dev"] -markers = "python_version == \"3.11\"" +python-versions = "<3.14,>=3.11" +groups = ["main"] files = [ - {file = "overrides-7.7.0-py3-none-any.whl", hash = "sha256:c7ed9d062f78b8e4c1a7b70bd8796b35ead4d9f510227ef9c5dc7626c60d7e49"}, - {file = "overrides-7.7.0.tar.gz", hash = "sha256:55158fa3d93b98cc75299b1e67078ad9003ca27945c76162c1c0766d6f91820a"}, + {file = "ome_zarr_models-1.7-py3-none-any.whl", hash = "sha256:4e786af38936e62be50cee000c53bf41091ee7d24b0d304570b43147ef34b6bb"}, + {file = "ome_zarr_models-1.7.tar.gz", hash = "sha256:3979d304f7669e7a22be0bd8a6dd5dcc93fe85af3d842e56930d2ef2e6929090"}, ] +[package.dependencies] +pydantic = ">=2.11.5,<2.13" +pydantic-zarr = ">=0.8.2" +zarr = ">=3.1.1" + +[package.extras] +docs = ["fsspec[http] (==2025.9.0)", "griffe-fieldz (==0.4.0)", "matplotlib (==3.10.6)", "mkdocs (==1.6.1)", "mkdocs-jupyter (==0.25.1)", "mkdocs-material (==9.6.21)", "mkdocstrings-python (==1.18.2)", "rich (==14.1.0)"] + [[package]] name = "packaging" version = "25.0" @@ -4249,10 +4226,7 @@ files = [ ] [package.dependencies] -numpy = [ - {version = ">=1.23.2", markers = "python_version == \"3.11\""}, - {version = ">=1.26.0", markers = "python_version >= \"3.12\""}, -] +numpy = {version = ">=1.26.0", markers = "python_version >= \"3.12\""} python-dateutil = ">=2.8.2" pytz = ">=2020.1" tzdata = ">=2022.7" @@ -4951,7 +4925,7 @@ version = "2.11.7" description = "Data validation using Python type hints" optional = false python-versions = ">=3.9" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b"}, {file = "pydantic-2.11.7.tar.gz", hash = "sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db"}, @@ -4973,7 +4947,7 @@ version = "2.33.2" description = "Core functionality for Pydantic validation and serialization" optional = false python-versions = ">=3.9" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "pydantic_core-2.33.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8"}, {file = "pydantic_core-2.33.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d"}, @@ -5079,6 +5053,53 @@ files = [ [package.dependencies] typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" +[[package]] +name = "pydantic-settings" +version = "2.14.2" +description = "Settings management using Pydantic" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440"}, + {file = "pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f"}, +] + +[package.dependencies] +pydantic = ">=2.7.0" +python-dotenv = ">=0.21.0" +typing-inspection = ">=0.4.0" + +[package.extras] +aws-secrets-manager = ["boto3 (>=1.35.0)", "types-boto3[secretsmanager]"] +azure-key-vault = ["azure-identity (>=1.16.0)", "azure-keyvault-secrets (>=4.8.0)"] +gcp-secret-manager = ["google-cloud-secret-manager (>=2.23.1)"] +toml = ["tomli (>=2.0.1)"] +yaml = ["pyyaml (>=6.0.1)"] + +[[package]] +name = "pydantic-zarr" +version = "0.10.0" +description = "Pydantic models for the Zarr file format" +optional = false +python-versions = ">=3.12" +groups = ["main"] +files = [ + {file = "pydantic_zarr-0.10.0-py3-none-any.whl", hash = "sha256:d15769dab43346b070051fdf6e83509d7f55259dd00c6d12be2400935117e230"}, + {file = "pydantic_zarr-0.10.0.tar.gz", hash = "sha256:996b5b4225fb05757e7fff831ee7a75c61f54514a4c2493c7b79635bade7b415"}, +] + +[package.dependencies] +numpy = ">=2.0.0" +packaging = ">=21.0" +pydantic = ">2.0.0" + +[package.extras] +docs = ["mkdocs-material", "mkdocstrings[python]", "pydantic (==2.12.*)", "pytest-examples", "towncrier", "zarr (>=3.1.0)"] +test = ["coverage", "dask (==2025.11.0)", "pytest (<8.4)", "pytest-cov", "pytest-examples", "xarray (==2025.10.0)", "zarr (>=3.0.0)"] +test-base = ["coverage", "dask (==2025.11.0)", "pytest (<8.4)", "pytest-cov", "pytest-examples", "xarray (==2025.10.0)"] +zarr = ["zarr (>=3.0.0)"] + [[package]] name = "pydocstyle" version = "6.3.0" @@ -5335,7 +5356,7 @@ version = "1.1.1" description = "Read key-value pairs from a .env file and set them as environment variables" optional = false python-versions = ">=3.9" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc"}, {file = "python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab"}, @@ -6079,6 +6100,30 @@ dev = ["cython-lint (>=0.12.2)", "doit (>=0.36.0)", "mypy (==1.10.0)", "pycodest doc = ["intersphinx_registry", "jupyterlite-pyodide-kernel", "jupyterlite-sphinx (>=0.19.1)", "jupytext", "linkify-it-py", "matplotlib (>=3.5)", "myst-nb (>=1.2.0)", "numpydoc", "pooch", "pydata-sphinx-theme (>=0.15.2)", "sphinx (>=5.0.0,<8.2.0)", "sphinx-copybutton", "sphinx-design (>=0.4.0)"] test = ["Cython", "array-api-strict (>=2.3.1)", "asv", "gmpy2", "hypothesis (>=6.30)", "meson", "mpmath", "ninja ; sys_platform != \"emscripten\"", "pooch", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "scikit-umfpack", "threadpoolctl"] +[[package]] +name = "scverse-misc" +version = "0.1.3" +description = "Miscellaneous utility code used by scverse packages" +optional = false +python-versions = ">=3.12" +groups = ["main"] +files = [ + {file = "scverse_misc-0.1.3-py3-none-any.whl", hash = "sha256:1f0f73b021fd6501800521a47257f640ddf2faf57a37322d133c7b860b6e4813"}, + {file = "scverse_misc-0.1.3.tar.gz", hash = "sha256:843f29b40e4bbeab85849dcaa3ce0be1e64dc9de285eb7afff9c249f4c2bc76a"}, +] + +[package.dependencies] +pydantic-settings = {version = "*", optional = true, markers = "extra == \"settings\""} +python-dotenv = {version = "*", optional = true, markers = "extra == \"settings\""} +session-info2 = "*" +typing-extensions = {version = "*", markers = "python_version < \"3.13\""} + +[package.extras] +datasets = ["anndata", "pooch", "pyyaml", "tqdm"] +settings = ["pydantic-settings", "python-dotenv"] +spatialdata = ["spatialdata"] +sphinx = ["jinja2", "pydocstring-rs (>=0.4.1)", "sphinx (>=9)"] + [[package]] name = "seaborn" version = "0.13.2" @@ -6327,31 +6372,31 @@ xarray-dataclass = ">=3.0.0" [[package]] name = "spatialdata" -version = "0.7.3" +version = "0.8.0" description = "Spatial data format." optional = false -python-versions = ">=3.11" +python-versions = ">=3.12" groups = ["main"] files = [ - {file = "spatialdata-0.7.3-py3-none-any.whl", hash = "sha256:15e0df647f149a8be70cef6b866dcf033c71890440583bca8bb1c20c37566e6c"}, - {file = "spatialdata-0.7.3.tar.gz", hash = "sha256:6054d66d00e19af96368a897faa0b369f3a25dbf9e0371f091129f8522ee6986"}, + {file = "spatialdata-0.8.0-py3-none-any.whl", hash = "sha256:75139b0df0e27c4573141a8dbe6b3301b47231571505926ad6a633c046ec1010"}, + {file = "spatialdata-0.8.0.tar.gz", hash = "sha256:14143788a1de83302f8e83a29dd723b2d4e82f5b130d840409822fbf3a778e25"}, ] [package.dependencies] anndata = ">=0.9.1" annsel = ">=0.1.2" click = "*" -dask = ">=2025.12.0,<2026.1.2" +dask = ">=2026.3.0" dask-image = "*" datashader = "*" -distributed = "<2026.1.2" +distributed = ">=2026.3.0" fsspec = {version = "*", extras = ["http", "s3"]} geopandas = ">=0.14" multiscale-spatial-image = "2.0.3" networkx = "*" numba = ">=0.55.0" numpy = "*" -ome-zarr = ">=0.14.0" +ome-zarr = ">=0.16.0" pandas = "*" pooch = "*" pyarrow = "*" @@ -6607,14 +6652,14 @@ urllib3 = ">=2" [[package]] name = "typing-extensions" -version = "4.14.1" +version = "4.16.0" description = "Backported and Experimental Type Hints for Python 3.9+" optional = false python-versions = ">=3.9" groups = ["main", "dev", "docs"] files = [ - {file = "typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76"}, - {file = "typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36"}, + {file = "typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8"}, + {file = "typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5"}, ] [[package]] @@ -6623,7 +6668,7 @@ version = "0.4.1" description = "Runtime typing introspection tools" optional = false python-versions = ">=3.9" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51"}, {file = "typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28"}, @@ -7164,28 +7209,7 @@ files = [ {file = "zict-3.0.0.tar.gz", hash = "sha256:e321e263b6a97aafc0790c3cfb3c04656b7066e6738c37fffcca95d803c9fba5"}, ] -[[package]] -name = "zipp" -version = "3.23.0" -description = "Backport of pathlib-compatible object wrapper for zip files" -optional = false -python-versions = ">=3.9" -groups = ["main"] -markers = "python_version == \"3.11\"" -files = [ - {file = "zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e"}, - {file = "zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166"}, -] - -[package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] -cover = ["pytest-cov"] -doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -enabler = ["pytest-enabler (>=2.2)"] -test = ["big-O", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more_itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] -type = ["pytest-mypy"] - [metadata] lock-version = "2.1" -python-versions = ">=3.11, <3.14" -content-hash = "fc975338f1d8995a62853f1534ff02386ca16eb51c373a4578526daca7d3da89" +python-versions = ">=3.12, <3.14" +content-hash = "bef71e6385fe784a8933b882f0f6ce6e765b282a91b5cdefda8c54420cf4e038" diff --git a/pyproject.toml b/pyproject.toml index 6bff29ac..60cbac2a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,6 @@ classifiers = [ "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Topic :: Scientific/Engineering :: Bio-Informatics", @@ -54,59 +53,51 @@ packages = [{include = "thyra"}] "Contributing" = "https://github.com/M4i-Imaging-Mass-Spectrometry/thyra/blob/main/CONTRIBUTING.md" [tool.poetry.dependencies] -python = ">=3.11, <3.14" +python = ">=3.12, <3.14" # The spatialdata / ome-zarr / zarr / dask cluster is tightly coupled and # all four release frequently with breaking cross-version interactions. # Floors alone are NOT enough for `pip install thyra`: without upper -# bounds pip resolves a mismatched set (e.g. spatialdata 0.7.0 pulled in -# alongside ome-zarr 0.18.0 + dask 2026.7.0), which fails to save/read -# back a SpatialData object. The upper bounds below pin this cluster to +# bounds pip resolves a mismatched set (a stale spatialdata alongside a +# much newer ome-zarr and dask), which fails to save/read back a +# SpatialData object. The upper bounds below pin this cluster to # the tested, lockfile-consistent set so a plain pip install lands on a # working combination. The clean-venv CI job in .github/workflows/tests.yml # exercises exactly this pip-resolved path so these bounds stay honest. # Bump the ceilings deliberately after re-testing against newer releases. -dask = ">=2025.12.0, <2026.2" # tested 2026.1.1 (spatialdata 0.7.3 needs <2026.1.2) +dask = ">=2026.3.0, <2026.8" # tested 2026.7.1 (spatialdata 0.8.0 needs >=2026.3.0) defusedxml = ">=0.7.1" # Secure XML parsing for the Bruker Rapiflex reader geopandas = ">=0.9.0" lxml = ">=4.6.0" numpy = ">=2.0.0, <3" # pandas 3.0 makes `future.infer_string` the default, which gives the table -# indices and string columns a dtype the pinned anndata cannot serialize. -# `BaseSpatialDataConverter._coerce_table_strings_to_object` handles that, so -# the ceiling is NOT `<3`: capping there would strand users whose other -# packages want pandas 3, for a defect we already fix. `<4` records the newest -# major actually exercised -- the unit suite passes on 2.3.2 (the lockfile) -# and on 3.0.5 with this dependency set. +# indices and string columns pandas' `str` dtype. anndata >= 0.13 serializes +# that natively (anndata #2133); older anndata could not, which is why this +# project carried a write-time coercion until the anndata floor moved. The +# ceiling is NOT `<3`: capping there would strand users whose other packages +# want pandas 3. `<4` records the newest major actually exercised -- the unit +# suite passes on 2.3.2 (the lockfile) and on 3.0.5 with this set. pandas = ">=2.0.0, <4" # tested 2.3.2 and 3.0.5 pyimzML = ">=1.4.0" scipy = ">=1.7.0" Shapely = ">=2.0.0" -spatialdata = ">=0.7.3, <0.8" # tested 0.7.3 +spatialdata = ">=0.8.0, <0.9" # tested 0.8.0 # Floor pulls the fix for the zarr_format/Group.create_array() bug that -# bit ome_zarr 0.12.x with newer dask; the ceiling keeps pip off the -# 0.16+ releases that the resolver otherwise pairs with a stale spatialdata. -ome-zarr = ">=0.14.0, <0.16" # tested 0.15.0 +# bit ome_zarr 0.12.x with newer dask, and spatialdata 0.8 requires >= 0.16. +ome-zarr = ">=0.16.0, <0.19" # tested 0.18.0 tqdm = ">=4.50.0" zarr = ">=3.0.0, <3.2" # tested 3.1.3 imagecodecs = ">=2024.1.1" # For reading compressed TIFF optical images -# Moving this ceiling is the removal trigger for the pandas 3 string-dtype -# coercion in base_spatialdata_converter.py: anndata 0.12.19 carries its own -# `pandas<3` cap, so bumping to a release with pandas 3 support both opens the -# pandas 3 door and makes that coercion dead code. +# Floor is 0.13.2, not 0.13.0. anndata 0.13.0 imports +# `typing_extensions.sentinel` (needs typing-extensions >= 4.16) but declares +# no floor for it, so a resolver prefers 0.13.0, pairs it with an older +# typing-extensions, and produces an environment that installs cleanly and +# then dies at import. 0.13.2 declares the floor correctly. # -# That release is anndata 0.13.0 (anndata #2221, fixed by anndata #2133). Do -# not trust #2221's milestone label, which still reads 0.14.0 even though the -# fix shipped in 0.13.0. -# -# The bump is NOT mechanical -- see issue #117, where it was measured: -# - anndata 0.13.x requires Python >= 3.12, which this project does not. -# - anndata 0.13 moved `X` into `layers` under a None key, which spatialdata -# 0.7.3's validate_table_attr_keys chokes on, breaking every write path. -# Fixed by spatialdata #1122, which is in v0.8.0 -- so this ceiling cannot -# move until the spatialdata ceiling does, despite 0.7.3 declaring only -# `anndata>=0.9.1`. -anndata = ">=0.11.0, <0.13" # tested 0.12.2; 0.13.2 fails, see #117 +# 0.13 is also the release that made the pandas 3 string-dtype coercion in +# base_spatialdata_converter.py unnecessary (anndata #2221, fixed by #2133); +# that coercion was deleted when this floor moved. See issue #117. +anndata = ">=0.13.2, <0.14" # tested 0.13.2 psutil = ">=5.0.0" [tool.poetry.group.test.dependencies] @@ -199,7 +190,7 @@ exclude_dirs = ["tests", "docs"] skips = ["B101", "B601", "B603", "B607"] # Allow assert statements, shell usage, and subprocess calls with controlled input [tool.mypy] -python_version = "3.11" +python_version = "3.12" warn_return_any = true warn_unused_configs = true disallow_untyped_defs = false # Set to true once types are added diff --git a/tests/unit/converters/test_pandas3_string_dtypes.py b/tests/unit/converters/test_pandas3_string_dtypes.py index c030be51..c756b50d 100644 --- a/tests/unit/converters/test_pandas3_string_dtypes.py +++ b/tests/unit/converters/test_pandas3_string_dtypes.py @@ -3,28 +3,22 @@ Under pandas 3.0 -- or pandas 2.x with ``future.infer_string=True``, which is what 3.0 makes the default -- the table's obs index (``instance_id``), the ``instance_key`` column and the var index (``mz_*``) are inferred as pandas' -``str`` dtype, backed by ``ArrowStringArrayNumpySemantics``. anndata's IO -registry has a writer for ``pandas.core.arrays.string_.StringArray`` but not -for the ``*NumpySemantics`` subclasses pandas 3 actually produces, and it -matches exact types rather than subclasses, so ``SpatialData.write()`` raises:: - - No method registered for writing - - into - -``BaseSpatialDataConverter._coerce_table_strings_to_object`` is the fix. Both -write paths that reach anndata's writer fail without it: the in-memory -converter (the default for anything under the 10 GB streaming threshold, i.e. -most conversions) and the streaming COO path. The streaming PCS path +``str`` dtype rather than ``object``. + +anndata could not serialize that until 0.13.0 (anndata #2221, fixed by anndata +#2133), so ``SpatialData.write()`` raised ``IORegistryError`` and Thyra carried +a write-time coercion to ``object`` in ``_save_output``. The anndata floor has +since moved past 0.13, the coercion is gone, and these tests now cover +anndata's own handling instead of Thyra's workaround. + +They are still worth keeping. The rest of the suite runs with pandas' default +inference, so nothing else in CI would notice a regression here; every test in +this module turns ``future.infer_string`` on for its duration and restores it +afterwards. All three write paths are covered: the in-memory converter (the +default for anything under the 10 GB streaming threshold, i.e. most +conversions), the streaming COO path, and the streaming PCS path, which hand-writes the AnnData layout straight to Zarr and never reaches anndata's -writer, so it passes either way -- it is covered here to keep it that way. - -The rest of the suite runs with pandas' default inference, so nothing else -would notice if the coercion were dropped; every test in this module turns -``future.infer_string`` on for its duration and restores it afterwards. - -``test_anndata_can_write_arrow_backed_strings`` is the removal trigger: see -``_coerce_table_strings_to_object`` for the full anndata #2221 story. +writer at all. """ from typing import Callable, Dict @@ -36,7 +30,6 @@ from tests.fixtures.mock_msi_generator import MockMSIConfig, MockMSIReader from thyra.converters.spatialdata.base_spatialdata_converter import ( SPATIALDATA_AVAILABLE, - BaseSpatialDataConverter, ) from thyra.converters.spatialdata.spatialdata_2d_converter import SpatialData2DConverter from thyra.converters.spatialdata.streaming_converter import ( @@ -177,96 +170,14 @@ def test_written_store_reads_back_with_pandas_string_index(tmp_path): assert table.obs["region"].tolist() == ["mock_z0_pixels"] * _N_PIXELS -def test_coercion_changes_only_dtypes(): - """Same order, same content, only the string dtypes move to ``object``.""" - df = _string_obs_table() - - # Precondition: without these the rest of the test is vacuous. - assert isinstance(df.index.dtype, pd.StringDtype) - assert isinstance(df["instance_key"].dtype, pd.StringDtype) - - index_before = df.index.tolist() - instance_key_before = df["instance_key"].tolist() - x_before = df["x"].tolist() - spatial_x_before = df["spatial_x"].tolist() - - BaseSpatialDataConverter._coerce_table_strings_to_object(df) - - assert df.index.dtype == object - assert df["instance_key"].dtype == object - assert df.index.tolist() == index_before - assert df.index.name == "instance_id" - assert df["instance_key"].tolist() == instance_key_before - - # Non-string columns are untouched, dtype included. - assert df["x"].dtype == np.int32 - assert df["spatial_x"].dtype == np.float64 - assert df["x"].tolist() == x_before - assert df["spatial_x"].tolist() == spatial_x_before - - assert list(df.columns) == ["x", "spatial_x", "region", "instance_key"] - - -def test_coercion_preserves_categorical_codes_and_categories(): - """A string-backed categorical keeps its codes and its category values.""" - df = _string_obs_table() - - # Precondition: the categorical must be string-backed to be worth testing. - assert isinstance(df["region"].dtype, pd.CategoricalDtype) - assert isinstance(df["region"].cat.categories.dtype, pd.StringDtype) - - codes_before = df["region"].cat.codes.tolist() - categories_before = df["region"].cat.categories.tolist() - values_before = df["region"].tolist() - assert len(set(codes_before)) > 1, "need >1 category for codes to mean anything" - - BaseSpatialDataConverter._coerce_table_strings_to_object(df) - - assert isinstance(df["region"].dtype, pd.CategoricalDtype) - assert df["region"].cat.categories.dtype == object - assert df["region"].cat.codes.tolist() == codes_before - assert df["region"].cat.categories.tolist() == categories_before - assert df["region"].tolist() == values_before - - -def test_coercion_is_a_noop_on_object_dtypes(): - """On pandas < 3 the same columns are already ``object`` -- nothing moves. - - Built by coercing once, so this also covers a table that has already been - through ``_save_output``. - """ - df = _string_obs_table() - BaseSpatialDataConverter._coerce_table_strings_to_object(df) - - # Precondition: this is the pandas < 3 shape. - assert df.index.dtype == object - assert df["instance_key"].dtype == object - assert df["region"].cat.categories.dtype == object - - before = df.copy(deep=True) - BaseSpatialDataConverter._coerce_table_strings_to_object(df) - - pd.testing.assert_frame_equal(df, before) - - -@pytest.mark.xfail( - strict=False, - reason=( - "The pinned anndata (< 0.13) cannot serialize pandas' str dtype. " - "anndata #2221 ('Pandas 3.0 compatibility') was fixed by anndata " - "#2133 and released in 0.13.0, so raising the ceiling is what makes " - "this XPASS. An XPASS here means the installed anndata can write " - "Arrow-backed strings and _coerce_table_strings_to_object can be " - "deleted." - ), -) -def test_anndata_can_write_arrow_backed_strings(tmp_path): - """Removal trigger: XPASSes when the workaround becomes unnecessary. +def test_anndata_writes_arrow_backed_strings(tmp_path): + """anndata serializes pandas' ``str`` dtype directly. - Deliberately non-strict, so an anndata that gains pandas 3 support shows up - as an XPASS in the run summary rather than as a red build. The point is - that the coercion announces its own obsolescence instead of silently - pessimising every write forever. + This was the removal trigger for the write-time coercion Thyra used to + carry: it was an ``xfail`` that XPASSed once the anndata floor moved to + 0.13. Kept as a plain assertion so a regression in anndata's string + support is caught here, at the smallest possible scope, rather than as a + confusing failure somewhere in the write paths above. """ import anndata as ad import zarr diff --git a/thyra/converters/spatialdata/base_spatialdata_converter.py b/thyra/converters/spatialdata/base_spatialdata_converter.py index 21da6535..0cbc2f84 100644 --- a/thyra/converters/spatialdata/base_spatialdata_converter.py +++ b/thyra/converters/spatialdata/base_spatialdata_converter.py @@ -1829,66 +1829,6 @@ def _generate_optical_image_name(self, tiff_path: Path) -> str: return f"{self.dataset_id}_optical_{suffix}" - # --- pandas 3 string dtypes: removal trigger --------------------------- - # anndata #2221 ("Pandas 3.0 compatibility") was closed by anndata #2133 - # and shipped in anndata 0.13.0 (2026-07-07), so this is already fixed - # upstream -- just not in a version this project's ceiling admits. - # anndata #2377 is this exact IORegistryError, closed as a duplicate of - # it, and spatialdata-io #364 is the same failure in spatialdata's own - # Xenium writer, so this is an ecosystem gap and not a Thyra bug. - # anndata's documented escape hatches do not help individually on the - # pinned 0.12.2: `ad.settings.allow_write_nullable_strings = True` and - # `pd.set_option("mode.string_storage", "python")` each still raise. On - # pandas 3 the two together do write cleanly, but they are global process - # state that a library has no business setting for its users, and they do - # not help at all under future.infer_string on pandas 2, which is what CI - # runs. A local coercion here stays the right layer. - # - # Delete `_coerce_table_strings_to_object` and its call in `_save_output` - # once the anndata ceiling in pyproject.toml moves to >= 0.13. Verified - # 2026-07-30: anndata 0.13.2 writes these tables cleanly on both pandas - # 2.3.2 with future.infer_string and pandas 3.0.5. spatialdata 0.7.3 - # requires only anndata >= 0.9.1, so that bump is not blocked by the - # spatialdata pin. Until it happens this is the only thing keeping - # non-PCS conversions writable, so it is not dead code yet: - # tests/unit/converters/test_pandas3_string_dtypes.py - # ::test_anndata_can_write_arrow_backed_strings XPASSes when it becomes so. - @staticmethod - def _coerce_table_strings_to_object(df: pd.DataFrame) -> None: - """Coerce pandas string-extension dtypes in ``df`` to NumPy ``object``. - - Under pandas >= 3.0 (or with ``future.infer_string`` enabled) string - columns and string indices carry ``pandas.StringDtype``, backed by - ``ArrowStringArray`` on pandas 3 and by - ``ArrowStringArrayNumpySemantics`` on pandas 2. anndata's IO registry - matches exact types and has no writer for either, so writing such a - table raises ``IORegistryError: No method registered for writing - into zarr.core.group.Group``. Converting these to - ``object`` dtype restores writeability without changing any values, and - is a no-op on pandas < 3.0 where the same columns are already - ``object``. - - Operates on an AnnData ``obs``/``var`` table in place. Categorical - columns whose categories are string-backed (e.g. the ``region`` column) - have their categories coerced to ``object`` while preserving codes. - - Args: - df: An AnnData ``obs`` or ``var`` table to sanitize in place. - """ - if isinstance(df.index.dtype, pd.StringDtype): - df.index = df.index.astype(object) - - for column in df.columns: - dtype = df[column].dtype - if isinstance(dtype, pd.StringDtype): - df[column] = df[column].astype(object) - elif isinstance(dtype, pd.CategoricalDtype) and isinstance( - dtype.categories.dtype, pd.StringDtype - ): - df[column] = df[column].cat.rename_categories( - dtype.categories.astype(object) - ) - def _save_output(self, data_structures: Dict[str, Any]) -> bool: """Save the data to SpatialData format. @@ -1902,18 +1842,6 @@ def _save_output(self, data_structures: Dict[str, Any]) -> bool: raise ImportError("SpatialData dependencies not available") try: - # Coerce pandas string-extension dtypes (ArrowStringArray under - # pandas >= 3.0 / future.infer_string) back to ``object`` so - # anndata's Zarr writer can serialize the table indices and string - # columns. No-op on pandas < 3.0 (already ``object``). - for table in data_structures["tables"].values(): - obs = getattr(table, "obs", None) - if isinstance(obs, pd.DataFrame): - self._coerce_table_strings_to_object(obs) - var = getattr(table, "var", None) - if isinstance(var, pd.DataFrame): - self._coerce_table_strings_to_object(var) - # Create SpatialData object with images included sdata = SpatialData( tables=data_structures["tables"],