diff --git a/.gitignore b/.gitignore index 39d7dd00..20ab3635 100644 --- a/.gitignore +++ b/.gitignore @@ -45,10 +45,7 @@ uv.lock profile.speedscope.json # test datasets (e.g. Xenium ones) -# symlinks -data # data folder data/ tests/data .venv -.uv.lock diff --git a/pyproject.toml b/pyproject.toml index d97dc276..05c5baa2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,7 +61,8 @@ test = [ "pytest", "pytest-cov", # https://github.com/scverse/spatialdata-io/issues/334 - "pyarrow!=22" + "pyarrow!=22", + "pytest-mock" ] # this will be used by readthedocs and will make pip also look for pre-releases, generally installing the latest available version # update: readthedocs doens't seem to try to install pre-releases even if when trying to install the pre optional-dependency. For diff --git a/src/spatialdata_io/__main__.py b/src/spatialdata_io/__main__.py index 970bf180..293260a8 100644 --- a/src/spatialdata_io/__main__.py +++ b/src/spatialdata_io/__main__.py @@ -1,3 +1,4 @@ +import json from collections.abc import Callable from pathlib import Path from typing import Any, Literal @@ -37,6 +38,14 @@ def _input_output_click_options(func: Callable[..., None]) -> Callable[..., None return func +def _parse_json_param(value: str, param_name: str) -> dict[str, Any]: + try: + result: dict[str, Any] = json.loads(value) + return result + except json.JSONDecodeError as e: + raise click.BadParameter(f"Invalid JSON for {param_name!r}: {e}") from e + + @cli.command(name="codex") @_input_output_click_options @click.option( @@ -45,11 +54,22 @@ def _input_output_click_options(func: Callable[..., None]) -> Callable[..., None default=True, help="Whether the .fcs file is provided if False a .csv file is expected. [default: True]", ) -def codex_wrapper(input: str, output: str, fcs: bool = True) -> None: +@click.option( + "--imread-kwargs", + type=str, + default="{}", + help="JSON string of keyword arguments passed to imread. [default: {}]", +) +def codex_wrapper( + input: str, + output: str, + fcs: bool = True, + imread_kwargs: str = "{}", +) -> None: """Codex conversion to SpatialData.""" from spatialdata_io.readers.codex import codex - sdata = codex(input, fcs=fcs) + sdata = codex(input, fcs=fcs, imread_kwargs=_parse_json_param(imread_kwargs, "imread_kwargs")) sdata.write(output) @@ -57,11 +77,36 @@ def codex_wrapper(input: str, output: str, fcs: bool = True) -> None: @_input_output_click_options @click.option("--dataset-id", type=str, default=None, help="Name of the dataset [default: None]") @click.option("--transcripts", type=bool, default=True, help="Whether to load transcript information. [default: True]") -def cosmx_wrapper(input: str, output: str, dataset_id: str | None = None, transcripts: bool = True) -> None: +@click.option( + "--imread-kwargs", + type=str, + default="{}", + help="JSON string of keyword arguments passed to imread. [default: {}]", +) +@click.option( + "--image-models-kwargs", + type=str, + default="{}", + help="JSON string of keyword arguments passed to Image2DModel. [default: {}]", +) +def cosmx_wrapper( + input: str, + output: str, + dataset_id: str | None = None, + transcripts: bool = True, + imread_kwargs: str = "{}", + image_models_kwargs: str = "{}", +) -> None: """Cosmic conversion to SpatialData.""" from spatialdata_io.readers.cosmx import cosmx - sdata = cosmx(input, dataset_id=dataset_id, transcripts=transcripts) + sdata = cosmx( + input, + dataset_id=dataset_id, + transcripts=transcripts, + imread_kwargs=_parse_json_param(imread_kwargs, "imread_kwargs"), + image_models_kwargs=_parse_json_param(image_models_kwargs, "image_models_kwargs"), + ) sdata.write(output) @@ -151,6 +196,24 @@ def dbit_wrapper( default=True, help="Whether to process the label image into a multiscale image [default: True]", ) +@click.option( + "--imread-kwargs", + type=str, + default="{}", + help="JSON string of keyword arguments passed to imread. [default: {}]", +) +@click.option( + "--image-models-kwargs", + type=str, + default="{}", + help="JSON string of keyword arguments passed to Image2DModel. [default: {}]", +) +@click.option( + "--labels-models-kwargs", + type=str, + default="{}", + help="JSON string of keyword arguments passed to Labels2DModel. [default: {}]", +) def iss_wrapper( input: str, output: str, @@ -161,6 +224,9 @@ def iss_wrapper( dataset_id: str = "region", multiscale_image: bool = True, multiscale_labels: bool = True, + imread_kwargs: str = "{}", + image_models_kwargs: str = "{}", + labels_models_kwargs: str = "{}", ) -> None: """ISS conversion to SpatialData.""" from spatialdata_io.readers.iss import iss @@ -174,6 +240,9 @@ def iss_wrapper( dataset_id=dataset_id, multiscale_image=multiscale_image, multiscale_labels=multiscale_labels, + imread_kwargs=_parse_json_param(imread_kwargs, "imread_kwargs"), + image_models_kwargs=_parse_json_param(image_models_kwargs, "image_models_kwargs"), + labels_models_kwargs=_parse_json_param(labels_models_kwargs, "labels_models_kwargs"), ) sdata.write(output) @@ -183,11 +252,40 @@ def iss_wrapper( "--input", "-i", type=click.Path(exists=True), help="Path to the mcmicro project directory.", required=True ) @click.option("--output", "-o", type=click.Path(), help="Path to the output.zarr file.", required=True) -def mcmicro_wrapper(input: str, output: str) -> None: +@click.option( + "--imread-kwargs", + type=str, + default="{}", + help="JSON string of keyword arguments passed to imread. [default: {}]", +) +@click.option( + "--image-models-kwargs", + type=str, + default="{}", + help="JSON string of keyword arguments passed to Image2DModel. [default: {}]", +) +@click.option( + "--labels-models-kwargs", + type=str, + default="{}", + help="JSON string of keyword arguments passed to Labels2DModel. [default: {}]", +) +def mcmicro_wrapper( + input: str, + output: str, + imread_kwargs: str = "{}", + image_models_kwargs: str = "{}", + labels_models_kwargs: str = "{}", +) -> None: """Conversion of MCMicro to SpatialData.""" from spatialdata_io.readers.mcmicro import mcmicro - sdata = mcmicro(input) + sdata = mcmicro( + input, + imread_kwargs=_parse_json_param(imread_kwargs, "imread_kwargs"), + image_models_kwargs=_parse_json_param(image_models_kwargs, "image_models_kwargs"), + labels_models_kwargs=_parse_json_param(labels_models_kwargs, "labels_models_kwargs"), + ) sdata.write(output) @@ -212,6 +310,18 @@ def mcmicro_wrapper(input: str, output: str) -> None: @click.option("--cells-boundaries", type=bool, default=True, help="Whether to read cells boundaries. [default: True]") @click.option("--cells-table", type=bool, default=True, help="Whether to read cells table. [default: True]") @click.option("--mosaic-images", type=bool, default=True, help="Whether to read the mosaic images. [default: True]") +@click.option( + "--imread-kwargs", + type=str, + default="{}", + help="JSON string of keyword arguments passed to imread. [default: {}]", +) +@click.option( + "--image-models-kwargs", + type=str, + default="{}", + help="JSON string of keyword arguments passed to Image2DModel. [default: {}]", +) def merscope_wrapper( input: str, output: str, @@ -224,6 +334,8 @@ def merscope_wrapper( cells_boundaries: bool = True, cells_table: bool = True, mosaic_images: bool = True, + imread_kwargs: str = "{}", + image_models_kwargs: str = "{}", ) -> None: """Merscope conversion to SpatialData.""" from spatialdata_io.readers.merscope import merscope @@ -239,6 +351,8 @@ def merscope_wrapper( cells_boundaries=cells_boundaries, cells_table=cells_table, mosaic_images=mosaic_images, + imread_kwargs=_parse_json_param(imread_kwargs, "imread_kwargs"), + image_models_kwargs=_parse_json_param(image_models_kwargs, "image_models_kwargs"), ) sdata.write(output) @@ -252,10 +366,23 @@ def merscope_wrapper( @click.option("--cells-as-circles", type=bool, default=False, help="Whether to read cells as circles. [default: False]") @click.option( "--rois", - type=click.IntRange(min=0), + type=str, + multiple=True, + default=None, + help="Which sections to load. Provide one or more ROI identifiers. [default: All sections are loaded]", +) +@click.option( + "--raster-models-scale-factors", + type=int, multiple=True, default=None, - help="Which sections to load. Provide one or more section indices. [default: All sections are loaded]", + help="Scale factors for raster models. [default: None]", +) +@click.option( + "--imread-kwargs", + type=str, + default="{}", + help="JSON string of keyword arguments passed to imread. [default: {}]", ) def seqfish_wrapper( input: str, @@ -265,12 +392,13 @@ def seqfish_wrapper( load_points: bool = True, load_shapes: bool = True, cells_as_circles: bool = False, - rois: list[int] | None = None, + rois: list[str] | None = None, + raster_models_scale_factors: list[int] | None = None, + imread_kwargs: str = "{}", ) -> None: """Seqfish conversion to SpatialData.""" from spatialdata_io.readers.seqfish import seqfish - rois = list(rois) if rois else None sdata = seqfish( input, load_images=load_images, @@ -278,7 +406,9 @@ def seqfish_wrapper( load_points=load_points, load_shapes=load_shapes, cells_as_circles=cells_as_circles, - rois=rois, + rois=list(rois) if rois else None, + raster_models_scale_factors=list(raster_models_scale_factors) if raster_models_scale_factors else None, + imread_kwargs=_parse_json_param(imread_kwargs, "imread_kwargs"), ) sdata.write(output) @@ -291,11 +421,34 @@ def seqfish_wrapper( default="deepcell", help="What kind of labels to use. [default: 'deepcell']", ) -def steinbock_wrapper(input: str, output: str, labels_kind: Literal["deepcell", "ilastik"] = "deepcell") -> None: +@click.option( + "--imread-kwargs", + type=str, + default="{}", + help="JSON string of keyword arguments passed to imread. [default: {}]", +) +@click.option( + "--image-models-kwargs", + type=str, + default="{}", + help="JSON string of keyword arguments passed to Image2DModel. [default: {}]", +) +def steinbock_wrapper( + input: str, + output: str, + labels_kind: Literal["deepcell", "ilastik"] = "deepcell", + imread_kwargs: str = "{}", + image_models_kwargs: str = "{}", +) -> None: """Steinbock conversion to SpatialData.""" from spatialdata_io.readers.steinbock import steinbock - sdata = steinbock(input, labels_kind=labels_kind) + sdata = steinbock( + input, + labels_kind=labels_kind, + imread_kwargs=_parse_json_param(imread_kwargs, "imread_kwargs"), + image_models_kwargs=_parse_json_param(image_models_kwargs, "image_models_kwargs"), + ) sdata.write(output) @@ -311,17 +464,38 @@ def steinbock_wrapper(input: str, output: str, labels_kind: Literal["deepcell", @click.option( "--optional-tif", type=bool, default=False, help="If True, will read ``{xx.TISSUE_TIF!r}`` files. [default: False]" ) +@click.option( + "--imread-kwargs", + type=str, + default="{}", + help="JSON string of keyword arguments passed to imread. [default: {}]", +) +@click.option( + "--image-models-kwargs", + type=str, + default="{}", + help="JSON string of keyword arguments passed to Image2DModel. [default: {}]", +) def stereoseq_wrapper( input: str, output: str, dataset_id: str | None = None, read_square_bin: bool = True, optional_tif: bool = False, + imread_kwargs: str = "{}", + image_models_kwargs: str = "{}", ) -> None: """Stereoseq conversion to SpatialData.""" from spatialdata_io.readers.stereoseq import stereoseq - sdata = stereoseq(input, dataset_id=dataset_id, read_square_bin=read_square_bin, optional_tif=optional_tif) + sdata = stereoseq( + input, + dataset_id=dataset_id, + read_square_bin=read_square_bin, + optional_tif=optional_tif, + imread_kwargs=_parse_json_param(imread_kwargs, "imread_kwargs"), + image_models_kwargs=_parse_json_param(image_models_kwargs, "image_models_kwargs"), + ) sdata.write(output) @@ -352,6 +526,24 @@ def stereoseq_wrapper( default=None, help="Path to the scalefactors file. [default: None]", ) +@click.option( + "--var-names-make-unique", + type=bool, + default=True, + help="Whether to make variable names unique. [default: True]", +) +@click.option( + "--imread-kwargs", + type=str, + default="{}", + help="JSON string of keyword arguments passed to imread. [default: {}]", +) +@click.option( + "--image-models-kwargs", + type=str, + default="{}", + help="JSON string of keyword arguments passed to Image2DModel. [default: {}]", +) def visium_wrapper( input: str, output: str, @@ -360,6 +552,9 @@ def visium_wrapper( fullres_image_file: str | Path | None = None, tissue_positions_file: str | Path | None = None, scalefactors_file: str | Path | None = None, + var_names_make_unique: bool = True, + imread_kwargs: str = "{}", + image_models_kwargs: str = "{}", ) -> None: """Visium conversion to SpatialData.""" from spatialdata_io.readers.visium import visium @@ -371,6 +566,9 @@ def visium_wrapper( fullres_image_file=fullres_image_file, tissue_positions_file=tissue_positions_file, scalefactors_file=scalefactors_file, + var_names_make_unique=var_names_make_unique, + imread_kwargs=_parse_json_param(imread_kwargs, "imread_kwargs"), + image_models_kwargs=_parse_json_param(image_models_kwargs, "image_models_kwargs"), ) sdata.write(output) @@ -417,6 +615,7 @@ def visium_wrapper( ) @click.option( "--load-segmentations-only", + type=bool, default=None, help="If `True`, only the segmented cell boundaries and their associated counts will be loaded. All binned data will be skipped. [default: None, which will fall back to `False` with a deprecation warning]", ) @@ -426,6 +625,36 @@ def visium_wrapper( default=False, help="If `True` and nucleus segmentation files are present, load nucleus segmentation polygons and the corresponding nucleus-filtered count table. [default: False]", ) +@click.option( + "--var-names-make-unique", + type=bool, + default=True, + help="Whether to make variable names unique. [default: True]", +) +@click.option( + "--gex-only", + type=bool, + default=False, + help="If `True`, only load gene expression features. [default: False]", +) +@click.option( + "--imread-kwargs", + type=str, + default="{}", + help="JSON string of keyword arguments passed to imread. [default: {}]", +) +@click.option( + "--image-models-kwargs", + type=str, + default="{}", + help="JSON string of keyword arguments passed to Image2DModel. [default: {}]", +) +@click.option( + "--anndata-kwargs", + type=str, + default="{}", + help="JSON string of keyword arguments passed to anndata. [default: {}]", +) def visium_hd_wrapper( input: str, output: str, @@ -438,6 +667,11 @@ def visium_hd_wrapper( fullres_image_file: str | Path | None = None, load_all_images: bool = False, annotate_table_by_labels: bool = False, + var_names_make_unique: bool = True, + gex_only: bool = False, + imread_kwargs: str = "{}", + image_models_kwargs: str = "{}", + anndata_kwargs: str = "{}", ) -> None: """Visium HD conversion to SpatialData.""" from spatialdata_io.readers.visium_hd import visium_hd @@ -453,6 +687,11 @@ def visium_hd_wrapper( fullres_image_file=fullres_image_file, load_all_images=load_all_images, annotate_table_by_labels=annotate_table_by_labels, + var_names_make_unique=var_names_make_unique, + gex_only=gex_only, + imread_kwargs=_parse_json_param(imread_kwargs, "imread_kwargs"), + image_models_kwargs=_parse_json_param(image_models_kwargs, "image_models_kwargs"), + anndata_kwargs=_parse_json_param(anndata_kwargs, "anndata_kwargs"), ) sdata.write(output) @@ -463,7 +702,7 @@ def visium_hd_wrapper( @click.option( "--nucleus-boundaries", type=bool, default=True, help="Whether to read Nucleus boundaries. [default: True]" ) -@click.option("--cells-as-circles", type=bool, default=None, help="Whether to read cells as circles. [default: None]") +@click.option("--cells-as-circles", type=bool, default=False, help="Whether to read cells as circles. [default: False]") @click.option("--cells-labels", type=bool, default=True, help="Whether to read cells labels (raster). [default: True]") @click.option( "--nucleus-labels", type=bool, default=True, help="Whether to read nucleus labels (raster). [default: True]" @@ -485,13 +724,37 @@ def visium_hd_wrapper( default=True, help="Whether to read cells annotations in the AnnData table. [default: True]", ) +@click.option( + "--gex-only", + type=bool, + default=True, + help="If `True`, only load gene expression features. [default: True]", +) +@click.option( + "--imread-kwargs", + type=str, + default="{}", + help="JSON string of keyword arguments passed to imread. [default: {}]", +) +@click.option( + "--image-models-kwargs", + type=str, + default="{}", + help="JSON string of keyword arguments passed to Image2DModel. [default: {}]", +) +@click.option( + "--labels-models-kwargs", + type=str, + default="{}", + help="JSON string of keyword arguments passed to Labels2DModel. [default: {}]", +) def xenium_wrapper( input: str, output: str, *, cells_boundaries: bool = True, nucleus_boundaries: bool = True, - cells_as_circles: bool | None = None, + cells_as_circles: bool = False, cells_labels: bool = True, nucleus_labels: bool = True, transcripts: bool = True, @@ -499,6 +762,10 @@ def xenium_wrapper( morphology_focus: bool = True, aligned_images: bool = True, cells_table: bool = True, + gex_only: bool = True, + imread_kwargs: str = "{}", + image_models_kwargs: str = "{}", + labels_models_kwargs: str = "{}", ) -> None: """Xenium conversion to SpatialData.""" from spatialdata_io.readers.xenium import xenium @@ -515,12 +782,22 @@ def xenium_wrapper( morphology_focus=morphology_focus, aligned_images=aligned_images, cells_table=cells_table, + gex_only=gex_only, + imread_kwargs=_parse_json_param(imread_kwargs, "imread_kwargs"), + image_models_kwargs=_parse_json_param(image_models_kwargs, "image_models_kwargs"), + labels_models_kwargs=_parse_json_param(labels_models_kwargs, "labels_models_kwargs"), ) sdata.write(output) @cli.command(name="macsima") @_input_output_click_options +@click.option( + "--parsing-style", + type=click.Choice(["auto", "processed_single_folder", "processed_multiple_folders", "raw"]), + default="auto", + help="Parsing style for MACSima data. [default: auto]", +) @click.option( "--filter-folder-names", type=str, @@ -583,10 +860,17 @@ def xenium_wrapper( default=False, help="Whether to include the cycle number in the channel name. [default: False]", ) +@click.option( + "--imread-kwargs", + type=str, + default="{}", + help="JSON string of keyword arguments passed to imread. [default: {}]", +) def macsima_wrapper( input: str, output: str, *, + parsing_style: str = "auto", filter_folder_names: list[str] | None = None, subset: int | None = None, c_subset: int | None = None, @@ -600,13 +884,16 @@ def macsima_wrapper( split_threshold_nuclei_channel: int | None = 2, skip_rounds: list[int] | None = None, include_cycle_in_channel_name: bool = False, + imread_kwargs: str = "{}", ) -> None: """Read MACSima formatted dataset and convert to SpatialData.""" from spatialdata_io.readers.macsima import macsima sdata = macsima( path=input, + parsing_style=parsing_style, filter_folder_names=filter_folder_names, + imread_kwargs=_parse_json_param(imread_kwargs, "imread_kwargs"), subset=subset, c_subset=c_subset, max_chunk_size=max_chunk_size, diff --git a/tests/test_cli_alignment.py b/tests/test_cli_alignment.py new file mode 100644 index 00000000..cab2c96a --- /dev/null +++ b/tests/test_cli_alignment.py @@ -0,0 +1,116 @@ +"""Structural test: CLI/reader parameter alignment. + +Every parameter of each reader function must be exposed by its CLI wrapper, and +every wrapper parameter (beyond ``input``/``output``) must correspond to a reader +parameter. Both directions are checked as a symmetric difference. + +Runs automatically in CI on every PR to catch drift between readers and +__main__.py before it reaches users. + +What this test does NOT check +------------------------------ +- **Types**: a reader may declare ``imread_kwargs: Mapping[str, Any]`` while the + CLI accepts a JSON string (``str``). The types will not match, and this test + will not catch that mismatch. +- **Default values**: a reader default and the corresponding ``@click.option`` + default can silently diverge (e.g. ``n_jobs=None`` vs ``default=1``). + +Both of these require manual or AI-assisted review. Run the following command +to ask Claude Code to audit type and default alignment for a specific reader:: + + claude "In src/spatialdata_io/__main__.py, compare every @click.option + default and type annotation in against the corresponding + parameter in the reader function in src/spatialdata_io/readers/.py. + List any mismatches in default values or types." + +When this test turns red, fix it by adding (or removing) the relevant +``@click.option`` and parameter in ``__main__.py``. If a reader parameter +should intentionally stay hidden (e.g. deprecated), add it to +``_READER_EXCEPTIONS`` with a comment explaining why. +""" + +import inspect +from typing import Any + +import pytest + +# (reader_module_path, reader_func_name, cli_wrapper_func_name) +_READERS = [ + ("spatialdata_io.readers.codex", "codex", "codex_wrapper"), + ("spatialdata_io.readers.cosmx", "cosmx", "cosmx_wrapper"), + ("spatialdata_io.readers.curio", "curio", "curio_wrapper"), + ("spatialdata_io.readers.dbit", "dbit", "dbit_wrapper"), + ("spatialdata_io.readers.iss", "iss", "iss_wrapper"), + ("spatialdata_io.readers.macsima", "macsima", "macsima_wrapper"), + ("spatialdata_io.readers.mcmicro", "mcmicro", "mcmicro_wrapper"), + ("spatialdata_io.readers.merscope", "merscope", "merscope_wrapper"), + ("spatialdata_io.readers.seqfish", "seqfish", "seqfish_wrapper"), + ("spatialdata_io.readers.steinbock", "steinbock", "steinbock_wrapper"), + ("spatialdata_io.readers.stereoseq", "stereoseq", "stereoseq_wrapper"), + ("spatialdata_io.readers.visium", "visium", "visium_wrapper"), + ("spatialdata_io.readers.visium_hd", "visium_hd", "visium_hd_wrapper"), + ("spatialdata_io.readers.xenium", "xenium", "xenium_wrapper"), +] + +# Parameters to skip in the reader (first positional path arg, and **kwargs catch-alls) +_READER_SKIP = {"path"} +# Parameters to skip in the wrapper (always present, not reader-specific) +_WRAPPER_SKIP = {"input", "output"} + +# Intentionally unexposed reader parameters: {reader_func_name: {param, ...}} +# Document *why* each entry exists so future contributors can re-evaluate. +_READER_EXCEPTIONS: dict[str, set[str]] = { + # n_jobs is deprecated in the xenium reader (kept for backward compat but + # has no effect); exposing it in the CLI would mislead users. + "xenium": {"n_jobs"}, +} + + +def _reader_params(module_path: str, func_name: str) -> set[str]: + import importlib + + module = importlib.import_module(module_path) + func = getattr(module, func_name) + sig = inspect.signature(func) + return { + name + for name, param in sig.parameters.items() + if param.kind not in (inspect.Parameter.VAR_KEYWORD, inspect.Parameter.VAR_POSITIONAL) + and name not in _READER_SKIP + } + + +def _wrapper_params(func_name: str) -> set[str]: + import importlib + + module = importlib.import_module("spatialdata_io.__main__") + obj = getattr(module, func_name) + # @cli.command replaces the function with a click.Command; the Python + # function lives in .callback. + func = obj.callback if hasattr(obj, "callback") else obj + sig = inspect.signature(func) + return {name for name in sig.parameters if name not in _WRAPPER_SKIP} + + +@pytest.mark.parametrize("module_path,reader_name,wrapper_name", _READERS) +def test_cli_exposes_all_reader_params(module_path: str, reader_name: str, wrapper_name: str) -> None: + """CLI wrapper and reader must have exactly the same parameter names (symmetric).""" + reader = _reader_params(module_path, reader_name) + wrapper = _wrapper_params(wrapper_name) + exceptions = _READER_EXCEPTIONS.get(reader_name, set()) + + # Reader params absent from the CLI — the wrapper is missing options. + missing = reader - wrapper - exceptions + assert not missing, ( + f"{wrapper_name} is missing CLI parameters for reader '{reader_name}': {sorted(missing)}\n" + f"Add @click.option and a matching parameter to {wrapper_name} in __main__.py.\n" + f"If the parameter should intentionally be hidden, add it to _READER_EXCEPTIONS['{reader_name}']." + ) + + # Wrapper params absent from the reader — the CLI has stale/extra options. + extra = wrapper - reader - exceptions + assert not extra, ( + f"{wrapper_name} has CLI parameters not present in reader '{reader_name}': {sorted(extra)}\n" + f"Remove the corresponding @click.option from {wrapper_name} in __main__.py,\n" + f"or add the parameter to the reader if it was omitted by mistake." + ) diff --git a/tests/test_xenium.py b/tests/test_xenium.py index a9beddaf..be353130 100644 --- a/tests/test_xenium.py +++ b/tests/test_xenium.py @@ -5,6 +5,7 @@ import numpy as np import pytest from click.testing import CliRunner +from pytest_mock import MockerFixture from spatialdata import match_table_to_element, read_zarr from spatialdata.models import TableModel, get_table_keys @@ -266,3 +267,57 @@ def test_xenium_other_feature_types(dataset: str, gex_only: bool) -> None: else: assert ValueError(f"Unexpected dataset {dataset}") + + +# ── CLI JSON kwargs tests (no real data needed) ─────────────────────────────── + + +@pytest.mark.parametrize( + "kwarg_name", + ["--imread-kwargs", "--image-models-kwargs", "--labels-models-kwargs"], +) +def test_cli_xenium_invalid_json_rejected(runner: CliRunner, tmp_path: Path, kwarg_name: str) -> None: + """Invalid JSON for any kwargs option must produce a non-zero exit and a clear error.""" + result = runner.invoke( + xenium_wrapper, + [ + "--input", + str(tmp_path), + "--output", + str(tmp_path / "out.zarr"), + kwarg_name, + "not-valid-json{", + ], + ) + assert result.exit_code != 0 + assert "Invalid JSON" in result.output + + +@pytest.mark.parametrize( + ("kwarg_name", "kwarg_param"), + [ + ("--imread-kwargs", "imread_kwargs"), + ("--image-models-kwargs", "image_models_kwargs"), + ("--labels-models-kwargs", "labels_models_kwargs"), + ], +) +def test_cli_xenium_valid_json_forwarded( + runner: CliRunner, tmp_path: Path, mocker: MockerFixture, kwarg_name: str, kwarg_param: str +) -> None: + """Valid JSON kwargs must be parsed and forwarded to the xenium reader as a dict.""" + mock_xenium = mocker.patch("spatialdata_io.readers.xenium.xenium") + mock_xenium.return_value = mocker.MagicMock() + result = runner.invoke( + xenium_wrapper, + [ + "--input", + str(tmp_path), + "--output", + str(tmp_path / "out.zarr"), + kwarg_name, + '{"chunks": 512}', + ], + ) + assert result.exit_code == 0, result.output + call_kwargs = mock_xenium.call_args.kwargs + assert call_kwargs[kwarg_param] == {"chunks": 512}