diff --git a/.gitignore b/.gitignore index 35db05af..49738283 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,9 @@ _version.py node_modules/ .code-workspace +# memray report +*.bin + # test datasets (e.g. Xenium ones) # symlinks data diff --git a/asv.conf.json b/asv.conf.json index 55f61b90..c3005b58 100644 --- a/asv.conf.json +++ b/asv.conf.json @@ -3,10 +3,10 @@ "project": "spatialdata-io", "project_url": "https://github.com/scverse/spatialdata-io", "repo": ".", - "branches": ["image-reader-chunkwise"], + "branches": ["faster-imports", "main"], "dvcs": "git", "environment_type": "virtualenv", - "pythons": ["3.12"], + "pythons": ["3.13"], "build_command": [], "install_command": ["python -m pip install {build_dir}[test]"], "uninstall_command": ["python -m pip uninstall -y {project}"], @@ -17,7 +17,7 @@ "hash_length": 8, "build_cache_size": 2, "install_timeout": 600, - "repeat": 3, + "repeat": 5, "processes": 1, "attribute_selection": ["time_*", "peakmem_*"] } diff --git a/benchmarks/benchmark_image.py b/benchmarks/benchmark_image.py index 3096fee7..1b660edf 100644 --- a/benchmarks/benchmark_image.py +++ b/benchmarks/benchmark_image.py @@ -16,7 +16,7 @@ from spatialdata._logging import logger from xarray import DataArray -from spatialdata_io import image # type: ignore[attr-defined] +from spatialdata_io import image # ============================================================================= # CONFIGURATION - Edit these values to match your setup diff --git a/benchmarks/benchmark_imports.py b/benchmarks/benchmark_imports.py new file mode 100644 index 00000000..9a1ca1b6 --- /dev/null +++ b/benchmarks/benchmark_imports.py @@ -0,0 +1,103 @@ +"""ASV benchmarks for spatialdata-io import times. + +Measures how long it takes to import the package and individual readers +in a fresh subprocess, isolating import overhead from runtime work. + +Running (with the current environment, no virtualenv rebuild): + # Quick sanity check (single iteration): + asv run --python=same --quick --show-stderr -v -b ImportBenchmark + + # Full benchmark on current commit: + asv run --python=same --show-stderr -v -b ImportBenchmark + + # Compare two branches (using --python=same, one-liner): + git stash && git checkout main && pip install -e . -q \ + && asv run --python=same -v -b ImportBenchmark \ + && git checkout faster-imports && git stash pop && pip install -e . -q \ + && asv run --python=same -v -b ImportBenchmark + # Then view the comparison: + asv compare $(git rev-parse main) $(git rev-parse faster-imports) + + # Compare two branches (let ASV build virtualenvs, slower first run): + asv continuous --show-stderr -v -b ImportBenchmark main faster-imports + + # Generate an HTML report: + asv publish && asv preview +""" + +import subprocess +import sys + + +def _import_time(statement: str) -> float: + """Time an import in a fresh subprocess. Returns seconds.""" + code = f"import time; t0=time.perf_counter(); {statement}; print(time.perf_counter()-t0)" + result = subprocess.run( + [sys.executable, "-c", code], + capture_output=True, + text=True, + ) + if result.returncode != 0: + raise RuntimeError(result.stderr) + return float(result.stdout.strip()) + + +class ImportBenchmark: + """Import-time benchmarks for spatialdata-io. + + Each ``time_*`` method is a separate ASV benchmark. + They run in isolated subprocesses so that one import + does not warm the cache for the next. + """ + + # ASV settings tuned for subprocess-based import timing: + timeout = 120 # seconds before ASV kills a benchmark; generous since each + # call spawns a subprocess (~2s each × 10 repeats = ~20s worst case) + repeat = 5 # number of timing samples ASV collects; high because import + # times have variance from OS caching / disk I/O / background load; + # ASV reports the median and IQR from these samples + number = 1 # calls per sample; must be 1 because each call spawns a fresh + # subprocess — running >1 would just re-import in a warm process + warmup_time = 0 # seconds of warm-up iterations before timing; disabled because + # each call is already a cold subprocess — warming up the parent + # process is meaningless + processes = 1 # number of ASV worker processes; 1 avoids parallel subprocesses + # competing for CPU / disk and inflating timings + + # -- top-level package ------------------------------------------------- + + def time_import_spatialdata_io(self) -> float: + """Wall time: ``import spatialdata_io`` (lazy, no readers loaded).""" + return _import_time("import spatialdata_io") + + # -- single reader via the public API ---------------------------------- + + def time_from_spatialdata_io_import_xenium(self) -> float: + """Wall time: ``from spatialdata_io import xenium``.""" + return _import_time("from spatialdata_io import xenium") + + def time_from_spatialdata_io_import_visium(self) -> float: + """Wall time: ``from spatialdata_io import visium``.""" + return _import_time("from spatialdata_io import visium") + + def time_from_spatialdata_io_import_visium_hd(self) -> float: + """Wall time: ``from spatialdata_io import visium_hd``.""" + return _import_time("from spatialdata_io import visium_hd") + + def time_from_spatialdata_io_import_merscope(self) -> float: + """Wall time: ``from spatialdata_io import merscope``.""" + return _import_time("from spatialdata_io import merscope") + + def time_from_spatialdata_io_import_cosmx(self) -> float: + """Wall time: ``from spatialdata_io import cosmx``.""" + return _import_time("from spatialdata_io import cosmx") + + # -- key dependencies (reference) -------------------------------------- + + def time_import_spatialdata(self) -> float: + """Wall time: ``import spatialdata`` (reference).""" + return _import_time("import spatialdata") + + def time_import_anndata(self) -> float: + """Wall time: ``import anndata`` (reference).""" + return _import_time("import anndata") diff --git a/benchmarks/benchmark_xenium.py b/benchmarks/benchmark_xenium.py index b1f09afe..58ac614f 100644 --- a/benchmarks/benchmark_xenium.py +++ b/benchmarks/benchmark_xenium.py @@ -39,7 +39,7 @@ from spatialdata import SpatialData -from spatialdata_io import xenium # type: ignore[attr-defined] +from spatialdata_io import xenium # ============================================================================= # CONFIGURATION - Edit these paths to match your setup diff --git a/pyproject.toml b/pyproject.toml index 6c1e6095..d97dc276 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -185,6 +185,8 @@ lint.ignore = [ # Unused imports "F401", ] +[tool.ruff.lint.per-file-ignores] +"src/spatialdata_io/__init__.py" = ["I001"] [tool.jupytext] formats = "ipynb,md" diff --git a/src/spatialdata_io/__init__.py b/src/spatialdata_io/__init__.py index 0648c09e..8badb295 100644 --- a/src/spatialdata_io/__init__.py +++ b/src/spatialdata_io/__init__.py @@ -1,26 +1,36 @@ +from importlib import import_module from importlib.metadata import version +from typing import Any, TYPE_CHECKING -from spatialdata_io.converters.generic_to_zarr import generic_to_zarr -from spatialdata_io.readers.codex import codex -from spatialdata_io.readers.cosmx import cosmx -from spatialdata_io.readers.curio import curio -from spatialdata_io.readers.dbit import dbit -from spatialdata_io.readers.generic import generic, geojson, image -from spatialdata_io.readers.macsima import macsima -from spatialdata_io.readers.mcmicro import mcmicro -from spatialdata_io.readers.merscope import merscope -from spatialdata_io.readers.seqfish import seqfish -from spatialdata_io.readers.steinbock import steinbock -from spatialdata_io.readers.stereoseq import stereoseq -from spatialdata_io.readers.visium import visium -from spatialdata_io.readers.visium_hd import visium_hd -from spatialdata_io.readers.xenium import ( - xenium, - xenium_aligned_image, - xenium_explorer_selection, -) - -_readers_technologies = [ +__version__ = version("spatialdata-io") + +_LAZY_IMPORTS: dict[str, str] = { + # readers + "codex": "spatialdata_io.readers.codex", + "cosmx": "spatialdata_io.readers.cosmx", + "curio": "spatialdata_io.readers.curio", + "dbit": "spatialdata_io.readers.dbit", + "macsima": "spatialdata_io.readers.macsima", + "mcmicro": "spatialdata_io.readers.mcmicro", + "merscope": "spatialdata_io.readers.merscope", + "seqfish": "spatialdata_io.readers.seqfish", + "steinbock": "spatialdata_io.readers.steinbock", + "stereoseq": "spatialdata_io.readers.stereoseq", + "visium": "spatialdata_io.readers.visium", + "visium_hd": "spatialdata_io.readers.visium_hd", + "xenium": "spatialdata_io.readers.xenium", + "xenium_aligned_image": "spatialdata_io.readers.xenium", + "xenium_explorer_selection": "spatialdata_io.readers.xenium", + # readers file types + "generic": "spatialdata_io.readers.generic", + "geojson": "spatialdata_io.readers.generic", + "image": "spatialdata_io.readers.generic", + # converters + "generic_to_zarr": "spatialdata_io.converters.generic_to_zarr", +} + +__all__ = [ + # readers "codex", "cosmx", "curio", @@ -34,28 +44,57 @@ "visium", "visium_hd", "xenium", -] - -_readers_file_types = [ + "xenium_aligned_image", + "xenium_explorer_selection", + # readers file types "generic", - "image", "geojson", -] - -_converters = [ + "image", + # converters "generic_to_zarr", ] -__all__ = ( - [ - "xenium_aligned_image", - "xenium_explorer_selection", - ] - + _readers_technologies - + _readers_file_types - + _converters -) +def __getattr__(name: str) -> Any: + if name in _LAZY_IMPORTS: + module_path = _LAZY_IMPORTS[name] + mod = import_module(module_path) + val = getattr(mod, name) + globals()[name] = val + return val + else: + try: + return globals()[name] + except KeyError as e: + raise AttributeError(f"Module 'spatialdata_io' has no attribute '{name}'") from e -__version__ = version("spatialdata-io") +def __dir__() -> list[str]: + return __all__ + ["__version__"] + + +if TYPE_CHECKING: + # readers + from spatialdata_io.readers.codex import codex + from spatialdata_io.readers.cosmx import cosmx + from spatialdata_io.readers.curio import curio + from spatialdata_io.readers.dbit import dbit + from spatialdata_io.readers.macsima import macsima + from spatialdata_io.readers.mcmicro import mcmicro + from spatialdata_io.readers.merscope import merscope + from spatialdata_io.readers.seqfish import seqfish + from spatialdata_io.readers.steinbock import steinbock + from spatialdata_io.readers.stereoseq import stereoseq + from spatialdata_io.readers.visium import visium + from spatialdata_io.readers.visium_hd import visium_hd + from spatialdata_io.readers.xenium import ( + xenium, + xenium_aligned_image, + xenium_explorer_selection, + ) + + # readers file types + from spatialdata_io.readers.generic import generic, geojson, image + + # converters + from spatialdata_io.converters.generic_to_zarr import generic_to_zarr diff --git a/src/spatialdata_io/__main__.py b/src/spatialdata_io/__main__.py index 35fd24de..970bf180 100644 --- a/src/spatialdata_io/__main__.py +++ b/src/spatialdata_io/__main__.py @@ -1,31 +1,12 @@ -import importlib from collections.abc import Callable from pathlib import Path from typing import Any, Literal import click -# dynamically import all readers and converters (also the experimental ones) -from spatialdata_io import _converters, _readers_file_types, _readers_technologies from spatialdata_io._constants._constants import VisiumKeys -from spatialdata_io.converters.generic_to_zarr import generic_to_zarr -from spatialdata_io.experimental import _converters as _experimental_converters -from spatialdata_io.experimental import ( - _readers_file_types as _experimental_readers_file_types, -) -from spatialdata_io.experimental import ( - _readers_technologies as _experimental_readers_technologies, -) from spatialdata_io.readers.generic import VALID_IMAGE_TYPES, VALID_SHAPE_TYPES -for func in _readers_technologies + _readers_file_types + _converters: - module = importlib.import_module("spatialdata_io") - globals()[func] = getattr(module, func) - -for func in _experimental_readers_technologies + _experimental_readers_file_types + _experimental_converters: - module = importlib.import_module("spatialdata_io.experimental") - globals()[func] = getattr(module, func) - @click.group() def cli() -> None: @@ -66,7 +47,9 @@ def _input_output_click_options(func: Callable[..., None]) -> Callable[..., None ) def codex_wrapper(input: str, output: str, fcs: bool = True) -> None: """Codex conversion to SpatialData.""" - sdata = codex(input, fcs=fcs) # type: ignore[name-defined] # noqa: F821 + from spatialdata_io.readers.codex import codex + + sdata = codex(input, fcs=fcs) sdata.write(output) @@ -76,7 +59,9 @@ def codex_wrapper(input: str, output: str, fcs: bool = True) -> 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: """Cosmic conversion to SpatialData.""" - sdata = cosmx(input, dataset_id=dataset_id, transcripts=transcripts) # type: ignore[name-defined] # noqa: F821 + from spatialdata_io.readers.cosmx import cosmx + + sdata = cosmx(input, dataset_id=dataset_id, transcripts=transcripts) sdata.write(output) @@ -84,7 +69,9 @@ def cosmx_wrapper(input: str, output: str, dataset_id: str | None = None, transc @_input_output_click_options def curio_wrapper(input: str, output: str) -> None: """Curio conversion to SpatialData.""" - sdata = curio(input) # type: ignore[name-defined] # noqa: F821 + from spatialdata_io.readers.curio import curio + + sdata = curio(input) sdata.write(output) @@ -117,7 +104,9 @@ def dbit_wrapper( border_scale: float = 1, ) -> None: """Conversion of DBit-seq to SpatialData.""" - sdata = dbit( # type: ignore[name-defined] # noqa: F821 + from spatialdata_io.readers.dbit import dbit + + sdata = dbit( input, anndata_path=anndata_path, barcode_position=barcode_position, @@ -174,7 +163,9 @@ def iss_wrapper( multiscale_labels: bool = True, ) -> None: """ISS conversion to SpatialData.""" - sdata = iss( # type: ignore[name-defined] # noqa: F821 + from spatialdata_io.readers.iss import iss + + sdata = iss( input, raw_relative_path, labels_relative_path, @@ -194,7 +185,9 @@ def iss_wrapper( @click.option("--output", "-o", type=click.Path(), help="Path to the output.zarr file.", required=True) def mcmicro_wrapper(input: str, output: str) -> None: """Conversion of MCMicro to SpatialData.""" - sdata = mcmicro(input) # type: ignore[name-defined] # noqa: F821 + from spatialdata_io.readers.mcmicro import mcmicro + + sdata = mcmicro(input) sdata.write(output) @@ -233,7 +226,9 @@ def merscope_wrapper( mosaic_images: bool = True, ) -> None: """Merscope conversion to SpatialData.""" - sdata = merscope( # type: ignore[name-defined] # noqa: F821 + from spatialdata_io.readers.merscope import merscope + + sdata = merscope( input, vpt_outputs=vpt_outputs, z_layers=z_layers, @@ -273,8 +268,10 @@ def seqfish_wrapper( rois: list[int] | None = None, ) -> None: """Seqfish conversion to SpatialData.""" + from spatialdata_io.readers.seqfish import seqfish + rois = list(rois) if rois else None - sdata = seqfish( # type: ignore[name-defined] # noqa: F821 + sdata = seqfish( input, load_images=load_images, load_labels=load_labels, @@ -296,7 +293,9 @@ def seqfish_wrapper( ) def steinbock_wrapper(input: str, output: str, labels_kind: Literal["deepcell", "ilastik"] = "deepcell") -> None: """Steinbock conversion to SpatialData.""" - sdata = steinbock(input, labels_kind=labels_kind) # type: ignore[name-defined] # noqa: F821 + from spatialdata_io.readers.steinbock import steinbock + + sdata = steinbock(input, labels_kind=labels_kind) sdata.write(output) @@ -320,7 +319,9 @@ def stereoseq_wrapper( optional_tif: bool = False, ) -> None: """Stereoseq conversion to SpatialData.""" - sdata = stereoseq(input, dataset_id=dataset_id, read_square_bin=read_square_bin, optional_tif=optional_tif) # type: ignore[name-defined] # noqa: F821 + 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.write(output) @@ -361,7 +362,9 @@ def visium_wrapper( scalefactors_file: str | Path | None = None, ) -> None: """Visium conversion to SpatialData.""" - sdata = visium( # type: ignore[name-defined] # noqa: F821 + from spatialdata_io.readers.visium import visium + + sdata = visium( input, dataset_id=dataset_id, counts_file=counts_file, @@ -437,7 +440,9 @@ def visium_hd_wrapper( annotate_table_by_labels: bool = False, ) -> None: """Visium HD conversion to SpatialData.""" - sdata = visium_hd( # type: ignore[name-defined] # noqa: F821 + from spatialdata_io.readers.visium_hd import visium_hd + + sdata = visium_hd( path=input, dataset_id=dataset_id, filtered_counts_file=filtered_counts_file, @@ -496,7 +501,9 @@ def xenium_wrapper( cells_table: bool = True, ) -> None: """Xenium conversion to SpatialData.""" - sdata = xenium( # type: ignore[name-defined] # noqa: F821 + from spatialdata_io.readers.xenium import xenium + + sdata = xenium( input, cells_boundaries=cells_boundaries, nucleus_boundaries=nucleus_boundaries, @@ -595,7 +602,9 @@ def macsima_wrapper( include_cycle_in_channel_name: bool = False, ) -> None: """Read MACSima formatted dataset and convert to SpatialData.""" - sdata = macsima( # type: ignore[name-defined] # noqa: F821 + from spatialdata_io.readers.macsima import macsima + + sdata = macsima( path=input, filter_folder_names=filter_folder_names, subset=subset, @@ -649,6 +658,8 @@ def read_generic_wrapper( coordinate_system: str | None = None, ) -> None: """Read generic data to SpatialData.""" + from spatialdata_io.converters.generic_to_zarr import generic_to_zarr + if data_axes is not None and "".join(sorted(data_axes)) not in ["cxy", "cxyz"]: raise ValueError("data_axes must be a permutation of 'cyx' or 'czyx'.") generic_to_zarr(input=input, output=output, name=name, data_axes=data_axes, coordinate_system=coordinate_system) diff --git a/src/spatialdata_io/readers/_utils/_image.py b/src/spatialdata_io/readers/_utils/_image.py index c5c8d7e3..deba6bf6 100644 --- a/src/spatialdata_io/readers/_utils/_image.py +++ b/src/spatialdata_io/readers/_utils/_image.py @@ -1,12 +1,18 @@ -from collections.abc import Callable, Mapping, Sequence -from typing import Any +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from typing import TYPE_CHECKING, Any import dask.array as da import numpy as np from dask import delayed -from numpy.typing import NDArray from spatialdata.models.models import Chunks_t +if TYPE_CHECKING: + from collections.abc import Callable + + from numpy.typing import NDArray + __all__ = ["Chunks_t", "_compute_chunks", "_read_chunks"] _Y_IDX = 0 diff --git a/src/spatialdata_io/readers/_utils/_utils.py b/src/spatialdata_io/readers/_utils/_utils.py index 1dbd496f..75eb8c5f 100644 --- a/src/spatialdata_io/readers/_utils/_utils.py +++ b/src/spatialdata_io/readers/_utils/_utils.py @@ -7,8 +7,6 @@ from anndata import AnnData from anndata.io import read_text from h5py import File -from ome_types import from_tiff -from ome_types.model import Pixels, UnitsLength from spatialdata._logging import logger from spatialdata_io.readers._utils._read_10x_h5 import _read_10x_h5 @@ -17,6 +15,7 @@ from collections.abc import Mapping from anndata import AnnData + from ome_types.model import Pixels from spatialdata import SpatialData PathLike = os.PathLike | str # type:ignore[type-arg] @@ -101,6 +100,8 @@ def calc_scale_factors(lower_scale_limit: float, min_size: int = 1000, default_s def parse_channels(path: Path) -> list[str]: """Parse channel names from an OME-TIFF file.""" + from ome_types import from_tiff + images = from_tiff(path).images if len(images) > 1: logger.warning("Found multiple images in OME-TIFF file. Only the first one will be used.") @@ -121,6 +122,9 @@ def _set_reader_metadata(sdata: SpatialData, reader: str) -> SpatialData: def parse_physical_size(path: Path | None = None, ome_pixels: Pixels | None = None) -> float: """Parse physical size from OME-TIFF to micrometer.""" + from ome_types import from_tiff + from ome_types.model import UnitsLength + pixels = ome_pixels or from_tiff(path).images[0].pixels logger.debug(pixels) if pixels.physical_size_x_unit != pixels.physical_size_y_unit: diff --git a/src/spatialdata_io/readers/generic.py b/src/spatialdata_io/readers/generic.py index 95bcf66c..c0d9bc3e 100644 --- a/src/spatialdata_io/readers/generic.py +++ b/src/spatialdata_io/readers/generic.py @@ -1,7 +1,7 @@ from __future__ import annotations from pathlib import Path -from typing import TYPE_CHECKING, Protocol, TypeVar +from typing import TYPE_CHECKING import dask.array as da import numpy as np @@ -35,12 +35,6 @@ __all__ = ["generic", "geojson", "image", "VALID_IMAGE_TYPES", "VALID_SHAPE_TYPES"] -T = TypeVar("T", bound=np.generic) # Restrict to NumPy scalar types - - -class DaskArray(Protocol[T]): - dtype: np.dtype[T] - @docstring_parameter( valid_image_types=", ".join(VALID_IMAGE_TYPES), @@ -96,7 +90,7 @@ def _tiff_to_chunks( input: Path, axes_dim_mapping: dict[str, int], chunks_cyx: dict[str, int], -) -> list[list[DaskArray[np.number]]]: +) -> list[list[da.Array]]: """Chunkwise reader for tiff files. Creates spatial tiles from a TIFF file. Each tile contains all channels. @@ -115,7 +109,7 @@ def _tiff_to_chunks( Returns ------- - list[list[DaskArray]] + list[list[dask.array.Array]] 2D list of dask arrays representing spatial tiles, each with shape (n_channels, height, width). """ # Lazy file reader diff --git a/src/spatialdata_io/readers/xenium.py b/src/spatialdata_io/readers/xenium.py index 01121c3d..ec7258a9 100644 --- a/src/spatialdata_io/readers/xenium.py +++ b/src/spatialdata_io/readers/xenium.py @@ -11,7 +11,6 @@ import dask.array as da import numpy as np -import ome_types import packaging.version import pandas as pd import pyarrow.compute as pc @@ -353,6 +352,9 @@ def xenium( 3: XeniumKeys.MORPHOLOGY_FOCUS_CHANNEL_3.value, } else: + # slow import + from ome_types import from_xml + # v4 if XeniumKeys.MORPHOLOGY_FOCUS_V4_DAPI_FILENAME.value not in files: raise ValueError( @@ -360,7 +362,7 @@ def xenium( f"chNNNN_.ome.tif starting with {XeniumKeys.MORPHOLOGY_FOCUS_V4_DAPI_FILENAME.value}" ) first_tiff_path = morphology_focus_dir / XeniumKeys.MORPHOLOGY_FOCUS_V4_DAPI_FILENAME.value - ome = ome_types.from_xml(tifffile.tiffcomment(first_tiff_path), validate=False) + ome = from_xml(tifffile.tiffcomment(first_tiff_path), validate=False) # Get channel names from the OME XML ome_channels = ome.images[0].pixels.channels diff --git a/tests/test_basic.py b/tests/test_basic.py deleted file mode 100644 index 71ac4d3a..00000000 --- a/tests/test_basic.py +++ /dev/null @@ -1,5 +0,0 @@ -import spatialdata_io - - -def test_package_has_version() -> None: - assert spatialdata_io.__version__ diff --git a/tests/test_init.py b/tests/test_init.py new file mode 100644 index 00000000..8a98211c --- /dev/null +++ b/tests/test_init.py @@ -0,0 +1,23 @@ +import spatialdata_io + + +def test_package_has_version() -> None: + assert spatialdata_io.__version__ + + +def test_all_matches_lazy_imports() -> None: + """Ensure __all__ and _LAZY_IMPORTS stay in sync.""" + assert set(spatialdata_io.__all__) == set(spatialdata_io._LAZY_IMPORTS.keys()) + + +def test_all_are_importable() -> None: + """Every name in __all__ should be accessible on the module.""" + for name in spatialdata_io.__all__: + assert hasattr(spatialdata_io, name), f"{name!r} listed in __all__ but not resolvable" + + +def test_all_are_in_dir() -> None: + """dir(spatialdata_io) should expose everything in __all__.""" + module_dir = dir(spatialdata_io) + for name in spatialdata_io.__all__: + assert name in module_dir, f"{name!r} missing from dir()"