diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1c226bb2..81b259b4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -89,9 +89,9 @@ jobs: uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: data/ - key: "${{ runner.os }}-data-${{ hashFiles('tests/conftest.py') }}" + key: "${{ runner.os }}-data-${{ hashFiles('src/ngio/utils/_datasets.py') }}" restore-keys: | - "${{ runner.os }}-data-" + ${{ runner.os }}-data- - name: Restore/save shared data cache (trusted) if: github.event_name != 'pull_request' @@ -102,12 +102,12 @@ jobs: uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: data/ - key: "${{ runner.os }}-data-${{ hashFiles('tests/conftest.py') }}" + key: "${{ runner.os }}-data-${{ hashFiles('src/ngio/utils/_datasets.py') }}" restore-keys: | - "${{ runner.os }}-data-" + ${{ runner.os }}-data- - name: ๐Ÿงช Run Tests - run: pixi run -e ${{ matrix.environment }} pytest ${{ matrix.platform != 'ubuntu-latest' && '--no-cov' || '' }} + run: pixi run -e ${{ matrix.environment }} pytest -n 4 --durations=10 ${{ matrix.platform == 'ubuntu-latest' && matrix.environment == 'test11' && '--cov=ngio --cov-report=xml' || '' }} # If something goes wrong with scheduled tests, open an issue in the repo - name: ๐Ÿ“ Report Failures @@ -124,7 +124,7 @@ jobs: update_existing: true - name: Coverage - if: success() && matrix.platform == 'ubuntu-latest' + if: success() && matrix.platform == 'ubuntu-latest' && matrix.environment == 'test11' uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 with: token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/ci_upstream.yml b/.github/workflows/ci_upstream.yml index 85a9eddc..4c8cc73e 100644 --- a/.github/workflows/ci_upstream.yml +++ b/.github/workflows/ci_upstream.yml @@ -56,12 +56,12 @@ jobs: uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: data/ - key: "${{ runner.os }}-data-${{ hashFiles('tests/conftest.py') }}" + key: "${{ runner.os }}-data-${{ hashFiles('src/ngio/utils/_datasets.py') }}" restore-keys: | - "${{ runner.os }}-data-" + ${{ runner.os }}-data- - name: ๐Ÿงช Run Tests - run: pytest --no-cov + run: pytest -n 4 --durations=10 - name: ๐Ÿ“ Report Failures if: failure() && github.event_name == 'schedule' @@ -109,12 +109,12 @@ jobs: uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: data/ - key: "${{ runner.os }}-data-${{ hashFiles('tests/conftest.py') }}" + key: "${{ runner.os }}-data-${{ hashFiles('src/ngio/utils/_datasets.py') }}" restore-keys: | - "${{ runner.os }}-data-" + ${{ runner.os }}-data- - name: ๐Ÿงช Run Tests - run: pytest --no-cov + run: pytest -n 4 --durations=10 - name: ๐Ÿ“ Report Failures if: failure() diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fd29f4e..5f2f2ecf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,39 @@ ## [Unreleased] +### Fix +- Fix the deprecated `yx_scaling_factor` shim assembling scaling factors in `(z, x, y)` order instead of the canonical `(z, y, x)`: an asymmetric tuple like `yx_scaling_factor=(2, 4)` silently produced swapped per-level y/x scales. The shim also now trims leading factors for shapes with fewer than 3 axes (a 2D image previously raised a length-mismatch error). +- Fix `concatenate_image_tables` (and variants) index construction: the concatenated table's index name was never set due to an inverted `None` check, and `mode="lazy"` built the index from the extras columns only โ€” producing duplicated index values โ€” while `mode="eager"` also hashed the original table index. Both modes now produce the same unique per-row index. The async variant also forwards `mode` to the per-image prefetch, so lazy prefetching no longer eagerly loads dataframes. +- Fix `Roi.union`/`Roi.intersection` dropping ROI name `""` and label `0` (the joins used truthiness instead of `None` checks; label `0` is a legal label). The joined ROI's slices now also keep a deterministic axis order (the receiver's axes first, then axes only present in the other ROI) instead of an arbitrary set order. +- `Roi.from_values` now validates its inputs (it used `model_construct`, which skips pydantic validation entirely), so invalid values such as a negative `label` or fewer than 2 axes raise instead of being silently accepted. +- Fix `PixelSize.__eq__` raising `TypeError` when compared with a non-`PixelSize` object (it now returns `NotImplemented`, so `ps == "x"` is `False` and `ps in [...]` works) and make the `time_unit` comparison symmetric: previously `a == b` and `b == a` could disagree when only one side had a `time_unit`. **Behavior change**: pixel sizes with different `time_unit`s now always compare unequal. +- Fix `NgioWellMeta.add_image`/`remove_image` and `NgioPlateMeta.add_well`/`add_acquisition`/`remove_well` mutating the receiver in place: the "updated copy" they returned shared its lists with the original object, so the original was silently modified too. All five now leave the receiver untouched. +- Fix `AxesSetup.from_ordered_list` silently dropping a non-canonical axis name when a canonical name appeared to its left (e.g. `["z", "custom", "y", "x"]`): the canonical name reclaimed the slot already assigned to the custom axis. Canonical names now always claim their own slot and non-canonical names fill the remaining slots right-aligned. +- Fix `ngio.experimental.iterators` grid helpers: `grid()` gave every generated ROI the same name (they now get unique per-tile names, e.g. `t0_z0_y32_x64`, prefixed with `base_name` when given), and `by_chunks` with an overlap equal to or larger than the chunk size now raises a clear `NgioValueError` instead of crashing with `range() arg 3 must not be zero`. +- The global config singleton is now loaded lazily on first `get_config()` call instead of at import time, so setting `NGIO_CONFIG_PATH` after importing ngio (but before first use) is honored. +- Fix the v0.5 metadata decoder passing a non-string axis `unit` through unchanged: the stringification computed for non-string JSON values was discarded (`unit=v05_axis.unit` instead of the normalized value). +- Fix accessing labels on a read-only image without a `labels` group: `labels_container` (and everything routed through it) raised `NgioValueError: Cannot create a group in read only mode` instead of degrading gracefully like the tables path (`list_labels()` โ†’ `[]`, `labels_container` โ†’ `NgioValidationError`). +- Fix an empty ROI table with no backend being unusable: `RoiTable().rois()` / `.add(roi)` raised `NgioValueError` instead of treating the table as empty (the `set_table_data` path already handled this case). +- Fix duplicate ROI names not surviving a write/read roundtrip: the dedup in `RoiDictWrapper` renamed only the internal dict key, so the serialized table still contained duplicate index values. The renamed ROI now carries the deduplicated name. +- Fix `OmeZarrPlate.get_well` never returning the instance it cached (it stored one `OmeZarrWell` and returned a second, freshly built one; repeated calls now return the cached instance, matching `get_image`). +- A negative index inside a slicing sequence (e.g. `get_array(y=[-1, 0])`) now raises `NgioValueError` instead of a bare `AssertionError` (which would also vanish under `python -O`). + +### Tests +- Enable `test_derive_from_legacy_images` (`tests/unit/images/test_create.py`), which was never collected due to a missing `test_` prefix; it now runs for both NGFF versions with the level pairing and channel-axis handling corrected. Rename the misnamed `test_fail_derive_singleton` to `test_pyramid_clamps_singleton_dimensions`. +- Deduplicate the 18-item test-image parametrization (six verbatim copies) into a shared `zarr_name` fixture in `tests/conftest.py`. +- Register a `network` pytest marker and apply it to the two tests hitting raw.githubusercontent.com, so `-m "not network"` runs offline. The Zenodo dataset downloads moved from conftest import time into session fixtures โ€” test collection no longer blocks on the network. +- Remove `--cov` from the default pytest `addopts` (local runs no longer pay ~40% coverage overhead); CI passes `--cov=ngio --cov-report=xml` explicitly on the codecov leg and `ci_upstream.yml` drops the now-unneeded `--no-cov`. +- Merge the four per-backend round-trip tests in `test_backends.py` into one parametrized test; drop the redundant middle-layer copy of the #207 backend-preservation test (still covered at the `write_table` and container layers). +- Anchor test-data paths to `__file__` instead of the current working directory (`tests/conftest.py`, `tests/create_test_data.py`, `test_unit_v04_utils.py`), so pytest can run from any directory. Reduce `test_multiprocessing_safety` from 1000 to 100 tasks and delete an unreferenced meta JSON fixture. +- Raise overall coverage from 91% to 95%: new unit tests for the v0.5 metadata codec (`test_unit_v05_utils.py`, mirroring the v0.4 file), container/image error paths and deprecated shims, ROI-table and plate edge cases, `NgioCache`, the `FeatureExtractorIterator` dask path, and io_pipes error branches. `hcs/_plate.py`, `images/_image.py`, `utils/_cache.py`, `experimental/iterators/_feature.py`, `io_pipes/_match_shape.py`, `_zoom_transform.py`, and `_ops_slices.py` are now at 100%. +- Speed up fixtures: the `moto_s3_server` fixture is now session-scoped (tests isolate via `random_zarr_path()`, and starting the server cost ~4s per S3 test); read-only consumers of `images_all_versions` and the Zenodo cardiomyocyte datasets now share session-scoped copies (`images_all_versions_readonly`, `cardiomyocyte_tiny_path_readonly`, `cardiomyocyte_small_mip_path_readonly`) instead of re-copying up to 126 MB per test, while mutating tests keep fresh per-test copies; `test_real_mask` copies only the single image it writes to; `test_table_ops.py` builds its two sample containers once per module. + +### Chores +- Speed up CI: run the test suite with `pytest-xdist` (`-n 4`, new `test` extra dependency); collect coverage and upload to codecov on the ubuntu/`test11` leg only (one report per run, unchanged content); add `--durations=10` to CI runs for runtime observability. The Zenodo download in `tests/conftest.py` is now guarded by a `FileLock` (xdist workers on a cold cache) and no longer re-extracts an already-unzipped dataset (`re_unzip=False`). +- Shrink the store-matrix test payload (`tests/stores/utils.py`) from a `(3, 5, 64, 64)`/3-level image to `(3, 2, 32, 32)`/2 levels โ€” same monitored behaviors (multi-channel z-stack pyramid, label, all four table backends, derive across the 4ร—4 store matrix), far fewer object round-trips through the mocked S3/HTTP stores (~75s โ†’ considerably less on CI). +- Slim the iterator tests: the two mutating tests (`test_segmentation_iterator`, `test_masked_segmentation_iterator`) now copy only the single image they write to (new `single_image_copy` fixture, replacing per-test copies of all 18 test images; the unused `images_all_versions` fixture is removed) and run the full 9-axes matrix on v0.5 plus a v0.4 smoke subset (`yx`, `tczyx`) instead of both versions ร— 9 โ€” the iterators are version-agnostic and the v0.4 write path stays monitored by the creation/store tests. The two read-only iterator tests keep the full 18-image matrix. +- Fix the CI data-cache `restore-keys` fallback never matching: the fallback key was written inside a YAML block scalar with double quotes (`"Linux-data-"`), which are literal there, so a cache-key rotation caused every PR job to re-download the ~160 MB Zenodo datasets during the test step (~80s/job). The cache key now also hashes `src/ngio/utils/_datasets.py` (the dataset registry) instead of `tests/conftest.py`, so test-suite refactors no longer rotate the key (`ci.yml`, `ci_upstream.yml`). + ### Feature - Add a configurable IO retry policy: `NgioConfig.io_retry` (`RetryConfig`) with `max_retries` (default `0`, never retry), a backoff strategy (`ConstantBackoff`, `LinearBackoff`, or `ExponentialBackoff`, each with `delay_s`, `max_delay_s`, `jitter`), and error matching via `retry_on` substrings (matched against `"ExceptionName: message"`) or the discouraged blanket `retry_all_errors` flag, which emits an `NgioUserWarning` and is mutually exclusive with `retry_on`. Ngio's own `NgioError`s are never retried. Retry helpers live in `ngio.utils._retry`; the public `ngio.utils.retry_io` decorator reads the global config at call time. - Add `ngio.utils.NgioStore`, a picklable zarr `WrapperStore` that applies the `io_retry` policy to every store IO call (the policy snapshot travels with the store into dask task graphs) and centralizes store-type dispatch behind uniform services (`store_type`, `full_url`, `sync_fs_and_path`, `get_mapper`, `local_root`, `memory_dict`, `list_dir_collected`, `NgioStore.from_any`/`ensure`). Every group ngio opens (`open_group_wrapper` / `ZarrGroupHandler`) is now backed by an `NgioStore`, so the `io_retry` policy applies to all zarr IO โ€” metadata, pixel data, and lazy dask reads/writes executed on workers (the policy snapshot pickles with the store into the task graph). User-provided `zarr.Group`s are transparently reopened on a wrapped store; `ZipStore` is now an explicitly supported store type (it previously warned). diff --git a/pixi.lock b/pixi.lock index 83865dac..a91041a7 100644 --- a/pixi.lock +++ b/pixi.lock @@ -1,8 +1,20 @@ version: 7 platforms: - name: linux-64 + virtual-packages: + - __unix=0=0 + - __linux=4.18 + - __glibc=2.28 + - __archspec=0=x86_64 - name: osx-arm64 + virtual-packages: + - __unix=0=0 + - __osx=13.0 + - __archspec=0=m1 - name: win-64 + virtual-packages: + - __win=10.0 + - __archspec=0=x86_64 environments: default: channels: @@ -32,7 +44,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.6.17-hbd8a1cb_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-8_cp314.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda - - pypi: . + - pypi: ./ - pypi: https://files.pythonhosted.org/packages/02/17/b82b537a30be67ba178fc0b0fbda8fcbaeda188fb039d6ad8a84e89353a2/dask-2026.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0c/d5/c5db1ea3394c6e1732fb3286b3bd878b59507a8f77d32a2cebda7d7b7cd4/donfig-0.8.1.post1-py3-none-any.whl @@ -113,7 +125,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.3-h46df422_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-h010d191_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hbf9d68e_6.conda - - pypi: . + - pypi: ./ - pypi: https://files.pythonhosted.org/packages/02/17/b82b537a30be67ba178fc0b0fbda8fcbaeda188fb039d6ad8a84e89353a2/dask-2026.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/dc/c7043cab6fed8ae159fc1923ce829ada35c4dbd797d408a43858ffaf9639/tornado-6.5.7-cp39-abi3-macosx_10_9_universal2.whl @@ -196,7 +208,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/win-64/vc14_runtime-14.51.36231-h1b9f54f_39.conda - conda: https://conda.anaconda.org/conda-forge/win-64/vcomp14-14.51.36231-h1b9f54f_39.conda - conda: https://conda.anaconda.org/conda-forge/win-64/zstd-1.5.7-h534d264_6.conda - - pypi: . + - pypi: ./ - pypi: https://files.pythonhosted.org/packages/02/17/b82b537a30be67ba178fc0b0fbda8fcbaeda188fb039d6ad8a84e89353a2/dask-2026.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/08/a5/7b059810934a09fb3ccb657e0843813c1fee1183d3bc2c8041800374aa2c/google_crc32c-1.8.0-cp314-cp314-win_amd64.whl @@ -290,7 +302,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.6.17-hbd8a1cb_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda - - pypi: . + - pypi: ./ - pypi: https://files.pythonhosted.org/packages/00/15/b92b4e1d88d02c6eff9733c9eea21846ab435cc4d813d84ccc5d335955df/graphql_core-3.2.11-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/01/72/2067f28fd0ae87978f3b61e8ec30c1d085bbed03f64eb58e43949d526b3a/napari_console-0.1.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/01/b9/748c3cbcdba20ef01c055a35905cad5c771cc1df29be11b9a82da6fd8e0d/superqt-0.8.2-py3-none-any.whl @@ -469,6 +481,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/a7/4b/25a9e8a09083d03ee7ee8701428e514c08029c000f34707cc7665980f4ae/cfn_lint-1.52.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a9/82/0340caa499416c78e5d8f5f05947ae4bc3cba53c9f038ab6e9ed964e22f1/nbformat-5.10.4-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b1/c4/2a6fe5111a01005fc7af3878259ce17684fabb8852815eda6225620f3c59/pyzmq-27.1.0-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b1/dd/ead9d8ea85bf202d90cc513b533f9c363121c7792674f78e0d8a854b63b4/jupyterlab_pygments-0.3.0-py3-none-any.whl @@ -493,6 +506,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/c5/fa/471e2511b0c77419ac2228ce72770e94e994ab99c6b9275cb3de1dcead2d/zarrs-0.2.3-cp311-abi3-manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c9/33/a7cbfccc39056a5cf8126b7aab4c8bafbedd4f0ca68ae40ecb627a2d2cd3/py_partiql_parser-0.6.3-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ca/d0/7920b8ba919b90173a80f4428d66e05f4d219a3ae3805cebe86924f9cfa1/vispy-0.16.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/cb/a8/20d0723294217e47de6d9e2e40fd4a9d2f7c4b6ef974babd482a59743694/fastjsonschema-2.21.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl @@ -562,7 +576,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.11.15-h0c9c016_1_cpython.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.3-h46df422_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-h010d191_3.conda - - pypi: . + - pypi: ./ - pypi: https://files.pythonhosted.org/packages/00/15/b92b4e1d88d02c6eff9733c9eea21846ab435cc4d813d84ccc5d335955df/graphql_core-3.2.11-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/01/72/2067f28fd0ae87978f3b61e8ec30c1d085bbed03f64eb58e43949d526b3a/napari_console-0.1.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/01/b3/4684b1e128a87821e485f5a901b179790e6b5bc02f89b7ee19c23be36ef3/lazy_object_proxy-1.12.0-cp311-cp311-macosx_11_0_arm64.whl @@ -749,6 +763,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/a7/4b/25a9e8a09083d03ee7ee8701428e514c08029c000f34707cc7665980f4ae/cfn_lint-1.52.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a9/82/0340caa499416c78e5d8f5f05947ae4bc3cba53c9f038ab6e9ed964e22f1/nbformat-5.10.4-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b1/dd/ead9d8ea85bf202d90cc513b533f9c363121c7792674f78e0d8a854b63b4/jupyterlab_pygments-0.3.0-py3-none-any.whl @@ -770,6 +785,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl - pypi: https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c9/33/a7cbfccc39056a5cf8126b7aab4c8bafbedd4f0ca68ae40ecb627a2d2cd3/py_partiql_parser-0.6.3-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cb/a8/20d0723294217e47de6d9e2e40fd4a9d2f7c4b6ef974babd482a59743694/fastjsonschema-2.21.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ce/4f/5249960887b1fbe561d9ff265496d170b55a735b76724f10ef19f9e40716/prompt_toolkit-3.0.51-py3-none-any.whl @@ -837,7 +853,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/win-64/vc-14.5-h1b7c187_39.conda - conda: https://conda.anaconda.org/conda-forge/win-64/vc14_runtime-14.51.36231-h1b9f54f_39.conda - conda: https://conda.anaconda.org/conda-forge/win-64/vcomp14-14.51.36231-h1b9f54f_39.conda - - pypi: . + - pypi: ./ - pypi: https://files.pythonhosted.org/packages/00/15/b92b4e1d88d02c6eff9733c9eea21846ab435cc4d813d84ccc5d335955df/graphql_core-3.2.11-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/01/72/2067f28fd0ae87978f3b61e8ec30c1d085bbed03f64eb58e43949d526b3a/napari_console-0.1.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/01/b9/748c3cbcdba20ef01c055a35905cad5c771cc1df29be11b9a82da6fd8e0d/superqt-0.8.2-py3-none-any.whl @@ -1013,6 +1029,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a9/82/0340caa499416c78e5d8f5f05947ae4bc3cba53c9f038ab6e9ed964e22f1/nbformat-5.10.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl + - pypi: https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/b0/5c/31feb3dd82d1b33ae0bd09ca601edb993d9da1b7f0226b3336d4b4c39e1e/pywinpty-3.0.5-cp311-cp311-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl @@ -1038,6 +1055,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/c9/33/a7cbfccc39056a5cf8126b7aab4c8bafbedd4f0ca68ae40ecb627a2d2cd3/py_partiql_parser-0.6.3-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c9/68/f16a3a8ba6f7b6dc92a1f19669c0810bd2c43fc5a02da13b1cbf8e253845/multidict-6.7.1-cp311-cp311-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/c9/a4/b393076ffb21b469eec5b328a0534cf03a3b90bfc6b1f09507cdd075d938/tornado-6.5.7-cp39-abi3-win_amd64.whl + - pypi: https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cb/a8/20d0723294217e47de6d9e2e40fd4a9d2f7c4b6ef974babd482a59743694/fastjsonschema-2.21.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ce/4f/5249960887b1fbe561d9ff265496d170b55a735b76724f10ef19f9e40716/prompt_toolkit-3.0.51-py3-none-any.whl @@ -1125,7 +1143,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.6.17-hbd8a1cb_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda - - pypi: . + - pypi: ./ - pypi: https://files.pythonhosted.org/packages/02/03/74fe2a4cb3817d94d86402f2506554130a2f01414e299b5a843e5a8a957f/numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/02/17/b82b537a30be67ba178fc0b0fbda8fcbaeda188fb039d6ad8a84e89353a2/dask-2026.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl @@ -1296,7 +1314,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.11.15-h0c9c016_1_cpython.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.3-h46df422_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-h010d191_3.conda - - pypi: . + - pypi: ./ - pypi: https://files.pythonhosted.org/packages/02/17/b82b537a30be67ba178fc0b0fbda8fcbaeda188fb039d6ad8a84e89353a2/dask-2026.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/dc/c7043cab6fed8ae159fc1923ce829ada35c4dbd797d408a43858ffaf9639/tornado-6.5.7-cp39-abi3-macosx_10_9_universal2.whl @@ -1470,7 +1488,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/win-64/vc-14.5-h1b7c187_39.conda - conda: https://conda.anaconda.org/conda-forge/win-64/vc14_runtime-14.51.36231-h1b9f54f_39.conda - conda: https://conda.anaconda.org/conda-forge/win-64/vcomp14-14.51.36231-h1b9f54f_39.conda - - pypi: . + - pypi: ./ - pypi: https://files.pythonhosted.org/packages/02/17/b82b537a30be67ba178fc0b0fbda8fcbaeda188fb039d6ad8a84e89353a2/dask-2026.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl @@ -1654,7 +1672,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.6.17-hbd8a1cb_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda - - pypi: . + - pypi: ./ - pypi: https://files.pythonhosted.org/packages/00/15/b92b4e1d88d02c6eff9733c9eea21846ab435cc4d813d84ccc5d335955df/graphql_core-3.2.11-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/03/74fe2a4cb3817d94d86402f2506554130a2f01414e299b5a843e5a8a957f/numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/02/17/b82b537a30be67ba178fc0b0fbda8fcbaeda188fb039d6ad8a84e89353a2/dask-2026.6.0-py3-none-any.whl @@ -1747,6 +1765,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/a5/0b/f68a968b49876eae0f2a515387093cebb2eb9451380a96741cc20efac0d0/s3fs-2026.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a5/2a/e71c1a7d90e70da67b88ccc609bd6ae54798d5847369b15d3a8052232f9d/scikit_image-0.26.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/a7/4b/25a9e8a09083d03ee7ee8701428e514c08029c000f34707cc7665980f4ae/cfn_lint-1.52.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b3/a0/3e6a0b1c1ea6bec76f71473727ef27abf3cd40e9709b3ebcbfbcfaae6f79/boto3-1.43.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b4/62/674ba5ada6de5615b458d768c0f0fb1a7b53299486fb38a36e0694f3c617/distributed-2026.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl @@ -1758,6 +1777,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/bf/c9/63f8d545568d9ab91476b1818b4741f521646cbdd151c6efebf40d6de6f7/pandas-2.3.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c9/33/a7cbfccc39056a5cf8126b7aab4c8bafbedd4f0ca68ae40ecb627a2d2cd3/py_partiql_parser-0.6.3-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cd/fd/93bfe5af45f0be4fa8983945455c0e6924e1aeb879cde227958869c1e71c/regex-2026.6.28-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl @@ -1797,7 +1817,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.11.15-h0c9c016_1_cpython.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.3-h46df422_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-h010d191_3.conda - - pypi: . + - pypi: ./ - pypi: https://files.pythonhosted.org/packages/00/15/b92b4e1d88d02c6eff9733c9eea21846ab435cc4d813d84ccc5d335955df/graphql_core-3.2.11-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/01/b3/4684b1e128a87821e485f5a901b179790e6b5bc02f89b7ee19c23be36ef3/lazy_object_proxy-1.12.0-cp311-cp311-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/02/17/b82b537a30be67ba178fc0b0fbda8fcbaeda188fb039d6ad8a84e89353a2/dask-2026.6.0-py3-none-any.whl @@ -1892,6 +1912,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/a2/e8/6d75ffd9784bce2e93d1ae4415649427e39a53bb172d4672b2b59c6f0a7b/pathable-0.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a5/0b/f68a968b49876eae0f2a515387093cebb2eb9451380a96741cc20efac0d0/s3fs-2026.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a7/4b/25a9e8a09083d03ee7ee8701428e514c08029c000f34707cc7665980f4ae/cfn_lint-1.52.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/b3/a0/3e6a0b1c1ea6bec76f71473727ef27abf3cd40e9709b3ebcbfbcfaae6f79/boto3-1.43.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b4/62/674ba5ada6de5615b458d768c0f0fb1a7b53299486fb38a36e0694f3c617/distributed-2026.6.0-py3-none-any.whl @@ -1903,6 +1924,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl - pypi: https://files.pythonhosted.org/packages/c9/33/a7cbfccc39056a5cf8126b7aab4c8bafbedd4f0ca68ae40ecb627a2d2cd3/py_partiql_parser-0.6.3-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d1/ae/afb1487556e2dc827a17097aac8158a25b433a345386f0e249f6d2694ccb/devtools-0.12.2-py3-none-any.whl @@ -1942,7 +1964,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/win-64/vc-14.5-h1b7c187_39.conda - conda: https://conda.anaconda.org/conda-forge/win-64/vc14_runtime-14.51.36231-h1b9f54f_39.conda - conda: https://conda.anaconda.org/conda-forge/win-64/vcomp14-14.51.36231-h1b9f54f_39.conda - - pypi: . + - pypi: ./ - pypi: https://files.pythonhosted.org/packages/00/15/b92b4e1d88d02c6eff9733c9eea21846ab435cc4d813d84ccc5d335955df/graphql_core-3.2.11-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/17/b82b537a30be67ba178fc0b0fbda8fcbaeda188fb039d6ad8a84e89353a2/dask-2026.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl @@ -2032,6 +2054,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/a5/0b/f68a968b49876eae0f2a515387093cebb2eb9451380a96741cc20efac0d0/s3fs-2026.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a7/4b/25a9e8a09083d03ee7ee8701428e514c08029c000f34707cc7665980f4ae/cfn_lint-1.52.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl + - pypi: https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/b3/a0/3e6a0b1c1ea6bec76f71473727ef27abf3cd40e9709b3ebcbfbcfaae6f79/boto3-1.43.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b4/62/674ba5ada6de5615b458d768c0f0fb1a7b53299486fb38a36e0694f3c617/distributed-2026.6.0-py3-none-any.whl @@ -2046,6 +2069,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/c9/33/a7cbfccc39056a5cf8126b7aab4c8bafbedd4f0ca68ae40ecb627a2d2cd3/py_partiql_parser-0.6.3-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c9/68/f16a3a8ba6f7b6dc92a1f19669c0810bd2c43fc5a02da13b1cbf8e253845/multidict-6.7.1-cp311-cp311-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/c9/a4/b393076ffb21b469eec5b328a0534cf03a3b90bfc6b1f09507cdd075d938/tornado-6.5.7-cp39-abi3-win_amd64.whl + - pypi: https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d1/0e/51db89361668fe077a835fc579277f824ba526e7daf7b94d23d25439e0d0/polars_runtime_32-1.42.1-cp310-abi3-win_amd64.whl @@ -2102,7 +2126,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.6.17-hbd8a1cb_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda - - pypi: . + - pypi: ./ - pypi: https://files.pythonhosted.org/packages/00/15/b92b4e1d88d02c6eff9733c9eea21846ab435cc4d813d84ccc5d335955df/graphql_core-3.2.11-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/03/74fe2a4cb3817d94d86402f2506554130a2f01414e299b5a843e5a8a957f/numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/02/17/b82b537a30be67ba178fc0b0fbda8fcbaeda188fb039d6ad8a84e89353a2/dask-2026.6.0-py3-none-any.whl @@ -2195,6 +2219,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/a5/0b/f68a968b49876eae0f2a515387093cebb2eb9451380a96741cc20efac0d0/s3fs-2026.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a5/2a/e71c1a7d90e70da67b88ccc609bd6ae54798d5847369b15d3a8052232f9d/scikit_image-0.26.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/a7/4b/25a9e8a09083d03ee7ee8701428e514c08029c000f34707cc7665980f4ae/cfn_lint-1.52.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b3/a0/3e6a0b1c1ea6bec76f71473727ef27abf3cd40e9709b3ebcbfbcfaae6f79/boto3-1.43.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b4/62/674ba5ada6de5615b458d768c0f0fb1a7b53299486fb38a36e0694f3c617/distributed-2026.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl @@ -2206,6 +2231,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/bf/c9/63f8d545568d9ab91476b1818b4741f521646cbdd151c6efebf40d6de6f7/pandas-2.3.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c9/33/a7cbfccc39056a5cf8126b7aab4c8bafbedd4f0ca68ae40ecb627a2d2cd3/py_partiql_parser-0.6.3-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cd/fd/93bfe5af45f0be4fa8983945455c0e6924e1aeb879cde227958869c1e71c/regex-2026.6.28-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl @@ -2245,7 +2271,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.11.15-h0c9c016_1_cpython.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.3-h46df422_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-h010d191_3.conda - - pypi: . + - pypi: ./ - pypi: https://files.pythonhosted.org/packages/00/15/b92b4e1d88d02c6eff9733c9eea21846ab435cc4d813d84ccc5d335955df/graphql_core-3.2.11-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/01/b3/4684b1e128a87821e485f5a901b179790e6b5bc02f89b7ee19c23be36ef3/lazy_object_proxy-1.12.0-cp311-cp311-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/02/17/b82b537a30be67ba178fc0b0fbda8fcbaeda188fb039d6ad8a84e89353a2/dask-2026.6.0-py3-none-any.whl @@ -2340,6 +2366,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/a2/e8/6d75ffd9784bce2e93d1ae4415649427e39a53bb172d4672b2b59c6f0a7b/pathable-0.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a5/0b/f68a968b49876eae0f2a515387093cebb2eb9451380a96741cc20efac0d0/s3fs-2026.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a7/4b/25a9e8a09083d03ee7ee8701428e514c08029c000f34707cc7665980f4ae/cfn_lint-1.52.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/b3/a0/3e6a0b1c1ea6bec76f71473727ef27abf3cd40e9709b3ebcbfbcfaae6f79/boto3-1.43.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b4/62/674ba5ada6de5615b458d768c0f0fb1a7b53299486fb38a36e0694f3c617/distributed-2026.6.0-py3-none-any.whl @@ -2351,6 +2378,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl - pypi: https://files.pythonhosted.org/packages/c9/33/a7cbfccc39056a5cf8126b7aab4c8bafbedd4f0ca68ae40ecb627a2d2cd3/py_partiql_parser-0.6.3-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d1/ae/afb1487556e2dc827a17097aac8158a25b433a345386f0e249f6d2694ccb/devtools-0.12.2-py3-none-any.whl @@ -2390,7 +2418,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/win-64/vc-14.5-h1b7c187_39.conda - conda: https://conda.anaconda.org/conda-forge/win-64/vc14_runtime-14.51.36231-h1b9f54f_39.conda - conda: https://conda.anaconda.org/conda-forge/win-64/vcomp14-14.51.36231-h1b9f54f_39.conda - - pypi: . + - pypi: ./ - pypi: https://files.pythonhosted.org/packages/00/15/b92b4e1d88d02c6eff9733c9eea21846ab435cc4d813d84ccc5d335955df/graphql_core-3.2.11-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/17/b82b537a30be67ba178fc0b0fbda8fcbaeda188fb039d6ad8a84e89353a2/dask-2026.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl @@ -2480,6 +2508,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/a5/0b/f68a968b49876eae0f2a515387093cebb2eb9451380a96741cc20efac0d0/s3fs-2026.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a7/4b/25a9e8a09083d03ee7ee8701428e514c08029c000f34707cc7665980f4ae/cfn_lint-1.52.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl + - pypi: https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/b3/a0/3e6a0b1c1ea6bec76f71473727ef27abf3cd40e9709b3ebcbfbcfaae6f79/boto3-1.43.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b4/62/674ba5ada6de5615b458d768c0f0fb1a7b53299486fb38a36e0694f3c617/distributed-2026.6.0-py3-none-any.whl @@ -2494,6 +2523,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/c9/33/a7cbfccc39056a5cf8126b7aab4c8bafbedd4f0ca68ae40ecb627a2d2cd3/py_partiql_parser-0.6.3-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c9/68/f16a3a8ba6f7b6dc92a1f19669c0810bd2c43fc5a02da13b1cbf8e253845/multidict-6.7.1-cp311-cp311-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/c9/a4/b393076ffb21b469eec5b328a0534cf03a3b90bfc6b1f09507cdd075d938/tornado-6.5.7-cp39-abi3-win_amd64.whl + - pypi: https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d1/0e/51db89361668fe077a835fc579277f824ba526e7daf7b94d23d25439e0d0/polars_runtime_32-1.42.1-cp310-abi3-win_amd64.whl @@ -2550,7 +2580,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.6.17-hbd8a1cb_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda - - pypi: . + - pypi: ./ - pypi: https://files.pythonhosted.org/packages/00/15/b92b4e1d88d02c6eff9733c9eea21846ab435cc4d813d84ccc5d335955df/graphql_core-3.2.11-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/17/b82b537a30be67ba178fc0b0fbda8fcbaeda188fb039d6ad8a84e89353a2/dask-2026.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl @@ -2640,6 +2670,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/a2/e8/6d75ffd9784bce2e93d1ae4415649427e39a53bb172d4672b2b59c6f0a7b/pathable-0.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a5/0b/f68a968b49876eae0f2a515387093cebb2eb9451380a96741cc20efac0d0/s3fs-2026.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a7/4b/25a9e8a09083d03ee7ee8701428e514c08029c000f34707cc7665980f4ae/cfn_lint-1.52.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b3/7a/829f7d9dfd37c207206081d6dad474d81dde29952401f07f2ba507814818/pyarrow-24.0.0-cp312-cp312-manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b3/a0/3e6a0b1c1ea6bec76f71473727ef27abf3cd40e9709b3ebcbfbcfaae6f79/boto3-1.43.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b4/62/674ba5ada6de5615b458d768c0f0fb1a7b53299486fb38a36e0694f3c617/distributed-2026.6.0-py3-none-any.whl @@ -2651,6 +2682,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c7/05/04af0ca9c5747c95591a549509ebbb0a986155015de59f1e4c984e3af9ab/pydantic_zarr-0.10.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c9/33/a7cbfccc39056a5cf8126b7aab4c8bafbedd4f0ca68ae40ecb627a2d2cd3/py_partiql_parser-0.6.3-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cd/d7/324b9bdd6fa89fefb4765494dd20d76c06d0f4f0aa8e46233e122c9f9f21/scverse_misc-0.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ce/a9/a780cc66f86335a6019f557a8aaca8fbb970728f0efd2430d15ff1beae0e/google_crc32c-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl @@ -2691,7 +2723,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.12.13-h8561d8f_0_cpython.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.3-h46df422_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-h010d191_3.conda - - pypi: . + - pypi: ./ - pypi: https://files.pythonhosted.org/packages/00/15/b92b4e1d88d02c6eff9733c9eea21846ab435cc4d813d84ccc5d335955df/graphql_core-3.2.11-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/17/b82b537a30be67ba178fc0b0fbda8fcbaeda188fb039d6ad8a84e89353a2/dask-2026.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl @@ -2782,6 +2814,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/a5/0b/f68a968b49876eae0f2a515387093cebb2eb9451380a96741cc20efac0d0/s3fs-2026.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a7/4b/25a9e8a09083d03ee7ee8701428e514c08029c000f34707cc7665980f4ae/cfn_lint-1.52.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b0/42/c84efcc1d4caebafb1ecd8be4643f39c85c47a80fe254d92b8b43b1eadaf/h5py-3.16.0-cp312-cp312-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/b3/a0/3e6a0b1c1ea6bec76f71473727ef27abf3cd40e9709b3ebcbfbcfaae6f79/boto3-1.43.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b4/62/674ba5ada6de5615b458d768c0f0fb1a7b53299486fb38a36e0694f3c617/distributed-2026.6.0-py3-none-any.whl @@ -2792,6 +2825,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c7/05/04af0ca9c5747c95591a549509ebbb0a986155015de59f1e4c984e3af9ab/pydantic_zarr-0.10.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c9/33/a7cbfccc39056a5cf8126b7aab4c8bafbedd4f0ca68ae40ecb627a2d2cd3/py_partiql_parser-0.6.3-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cb/0e/ca20a0e0de49837e6337603a91ab77556aa27033ac5b975615d98698cfb3/regex-2026.6.28-cp312-cp312-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cd/d7/324b9bdd6fa89fefb4765494dd20d76c06d0f4f0aa8e46233e122c9f9f21/scverse_misc-0.1.1-py3-none-any.whl @@ -2834,7 +2868,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/win-64/vc-14.5-h1b7c187_39.conda - conda: https://conda.anaconda.org/conda-forge/win-64/vc14_runtime-14.51.36231-h1b9f54f_39.conda - conda: https://conda.anaconda.org/conda-forge/win-64/vcomp14-14.51.36231-h1b9f54f_39.conda - - pypi: . + - pypi: ./ - pypi: https://files.pythonhosted.org/packages/00/15/b92b4e1d88d02c6eff9733c9eea21846ab435cc4d813d84ccc5d335955df/graphql_core-3.2.11-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/17/b82b537a30be67ba178fc0b0fbda8fcbaeda188fb039d6ad8a84e89353a2/dask-2026.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl @@ -2925,6 +2959,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/a7/25/722e3b93bd687009afb2d59a35e13d30ddd8f80571445bb0c4e4ce26ec66/yarl-1.24.2-cp312-cp312-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/a7/4b/25a9e8a09083d03ee7ee8701428e514c08029c000f34707cc7665980f4ae/cfn_lint-1.52.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl + - pypi: https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b3/a0/3e6a0b1c1ea6bec76f71473727ef27abf3cd40e9709b3ebcbfbcfaae6f79/boto3-1.43.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b4/62/674ba5ada6de5615b458d768c0f0fb1a7b53299486fb38a36e0694f3c617/distributed-2026.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl @@ -2936,6 +2971,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/c7/05/04af0ca9c5747c95591a549509ebbb0a986155015de59f1e4c984e3af9ab/pydantic_zarr-0.10.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c9/33/a7cbfccc39056a5cf8126b7aab4c8bafbedd4f0ca68ae40ecb627a2d2cd3/py_partiql_parser-0.6.3-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c9/a4/b393076ffb21b469eec5b328a0534cf03a3b90bfc6b1f09507cdd075d938/tornado-6.5.7-cp39-abi3-win_amd64.whl + - pypi: https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cd/d7/324b9bdd6fa89fefb4765494dd20d76c06d0f4f0aa8e46233e122c9f9f21/scverse_misc-0.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl @@ -2991,7 +3027,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.6.17-hbd8a1cb_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.13-8_cp313.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda - - pypi: . + - pypi: ./ - pypi: https://files.pythonhosted.org/packages/00/15/b92b4e1d88d02c6eff9733c9eea21846ab435cc4d813d84ccc5d335955df/graphql_core-3.2.11-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/17/b82b537a30be67ba178fc0b0fbda8fcbaeda188fb039d6ad8a84e89353a2/dask-2026.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl @@ -3081,6 +3117,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/a5/0b/f68a968b49876eae0f2a515387093cebb2eb9451380a96741cc20efac0d0/s3fs-2026.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a7/4b/25a9e8a09083d03ee7ee8701428e514c08029c000f34707cc7665980f4ae/cfn_lint-1.52.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b3/a0/3e6a0b1c1ea6bec76f71473727ef27abf3cd40e9709b3ebcbfbcfaae6f79/boto3-1.43.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b4/62/674ba5ada6de5615b458d768c0f0fb1a7b53299486fb38a36e0694f3c617/distributed-2026.6.0-py3-none-any.whl @@ -3093,6 +3130,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/c4/e6/d359fdd37498e74d26a167f7a51e54542e642ea47181eb4e643a69a066c3/numcodecs-0.16.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/c7/05/04af0ca9c5747c95591a549509ebbb0a986155015de59f1e4c984e3af9ab/pydantic_zarr-0.10.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c9/33/a7cbfccc39056a5cf8126b7aab4c8bafbedd4f0ca68ae40ecb627a2d2cd3/py_partiql_parser-0.6.3-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cd/d7/324b9bdd6fa89fefb4765494dd20d76c06d0f4f0aa8e46233e122c9f9f21/scverse_misc-0.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ce/42/b468aec74a0354b34c8cbf748db20d6e350a68a2b0912e128cabee49806c/google_crc32c-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl @@ -3134,7 +3172,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.13.14-h448ec07_100_cp313.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.3-h46df422_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-h010d191_3.conda - - pypi: . + - pypi: ./ - pypi: https://files.pythonhosted.org/packages/00/15/b92b4e1d88d02c6eff9733c9eea21846ab435cc4d813d84ccc5d335955df/graphql_core-3.2.11-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/17/b82b537a30be67ba178fc0b0fbda8fcbaeda188fb039d6ad8a84e89353a2/dask-2026.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/73/0291a64843270f4efb86cdcf2ee0f2048631b65ec6b405398b2b4dbf11bf/scipy-1.18.0-cp313-cp313-macosx_12_0_arm64.whl @@ -3223,6 +3261,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/a7/4b/25a9e8a09083d03ee7ee8701428e514c08029c000f34707cc7665980f4ae/cfn_lint-1.52.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a9/12/a6ba6482bb5ea3260c000c9b20881c95fa11c6b30173715668259f844ed7/propcache-0.5.2-cp313-cp313-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/ab/01/a2d5f96cd4e74424864d30bc0a7e44d0a12dacdcfa91b5b2d1bd3dca6bf3/aiohttp-3.14.1-cp313-cp313-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b0/65/5e088a45d0f43cd814bc5bec521c051d42005a472e804b1a36c48dada09b/h5py-3.16.0-cp313-cp313-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/b3/a0/3e6a0b1c1ea6bec76f71473727ef27abf3cd40e9709b3ebcbfbcfaae6f79/boto3-1.43.0-py3-none-any.whl @@ -3237,6 +3276,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c7/05/04af0ca9c5747c95591a549509ebbb0a986155015de59f1e4c984e3af9ab/pydantic_zarr-0.10.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c9/33/a7cbfccc39056a5cf8126b7aab4c8bafbedd4f0ca68ae40ecb627a2d2cd3/py_partiql_parser-0.6.3-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cd/d7/324b9bdd6fa89fefb4765494dd20d76c06d0f4f0aa8e46233e122c9f9f21/scverse_misc-0.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl @@ -3279,7 +3319,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/win-64/vc-14.5-h1b7c187_39.conda - conda: https://conda.anaconda.org/conda-forge/win-64/vc14_runtime-14.51.36231-h1b9f54f_39.conda - conda: https://conda.anaconda.org/conda-forge/win-64/vcomp14-14.51.36231-h1b9f54f_39.conda - - pypi: . + - pypi: ./ - pypi: https://files.pythonhosted.org/packages/00/15/b92b4e1d88d02c6eff9733c9eea21846ab435cc4d813d84ccc5d335955df/graphql_core-3.2.11-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/17/b82b537a30be67ba178fc0b0fbda8fcbaeda188fb039d6ad8a84e89353a2/dask-2026.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl @@ -3369,6 +3409,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/a2/e8/6d75ffd9784bce2e93d1ae4415649427e39a53bb172d4672b2b59c6f0a7b/pathable-0.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a5/0b/f68a968b49876eae0f2a515387093cebb2eb9451380a96741cc20efac0d0/s3fs-2026.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a7/4b/25a9e8a09083d03ee7ee8701428e514c08029c000f34707cc7665980f4ae/cfn_lint-1.52.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/b3/a0/3e6a0b1c1ea6bec76f71473727ef27abf3cd40e9709b3ebcbfbcfaae6f79/boto3-1.43.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b4/62/674ba5ada6de5615b458d768c0f0fb1a7b53299486fb38a36e0694f3c617/distributed-2026.6.0-py3-none-any.whl @@ -3384,6 +3425,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/c7/05/04af0ca9c5747c95591a549509ebbb0a986155015de59f1e4c984e3af9ab/pydantic_zarr-0.10.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c9/33/a7cbfccc39056a5cf8126b7aab4c8bafbedd4f0ca68ae40ecb627a2d2cd3/py_partiql_parser-0.6.3-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c9/a4/b393076ffb21b469eec5b328a0534cf03a3b90bfc6b1f09507cdd075d938/tornado-6.5.7-cp39-abi3-win_amd64.whl + - pypi: https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cd/d7/324b9bdd6fa89fefb4765494dd20d76c06d0f4f0aa8e46233e122c9f9f21/scverse_misc-0.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl @@ -3436,7 +3478,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.6.17-hbd8a1cb_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-8_cp314.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda - - pypi: . + - pypi: ./ - pypi: https://files.pythonhosted.org/packages/00/15/b92b4e1d88d02c6eff9733c9eea21846ab435cc4d813d84ccc5d335955df/graphql_core-3.2.11-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/17/b82b537a30be67ba178fc0b0fbda8fcbaeda188fb039d6ad8a84e89353a2/dask-2026.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl @@ -3530,6 +3572,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/a5/0b/f68a968b49876eae0f2a515387093cebb2eb9451380a96741cc20efac0d0/s3fs-2026.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a7/4b/25a9e8a09083d03ee7ee8701428e514c08029c000f34707cc7665980f4ae/cfn_lint-1.52.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b3/a0/3e6a0b1c1ea6bec76f71473727ef27abf3cd40e9709b3ebcbfbcfaae6f79/boto3-1.43.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b4/62/674ba5ada6de5615b458d768c0f0fb1a7b53299486fb38a36e0694f3c617/distributed-2026.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl @@ -3540,6 +3583,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/c2/53/d81269aaafccea0d33396c03035de997b743f11e648e6e27a0df99c72980/yarl-1.24.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/c7/05/04af0ca9c5747c95591a549509ebbb0a986155015de59f1e4c984e3af9ab/pydantic_zarr-0.10.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c9/33/a7cbfccc39056a5cf8126b7aab4c8bafbedd4f0ca68ae40ecb627a2d2cd3/py_partiql_parser-0.6.3-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cd/d7/324b9bdd6fa89fefb4765494dd20d76c06d0f4f0aa8e46233e122c9f9f21/scverse_misc-0.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl @@ -3580,7 +3624,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.3-h46df422_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-h010d191_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hbf9d68e_6.conda - - pypi: . + - pypi: ./ - pypi: https://files.pythonhosted.org/packages/00/15/b92b4e1d88d02c6eff9733c9eea21846ab435cc4d813d84ccc5d335955df/graphql_core-3.2.11-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/17/b82b537a30be67ba178fc0b0fbda8fcbaeda188fb039d6ad8a84e89353a2/dask-2026.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl @@ -3668,6 +3712,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/a2/e8/6d75ffd9784bce2e93d1ae4415649427e39a53bb172d4672b2b59c6f0a7b/pathable-0.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a5/0b/f68a968b49876eae0f2a515387093cebb2eb9451380a96741cc20efac0d0/s3fs-2026.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a7/4b/25a9e8a09083d03ee7ee8701428e514c08029c000f34707cc7665980f4ae/cfn_lint-1.52.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/ad/80/d022a34ff05d2cbedd8ccf841fc1f532ecfa9eb5ed1711b56d0e0ea71fc9/pyarrow-24.0.0-cp314-cp314-macosx_12_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/b3/a0/3e6a0b1c1ea6bec76f71473727ef27abf3cd40e9709b3ebcbfbcfaae6f79/boto3-1.43.0-py3-none-any.whl @@ -3683,6 +3728,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c7/05/04af0ca9c5747c95591a549509ebbb0a986155015de59f1e4c984e3af9ab/pydantic_zarr-0.10.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c9/33/a7cbfccc39056a5cf8126b7aab4c8bafbedd4f0ca68ae40ecb627a2d2cd3/py_partiql_parser-0.6.3-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cd/d7/324b9bdd6fa89fefb4765494dd20d76c06d0f4f0aa8e46233e122c9f9f21/scverse_misc-0.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl @@ -3726,7 +3772,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/win-64/vc14_runtime-14.51.36231-h1b9f54f_39.conda - conda: https://conda.anaconda.org/conda-forge/win-64/vcomp14-14.51.36231-h1b9f54f_39.conda - conda: https://conda.anaconda.org/conda-forge/win-64/zstd-1.5.7-h534d264_6.conda - - pypi: . + - pypi: ./ - pypi: https://files.pythonhosted.org/packages/00/15/b92b4e1d88d02c6eff9733c9eea21846ab435cc4d813d84ccc5d335955df/graphql_core-3.2.11-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/17/b82b537a30be67ba178fc0b0fbda8fcbaeda188fb039d6ad8a84e89353a2/dask-2026.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl @@ -3816,6 +3862,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/a5/0b/f68a968b49876eae0f2a515387093cebb2eb9451380a96741cc20efac0d0/s3fs-2026.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a6/3d/124ac75fcd0ecc09b8fdccb0246ef65e35b012030defb0e0eba2cbbbe948/pandas-2.3.3-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/a7/4b/25a9e8a09083d03ee7ee8701428e514c08029c000f34707cc7665980f4ae/cfn_lint-1.52.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b3/a0/3e6a0b1c1ea6bec76f71473727ef27abf3cd40e9709b3ebcbfbcfaae6f79/boto3-1.43.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b4/62/674ba5ada6de5615b458d768c0f0fb1a7b53299486fb38a36e0694f3c617/distributed-2026.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl @@ -3827,6 +3874,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/c7/05/04af0ca9c5747c95591a549509ebbb0a986155015de59f1e4c984e3af9ab/pydantic_zarr-0.10.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c9/33/a7cbfccc39056a5cf8126b7aab4c8bafbedd4f0ca68ae40ecb627a2d2cd3/py_partiql_parser-0.6.3-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c9/a4/b393076ffb21b469eec5b328a0534cf03a3b90bfc6b1f09507cdd075d938/tornado-6.5.7-cp39-abi3-win_amd64.whl + - pypi: https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/cd/d7/324b9bdd6fa89fefb4765494dd20d76c06d0f4f0aa8e46233e122c9f9f21/scverse_misc-0.1.1-py3-none-any.whl @@ -4915,7 +4963,7 @@ packages: - zstd >=1.5.7,<1.6.0a0 size: 388453 timestamp: 1764777142545 -- pypi: . +- pypi: ./ name: ngio requires_dist: - aiohttp @@ -4970,6 +5018,7 @@ packages: - pytest ; extra == 'test' - pytest-cov ; extra == 'test' - pytest-httpserver ; extra == 'test' + - pytest-xdist ; extra == 'test' - scikit-image ; extra == 'test' requires_python: '>=3.11,<3.15' - pypi: https://files.pythonhosted.org/packages/00/15/b92b4e1d88d02c6eff9733c9eea21846ab435cc4d813d84ccc5d335955df/graphql_core-3.2.11-py3-none-any.whl @@ -11622,6 +11671,16 @@ packages: - brotlicffi>=1.2 ; platform_python_implementation != 'CPython' and extra == 'speedups' - backports-zstd ; python_full_version < '3.14' and platform_python_implementation == 'CPython' and sys_platform != 'android' and sys_platform != 'ios' and extra == 'speedups' requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl + name: execnet + version: 2.1.2 + sha256: 67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec + requires_dist: + - hatch ; extra == 'testing' + - pre-commit ; extra == 'testing' + - pytest ; extra == 'testing' + - tox ; extra == 'testing' + requires_python: '>=3.8' - pypi: https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl name: pydantic-core version: 2.46.4 @@ -12558,6 +12617,17 @@ packages: requires_dist: - regex ; extra == 'extras' requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl + name: pytest-xdist + version: 3.8.0 + sha256: 202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88 + requires_dist: + - execnet>=2.1 + - pytest>=7.0.0 + - filelock ; extra == 'testing' + - psutil>=3.0 ; extra == 'psutil' + - setproctitle ; extra == 'setproctitle' + requires_python: '>=3.9' - pypi: https://files.pythonhosted.org/packages/ca/d0/7920b8ba919b90173a80f4428d66e05f4d219a3ae3805cebe86924f9cfa1/vispy-0.16.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl name: vispy version: 0.16.2 diff --git a/pyproject.toml b/pyproject.toml index dd80f1ac..90971a66 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,6 +60,7 @@ dependencies = [ test = [ "pytest", "pytest-cov", + "pytest-xdist", "scikit-image", "boto3", "pytest_httpserver", @@ -175,9 +176,9 @@ filterwarnings = [ addopts = [ "-vv", "--color=yes", - "--cov=ngio", - "--cov-report=term-missing", - "--cov-report=xml", +] +markers = [ + "network: test requires internet access (deselect with '-m \"not network\"')", ] # https://coverage.readthedocs.io/ diff --git a/src/ngio/common/_roi.py b/src/ngio/common/_roi.py index 825dc470..2916b032 100644 --- a/src/ngio/common/_roi.py +++ b/src/ngio/common/_roi.py @@ -38,7 +38,7 @@ def _join_roi_names(name1: str | None, name2: str | None) -> str | None: if name1 == name2: return name1 return f"{name1}:{name2}" - return name1 or name2 + return name1 if name1 is not None else name2 def _join_roi_labels(label1: int | None, label2: int | None) -> int | None: @@ -46,7 +46,7 @@ def _join_roi_labels(label1: int | None, label2: int | None) -> int | None: if label1 == label2: return label1 raise NgioValueError("Cannot join ROIs with different labels") - return label1 or label2 + return label1 if label1 is not None else label2 class RoiSlice(BaseModel): @@ -323,9 +323,7 @@ def from_values( _slices = [] for axis, _slice in slices.items(): _slices.append(RoiSlice.from_value(axis_name=axis, value=_slice)) - return cls.model_construct( - name=name, slices=_slices, label=label, space=space, **kwargs - ) + return cls(name=name, slices=_slices, label=label, space=space, **kwargs) def __getitem__(self, key): """Allow dict-like access to slices by axis name.""" @@ -372,7 +370,10 @@ def _apply_sym_ops( ) -> list[RoiSlice] | None: self_axis_dict = {s.axis_name: s for s in self_slices} other_axis_dict = {s.axis_name: s for s in other_slices} - common_axis_names = self_axis_dict.keys() | other_axis_dict.keys() + # self's axis order first, then axes only present in other + common_axis_names = list(self_axis_dict) + [ + name for name in other_axis_dict if name not in self_axis_dict + ] new_slices = [] for axis_name in common_axis_names: slice_a = self_axis_dict.get(axis_name) diff --git a/src/ngio/config/_config.py b/src/ngio/config/_config.py index 1e6c9339..ce21784e 100644 --- a/src/ngio/config/_config.py +++ b/src/ngio/config/_config.py @@ -152,9 +152,23 @@ def _load_config_data() -> dict[str, Any]: ) from e -_config = NgioConfig.model_validate(_load_config_data()) +_config: NgioConfig | None = None def get_config() -> NgioConfig: - """Return the global ngio configuration singleton.""" + """Return the global ngio configuration singleton. + + The configuration is loaded lazily on first access, so setting + `NGIO_CONFIG_PATH` before the first call is honored even if ngio + was imported earlier. + """ + global _config + if _config is None: + _config = NgioConfig.model_validate(_load_config_data()) return _config + + +def _reset_config() -> None: + """Drop the cached configuration so the next `get_config` reloads it.""" + global _config + _config = None diff --git a/src/ngio/experimental/iterators/_rois_utils.py b/src/ngio/experimental/iterators/_rois_utils.py index ef463810..ccd78072 100644 --- a/src/ngio/experimental/iterators/_rois_utils.py +++ b/src/ngio/experimental/iterators/_rois_utils.py @@ -1,5 +1,6 @@ from ngio import Roi from ngio.images._abstract_image import AbstractImage +from ngio.utils import NgioValueError def rois_product(rois_a: list[Roi], rois_b: list[Roi]) -> list[Roi]: @@ -42,14 +43,29 @@ def grid( stride_y = stride_y if stride_y is not None else size_y stride_x = stride_x if stride_x is not None else size_x + for axis_name, stride in ( + ("t", stride_t), + ("z", stride_z), + ("y", stride_y), + ("x", stride_x), + ): + if stride < 1: + raise NgioValueError( + f"Grid stride along '{axis_name}' must be >= 1, got {stride}. " + "This can happen when the requested overlap is equal to or " + "larger than the tile size." + ) + # Here we would create a grid of ROIs based on the specified parameters. new_rois = [] for t in range(0, t_dim, stride_t): for z in range(0, z_dim, stride_z): for y in range(0, y_dim, stride_y): for x in range(0, x_dim, stride_x): + tile_name = f"t{t}_z{z}_y{y}_x{x}" + name = f"{base_name}_{tile_name}" if base_name else tile_name roi = Roi.from_values( - name=base_name, + name=name, slices={ "x": (x, size_x), "y": (y, size_y), diff --git a/src/ngio/hcs/_plate.py b/src/ngio/hcs/_plate.py index 5e860230..b1c755d8 100644 --- a/src/ngio/hcs/_plate.py +++ b/src/ngio/hcs/_plate.py @@ -387,8 +387,9 @@ def _get_well(self, well_path: str) -> OmeZarrWell: return cached_well group_handler = self._group_handler.get_handler(well_path) - self._wells_cache.set(well_path, OmeZarrWell(group_handler)) - return OmeZarrWell(group_handler) + well = OmeZarrWell(group_handler) + self._wells_cache.set(well_path, well) + return well def get_well(self, row: str, column: int | str) -> OmeZarrWell: """Get a well from the plate. diff --git a/src/ngio/images/_create_utils.py b/src/ngio/images/_create_utils.py index 5a6abdee..08fc9bbc 100644 --- a/src/ngio/images/_create_utils.py +++ b/src/ngio/images/_create_utils.py @@ -87,10 +87,12 @@ def _check_deprecated_scaling_factors( y_scale = yx_scaling_factor if yx_scaling_factor is not None else 2.0 x_scale = yx_scaling_factor if yx_scaling_factor is not None else 2.0 z_scale = z_scaling_factor if z_scaling_factor is not None else 1.0 - scaling_factors = (z_scale, x_scale, y_scale) + scaling_factors = (z_scale, y_scale, x_scale) if len(scaling_factors) < len(shape): padding = (1.0,) * (len(shape) - len(scaling_factors)) scaling_factors = padding + scaling_factors + elif len(scaling_factors) > len(shape): + scaling_factors = scaling_factors[len(scaling_factors) - len(shape) :] return scaling_factors return scaling_factors diff --git a/src/ngio/images/_ome_zarr_container.py b/src/ngio/images/_ome_zarr_container.py index 23511115..1643a16c 100644 --- a/src/ngio/images/_ome_zarr_container.py +++ b/src/ngio/images/_ome_zarr_container.py @@ -79,7 +79,7 @@ def _try_get_label_container( axes_setup=axes_setup, ngff_version=ngff_version, ) - except FileNotFoundError: + except (NgioError, FileNotFoundError): return None diff --git a/src/ngio/images/_table_ops.py b/src/ngio/images/_table_ops.py index 508a608b..18d63ef9 100644 --- a/src/ngio/images/_table_ops.py +++ b/src/ngio/images/_table_ops.py @@ -32,7 +32,7 @@ def _reindex_dataframe( index_cols.append(old_index) dataframe.index = dataframe[index_cols].astype(str).agg("_".join, axis=1) - if index_key is None: + if index_key is not None: dataframe.index.name = index_key return dataframe @@ -58,16 +58,25 @@ def _add_const_columns_pl( dataframe: pl.LazyFrame, new_cols: dict[str, str], index_key: str | None = None, + table_index_key: str | None = None, ) -> pl.LazyFrame: dataframe = dataframe.with_columns( [pl.lit(value, dtype=pl.String()).alias(col) for col, value in new_cols.items()] ) if index_key is not None: + # Mirror the pandas path: the new index hashes the extras columns + # plus the table's original index column (when present). + index_cols = list(new_cols.keys()) + if ( + table_index_key is not None + and table_index_key in dataframe.collect_schema().names() + ): + index_cols.append(table_index_key) dataframe = dataframe.with_columns( [ pl.concat_str( - [pl.col(col) for col in new_cols.keys()], + [pl.col(col).cast(pl.String()) for col in index_cols], separator="_", ).alias(index_key) ] @@ -105,6 +114,7 @@ def _pl_concat( dataframe=table.table.lazy_frame, new_cols=table.extras, index_key=index_key, + table_index_key=table.table.meta.index_key, ) dataframes.append(polars_ls) @@ -301,6 +311,7 @@ def process_image( image=image, name=name, extra=extra, + mode=mode, strict=strict, ) tasks.append(task) diff --git a/src/ngio/io_pipes/_ops_slices.py b/src/ngio/io_pipes/_ops_slices.py index 6bc0fb96..8ff00824 100644 --- a/src/ngio/io_pipes/_ops_slices.py +++ b/src/ngio/io_pipes/_ops_slices.py @@ -299,7 +299,11 @@ def _try_to_slice(value: Sequence[int]) -> slice | list[int]: # If the input is not sorted, return it as a tuple max_input = max(value) min_input = min(value) - assert min_input >= 0, "Input must contain non-negative integers" + if min_input < 0: + raise NgioValueError( + f"Invalid negative value {min_input} in sequence {value}. " + "Slicing sequences must contain non-negative integers." + ) if sorted(value) == list(range(min_input, max_input + 1)): return slice(min_input, max_input + 1) diff --git a/src/ngio/io_pipes/_ops_slices_utils.py b/src/ngio/io_pipes/_ops_slices_utils.py index 4133340a..c132fada 100644 --- a/src/ngio/io_pipes/_ops_slices_utils.py +++ b/src/ngio/io_pipes/_ops_slices_utils.py @@ -47,12 +47,7 @@ def check_elem_intersection(s1: SlicingType, s2: SlicingType) -> bool: start1, stop1, step1 = s1.start or 0, s1.stop or float("inf"), s1.step or 1 start2, stop2, step2 = s2.start or 0, s2.stop or float("inf"), s2.step or 1 - if step1 is not None and step2 != 1: - raise NotImplementedError( - "Intersection for slices with step != 1 is not implemented" - ) - - if step2 is not None and step1 != 1: + if step1 != 1 or step2 != 1: raise NotImplementedError( "Intersection for slices with step != 1 is not implemented" ) diff --git a/src/ngio/ome_zarr_meta/ngio_specs/_axes.py b/src/ngio/ome_zarr_meta/ngio_specs/_axes.py index 18fb2c8b..f515ffd6 100644 --- a/src/ngio/ome_zarr_meta/ngio_specs/_axes.py +++ b/src/ngio/ome_zarr_meta/ngio_specs/_axes.py @@ -187,15 +187,16 @@ def from_ordered_list( "The number of axes names cannot be greater than the " "number of canonical axes." ) - canonical_order = list(canonical_order) chanonical_axes = canonical_axes_order() + # Canonical names always claim their own slot; non-canonical names + # fill the remaining slots right-aligned. + free_slots = [c_ax for c_ax in canonical_order if c_ax not in set(axes_names)] axes_mapping = {} for ax in reversed(axes_names): - c_ax = canonical_order.pop() if ax in chanonical_axes: axes_mapping[ax] = ax else: - axes_mapping[c_ax] = ax + axes_mapping[free_slots.pop()] = ax return cls(**axes_mapping) def canonical_map(self) -> dict[str, str]: diff --git a/src/ngio/ome_zarr_meta/ngio_specs/_ngio_hcs.py b/src/ngio/ome_zarr_meta/ngio_specs/_ngio_hcs.py index af903285..0202e14b 100644 --- a/src/ngio/ome_zarr_meta/ngio_specs/_ngio_hcs.py +++ b/src/ngio/ome_zarr_meta/ngio_specs/_ngio_hcs.py @@ -116,7 +116,7 @@ def add_image( strict (bool): If True, check if the image already exists in the well. If False, do not check if the image already exists in the well. """ - list_of_images = self.images + list_of_images = list(self.images) for image in list_of_images: if image.path == path: raise NgioValueError( @@ -143,7 +143,7 @@ def remove_image(self, path: str) -> "NgioWellMeta": Args: path (str): The path of the image. """ - list_of_images = self.images + list_of_images = list(self.images) for image in list_of_images: if image.path == path: list_of_images.remove(image) @@ -450,7 +450,7 @@ def add_well( plate, row_idx = self.add_row(row=row) plate, column_idx = plate.add_column(column=column) - wells = plate.plate.wells + wells = list(plate.plate.wells) for well_obj in wells: if well_obj.rowIndex == row_idx and well_obj.columnIndex == column_idx: break @@ -487,9 +487,10 @@ def add_acquisition( acquisition_name (str | None): The acquisition name of the well. **acquisition_kwargs: Additional acquisition metadata. """ - acquisitions = self.plate.acquisitions - if acquisitions is None: + if self.plate.acquisitions is None: acquisitions = [] + else: + acquisitions = list(self.plate.acquisitions) for acquisition_obj in acquisitions: if acquisition_obj.id == acquisition_id: @@ -528,7 +529,7 @@ def remove_well(self, row: str, column: str | int) -> "NgioPlateMeta": if column_idx is None: raise NgioValueError(f"Column {column} not found in the plate.") - wells = self.plate.wells + wells = list(self.plate.wells) for well_obj in wells: if well_obj.rowIndex == row_idx and well_obj.columnIndex == column_idx: wells.remove(well_obj) diff --git a/src/ngio/ome_zarr_meta/ngio_specs/_pixel_size.py b/src/ngio/ome_zarr_meta/ngio_specs/_pixel_size.py index 90d0fd79..6180a385 100644 --- a/src/ngio/ome_zarr_meta/ngio_specs/_pixel_size.py +++ b/src/ngio/ome_zarr_meta/ngio_specs/_pixel_size.py @@ -40,13 +40,9 @@ def __repr__(self) -> str: def __eq__(self, other) -> bool: """Check if two pixel sizes are equal.""" if not isinstance(other, PixelSize): - raise TypeError("Can only compare PixelSize with PixelSize.") + return NotImplemented - if ( - self.time_unit is not None - and other.time_unit is None - and self.time_unit != other.time_unit - ): + if self.time_unit != other.time_unit: return False if self.space_unit != other.space_unit: diff --git a/src/ngio/ome_zarr_meta/v05/_v05_spec.py b/src/ngio/ome_zarr_meta/v05/_v05_spec.py index fe6922a9..b97ba65a 100644 --- a/src/ngio/ome_zarr_meta/v05/_v05_spec.py +++ b/src/ngio/ome_zarr_meta/v05/_v05_spec.py @@ -156,7 +156,7 @@ def _v05_to_ngio_datasets( axis_type=AxisType(v05_axis.type), # (for some reason the type is a generic JsonValue, # but it should be a string or None) - unit=v05_axis.unit, # type: ignore + unit=unit, # type: ignore ) ) axes_handler = AxesHandler( diff --git a/src/ngio/tables/v1/_roi_table.py b/src/ngio/tables/v1/_roi_table.py index 6cc69153..f2d2bbce 100644 --- a/src/ngio/tables/v1/_roi_table.py +++ b/src/ngio/tables/v1/_roi_table.py @@ -210,6 +210,10 @@ def __init__(self, rois: Iterable[Roi]) -> None: name = roi.name if name in self._rois_by_name: name = f"{name}_{uuid4().hex[:8]}" + if roi.name is not None: + # keep the roi's own name in sync so the rename + # survives serialization + roi = roi.model_copy(update={"name": name}) self._rois_by_name[name] = roi if roi.label is not None: self._rois_by_label[roi.label] = roi @@ -374,6 +378,10 @@ def _check_rois(self) -> None: If the ROIs are not loaded, load them from the table. """ if self._rois is None: + if self._table_data is None and self._table_backend is None: + # No backend and no in-memory data: this is an empty ROI table. + self._rois = RoiDictWrapper([]) + return self._rois = RoiDictWrapper.from_dataframe( self.dataframe, required_columns=self._required_columns ) diff --git a/tests/conftest.py b/tests/conftest.py index c3453778..a4c86cb6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,6 +3,7 @@ from pathlib import Path import pytest +from filelock import FileLock from ngio.utils import download_ome_zarr_dataset @@ -15,41 +16,141 @@ def pytest_configure(config): ) -zenodo_download_dir = Path(__file__).parent.parent / "data" -os.makedirs(zenodo_download_dir, exist_ok=True) -cardiomyocyte_tiny_source_path = download_ome_zarr_dataset( - "CardiomyocyteTiny", download_dir=zenodo_download_dir -) +ZENODO_DOWNLOAD_DIR = Path(__file__).parent.parent / "data" +TEST_DATA_DIR = Path(__file__).parent / "data" -cardiomyocyte_small_mip_source_path = download_ome_zarr_dataset( - "CardiomyocyteSmallMip", download_dir=zenodo_download_dir -) + +def _download_dataset(name: str) -> Path: + """Download (or reuse the cached copy of) a Zenodo test dataset. + + Called lazily from session fixtures so that test collection never + blocks on the network; with a warm cache no network access happens. + The file lock serializes concurrent pytest-xdist workers on a cold + cache, and `re_unzip=False` skips re-extracting an existing dataset. + """ + os.makedirs(ZENODO_DOWNLOAD_DIR, exist_ok=True) + with FileLock(ZENODO_DOWNLOAD_DIR / f"{name}.lock"): + return download_ome_zarr_dataset( + name, download_dir=ZENODO_DOWNLOAD_DIR, re_unzip=False + ) + + +@pytest.fixture(scope="session") +def cardiomyocyte_tiny_source_path() -> Path: + return _download_dataset("CardiomyocyteTiny") + + +@pytest.fixture(scope="session") +def cardiomyocyte_small_mip_source_path() -> Path: + return _download_dataset("CardiomyocyteSmallMip") @pytest.fixture -def cardiomyocyte_tiny_path(tmp_path: Path) -> Path: +def cardiomyocyte_tiny_path( + tmp_path: Path, cardiomyocyte_tiny_source_path: Path +) -> Path: dest_path = tmp_path / cardiomyocyte_tiny_source_path.stem shutil.copytree(cardiomyocyte_tiny_source_path, dest_path, dirs_exist_ok=True) return dest_path @pytest.fixture -def cardiomyocyte_small_mip_path(tmp_path: Path) -> Path: +def cardiomyocyte_small_mip_path( + tmp_path: Path, cardiomyocyte_small_mip_source_path: Path +) -> Path: dest_path = tmp_path / cardiomyocyte_small_mip_source_path.stem shutil.copytree(cardiomyocyte_small_mip_source_path, dest_path, dirs_exist_ok=True) return dest_path @pytest.fixture -def images_all_versions(tmp_path: Path) -> dict[str, Path]: - dest_base = tmp_path / "all_versions" / "images" +def cardiomyocyte_small_mip_image_path( + tmp_path: Path, cardiomyocyte_small_mip_source_path: Path +) -> Path: + """A fresh copy of the single image `B/03/0` of the small-mip plate. + + Much cheaper than copying the whole plate; use for tests that write to + one image only. + """ + source = cardiomyocyte_small_mip_source_path / "B" / "03" / "0" + dest_path = tmp_path / "cardiomyocyte_small_mip_image" + shutil.copytree(source, dest_path) + return dest_path + + +@pytest.fixture(scope="session") +def cardiomyocyte_tiny_path_readonly( + tmp_path_factory: pytest.TempPathFactory, cardiomyocyte_tiny_source_path: Path +) -> Path: + """One shared session-wide copy of the tiny plate โ€” for read-only tests.""" + dest_path = ( + tmp_path_factory.mktemp("cardio_tiny_ro") / cardiomyocyte_tiny_source_path.stem + ) + shutil.copytree(cardiomyocyte_tiny_source_path, dest_path) + return dest_path + + +@pytest.fixture(scope="session") +def cardiomyocyte_small_mip_path_readonly( + tmp_path_factory: pytest.TempPathFactory, cardiomyocyte_small_mip_source_path: Path +) -> Path: + """One shared session-wide copy of the small-mip plate โ€” for read-only tests.""" + dest_path = ( + tmp_path_factory.mktemp("cardio_mip_ro") + / cardiomyocyte_small_mip_source_path.stem + ) + shutil.copytree(cardiomyocyte_small_mip_source_path, dest_path) + return dest_path + + +# One entry per (NGFF version, axes combination) in tests/data/{v04,v05}/images +ALL_IMAGE_ZARR_NAMES = [ + f"{version}/test_image_{axes}.zarr" + for version in ("v04", "v05") + for axes in ("yx", "cyx", "zyx", "czyx", "c1yx", "tyx", "tcyx", "tzyx", "tczyx") +] + + +@pytest.fixture(params=ALL_IMAGE_ZARR_NAMES) +def zarr_name(request: pytest.FixtureRequest) -> str: + """Name of one on-disk test image, keyed into `images_all_versions`.""" + return request.param + + +def _copy_images_all_versions(dest_base: Path) -> dict[str, Path]: dest_base.mkdir(parents=True, exist_ok=True) paths = {} for version in ["v04", "v05"]: - source = Path(f"tests/data/{version}/images/") + source = TEST_DATA_DIR / version / "images" dest = dest_base / version dest.mkdir(parents=True, exist_ok=True) shutil.copytree(source, dest, dirs_exist_ok=True) for file in dest.glob("*.zarr"): paths[f"{version}/{file.name}"] = file return paths + + +@pytest.fixture +def single_image_copy(tmp_path: Path, zarr_name: str) -> Path: + """A fresh per-test copy of one test image โ€” for tests that write to it. + + Composes with the `zarr_name` parametrization (or an explicit + `@pytest.mark.parametrize("zarr_name", ...)` override). + """ + version, name = zarr_name.split("/") + source = TEST_DATA_DIR / version / "images" / name + dest = tmp_path / version / name + shutil.copytree(source, dest) + return dest + + +@pytest.fixture(scope="session") +def images_all_versions_readonly( + tmp_path_factory: pytest.TempPathFactory, +) -> dict[str, Path]: + """One shared session-wide copy of the test images. + + Use only in tests that never write to the images (a stray write would + leak into every later test of the session). + """ + return _copy_images_all_versions(tmp_path_factory.mktemp("images_all_versions")) diff --git a/tests/create_test_data.py b/tests/create_test_data.py index 12ebfd5f..3426dfc4 100644 --- a/tests/create_test_data.py +++ b/tests/create_test_data.py @@ -2,7 +2,7 @@ from ngio import NgffVersions, create_empty_ome_zarr -DATA_DIR = Path("tests/data") +DATA_DIR = Path(__file__).parent / "data" IMAGE_SPECS = [ { diff --git a/tests/data/v04/meta/base_ome_zarr_image_meta_wrong_axis_order.json b/tests/data/v04/meta/base_ome_zarr_image_meta_wrong_axis_order.json deleted file mode 100644 index 00f0aa8b..00000000 --- a/tests/data/v04/meta/base_ome_zarr_image_meta_wrong_axis_order.json +++ /dev/null @@ -1,73 +0,0 @@ -{ - "multiscales": [ - { - "axes": [ - {"name": "z", "type": "space", "unit": "micrometer"}, - {"name": "c", "type": "channel"}, - {"name": "y", "type": "space", "unit": "micrometer"}, - {"name": "x", "type": "space", "unit": "micrometer"} - ], - "datasets": [ - { - "coordinateTransformations": [ - {"scale": [1.0, 1.0, 0.1625, 0.1625], "type": "scale"} - ], - "path": "0" - }, - { - "coordinateTransformations": [ - {"scale": [1.0, 1.0, 0.325, 0.325], "type": "scale"} - ], - "path": "1" - }, - { - "coordinateTransformations": [ - {"scale": [1.0, 1.0, 0.65, 0.65], "type": "scale"} - ], - "path": "2" - }, - { - "coordinateTransformations": [ - {"scale": [1.0, 1.0, 1.3, 1.3], "type": "scale"} - ], - "path": "3" - }, - { - "coordinateTransformations": [ - {"scale": [1.0, 1.0, 2.6, 2.6], "type": "scale"} - ], - "path": "4" - } - ], - "version": "0.4" - } - ], - "omero": { - "channels": [ - { - "active": true, - "color": "00FFFF", - "label": "DAPI", - "wavelength_id": "A01_C01", - "window": {"end": 700, "max": 65535, "min": 0, "start": 0} - }, - { - "active": true, - "color": "FF00FF", - "label": "nanog", - "wavelength_id": "A01_C02", - "window": {"end": 180, "max": 65535, "min": 0, "start": 0} - }, - { - "active": true, - "color": "FFFF00", - "label": "Lamin B1", - "wavelength_id": "A02_C03", - "window": {"end": 1500, "max": 65535, "min": 0, "start": 0} - } - ], - "id": 1, - "name": "TBD", - "version": "0.4" - } -} diff --git a/tests/stores/conftest.py b/tests/stores/conftest.py index 03dd8014..fd2889ec 100644 --- a/tests/stores/conftest.py +++ b/tests/stores/conftest.py @@ -17,7 +17,7 @@ def log_message(self, format, *args): pass -@pytest.fixture +@pytest.fixture(scope="session") def moto_s3_server(): """Mock S3 backend via aiomoto in server mode. @@ -26,6 +26,9 @@ def moto_s3_server(): region), so both boto3 and s3fs discover it automatically โ€” no subprocess, no hardcoded port. The Moto backend is shared between the synchronous boto3 client used here and the aiobotocore backend that s3fs drives under the hood. + + Session-scoped: starting/stopping the server costs ~4s per test. Tests + isolate from each other by writing to `random_zarr_path()` keys. """ bucket_name = "s3-ci-test-bucket" with mock_aws(server_mode=True) as ctx: diff --git a/tests/stores/utils.py b/tests/stores/utils.py index 695a2852..5bad20c6 100644 --- a/tests/stores/utils.py +++ b/tests/stores/utils.py @@ -8,11 +8,14 @@ from ngio import OmeZarrContainer, create_empty_ome_zarr from ngio.tables import FeatureTable -TEST_IMAGE = np.ones((3, 5, 64, 64), dtype=np.uint16) -TEST_LABEL = np.zeros((5, 64, 64), dtype=np.uint16) -TEST_LABEL[0, 10:30, 10:30] = 1 -TEST_LABEL[0, 35:55, 35:55] = 2 -TEST_LABEL[0, 20:40, 40:60] = 3 +# Kept deliberately small: every store-matrix test round-trips this image +# (plus a label and four table backends) through mocked remote stores, so +# the object count directly drives CI runtime. +TEST_IMAGE = np.ones((3, 2, 32, 32), dtype=np.uint16) +TEST_LABEL = np.zeros((2, 32, 32), dtype=np.uint16) +TEST_LABEL[0, 5:15, 5:15] = 1 +TEST_LABEL[0, 17:27, 17:27] = 2 +TEST_LABEL[0, 10:20, 20:30] = 3 TEST_TABLE = pd.DataFrame( { @@ -108,10 +111,10 @@ def create_sample_ome_zarr( """Create a sample OME-Zarr structure in the given store for testing.""" ome_zarr = create_empty_ome_zarr( store=store, - shape=(3, 5, 64, 64), + shape=TEST_IMAGE.shape, pixelsize=(0.65, 0.65), channels_meta=["Channel 1", "Channel 2", "Channel 3"], - levels=3, + levels=2, axes_names=["c", "z", "y", "x"], ) ome_zarr = set_to_image(ome_zarr) diff --git a/tests/unit/common/test_roi.py b/tests/unit/common/test_roi.py index 5f281625..1c41cb97 100644 --- a/tests/unit/common/test_roi.py +++ b/tests/unit/common/test_roi.py @@ -1,4 +1,5 @@ import pytest +from pydantic import ValidationError from ngio import PixelSize from ngio.common import Roi, RoiSlice @@ -571,3 +572,44 @@ def test_rois_union( assert union.get("y") == expected_union.get("y") assert union.get("z") == expected_union.get("z") assert union.get("t") == expected_union.get("t") + + +def test_roi_join_preserves_label_zero(): + roi_a = Roi.from_values(name="a", slices={"x": (0, 2), "y": (0, 2)}, label=0) + roi_b = Roi.from_values(name="b", slices={"x": (1, 2), "y": (1, 2)}) + + intersection = roi_a.intersection(roi_b) + assert intersection is not None + assert intersection.label == 0 + + union = roi_a.union(roi_b) + assert union.label == 0 + + +def test_roi_join_preserves_empty_name(): + roi_a = Roi.from_values(name="", slices={"x": (0, 2), "y": (0, 2)}) + roi_b = Roi.from_values(name=None, slices={"x": (1, 2), "y": (1, 2)}) + + intersection = roi_a.intersection(roi_b) + assert intersection is not None + assert intersection.name == "" + + +def test_roi_sym_ops_axis_order_is_deterministic(): + roi_a = Roi.from_values(name="a", slices={"z": (0, 2), "y": (0, 2), "x": (0, 2)}) + roi_b = Roi.from_values(name="b", slices={"y": (1, 2), "x": (1, 2), "t": (0, 1)}) + + intersection = roi_a.intersection(roi_b) + assert intersection is not None + assert [s.axis_name for s in intersection.slices] == ["z", "y", "x", "t"] + + union = roi_a.union(roi_b) + assert [s.axis_name for s in union.slices] == ["z", "y", "x", "t"] + + +def test_roi_from_values_validates_fields(): + with pytest.raises(ValidationError): + Roi.from_values(name="r", slices={"x": (0, 1), "y": (0, 1)}, label=-1) + + with pytest.raises(ValidationError): + Roi.from_values(name="r", slices={"x": (0, 1)}) diff --git a/tests/unit/config/test_config.py b/tests/unit/config/test_config.py index 62bd7f67..2cf5d9a1 100644 --- a/tests/unit/config/test_config.py +++ b/tests/unit/config/test_config.py @@ -160,3 +160,19 @@ def test_retry_config_no_warning_by_default(recwarn): RetryConfig() RetryConfig(max_retries=5, retry_on=["OSError"]) assert len(recwarn) == 0 + + +def test_get_config_lazy_load_respects_env_var(monkeypatch, tmp_path): + from ngio.config._config import _reset_config, get_config + + config_path = tmp_path / "ngio_config.json" + config_path.write_text('{"io_retry": {"max_retries": 7}}') + monkeypatch.setenv("NGIO_CONFIG_PATH", str(config_path)) + + _reset_config() + try: + assert get_config().io_retry.max_retries == 7 + # cached: same object on repeated calls + assert get_config() is get_config() + finally: + _reset_config() diff --git a/tests/unit/hcs/test_plate.py b/tests/unit/hcs/test_plate.py index 474fcca8..2a265d28 100644 --- a/tests/unit/hcs/test_plate.py +++ b/tests/unit/hcs/test_plate.py @@ -19,9 +19,8 @@ from ngio.utils import NgioValueError -def test_open_real_ome_zarr_plate(cardiomyocyte_tiny_path: Path): - cardiomyocyte_tiny_path = cardiomyocyte_tiny_path - ome_zarr_plate = open_ome_zarr_plate(cardiomyocyte_tiny_path) +def test_open_real_ome_zarr_plate(cardiomyocyte_tiny_path_readonly: Path): + ome_zarr_plate = open_ome_zarr_plate(cardiomyocyte_tiny_path_readonly) assert isinstance(ome_zarr_plate.__repr__(), str) assert ome_zarr_plate.columns == ["03"] @@ -101,8 +100,10 @@ def test_create_and_edit_plate_path_normalization(tmp_path: Path): assert test_plate.images_paths() == ["B/03/0_mip", "B/03/1_illumination_correction"] -def test_derive_plate_from_ome_zarr(cardiomyocyte_tiny_path: Path, tmp_path: Path): - ome_zarr_plate = open_ome_zarr_plate(cardiomyocyte_tiny_path) +def test_derive_plate_from_ome_zarr( + cardiomyocyte_tiny_path_readonly: Path, tmp_path: Path +): + ome_zarr_plate = open_ome_zarr_plate(cardiomyocyte_tiny_path_readonly) test_plate = ome_zarr_plate.derive_plate( tmp_path / "test_plate.zarr", keep_acquisitions=True ) @@ -301,8 +302,8 @@ def test_tables_api(tmp_path: Path): test_plate.delete_table("non_existing_table", missing_ok=True) -def test_plate_table_aggregations(cardiomyocyte_small_mip_path: Path): - ome_zarr_plate = open_ome_zarr_plate(cardiomyocyte_small_mip_path) +def test_plate_table_aggregations(cardiomyocyte_small_mip_path_readonly: Path): + ome_zarr_plate = open_ome_zarr_plate(cardiomyocyte_small_mip_path_readonly) expected_tables = [ "FOV_ROI_table", "nuclei_ROI_table", diff --git a/tests/unit/hcs/test_plate_edge_cases.py b/tests/unit/hcs/test_plate_edge_cases.py new file mode 100644 index 00000000..30390a90 --- /dev/null +++ b/tests/unit/hcs/test_plate_edge_cases.py @@ -0,0 +1,173 @@ +"""Edge-case coverage tests for `ngio.hcs._plate`.""" + +import asyncio +from pathlib import Path + +import pandas as pd +import pytest + +from ngio import ( + Roi, + create_empty_plate, + create_empty_well, + open_ome_zarr_plate, +) +from ngio.tables import ( + ConditionTable, + FeatureTable, + GenericRoiTable, + MaskingRoiTable, + RoiTable, +) +from ngio.utils import NgioDeprecationWarning, NgioValueError + + +def _make_roi(name: str, label: int | None = None) -> Roi: + return Roi.from_values( + name=name, + slices={"x": (0, 10), "y": (0, 10), "z": (0, 10)}, + label=label, + ) + + +def test_get_image_missing_in_well(cardiomyocyte_tiny_path_readonly: Path): + plate = open_ome_zarr_plate(cardiomyocyte_tiny_path_readonly, mode="r") + with pytest.raises(ValueError, match="does not exist in well"): + plate.get_image("B", "03", "not_an_image") + + +def test_wells_and_images_cache(cardiomyocyte_tiny_path_readonly: Path): + plate = open_ome_zarr_plate(cardiomyocyte_tiny_path_readonly, cache=True, mode="r") + # First get_image populates the cache, later calls return the cached object + image = plate.get_image("B", "03", "0") + assert plate.get_image("B", "03", "0") is image + + # First get_well populates the cache, later calls return the cached object + _ = plate.get_well("B", "03") + well = plate.get_well("B", "03") + assert plate.get_well("B", "03") is well + + +def test_add_image_none_path_raises(tmp_path: Path): + plate = create_empty_plate(tmp_path / "plate.zarr", name="plate") + with pytest.raises(ValueError, match="Image path cannot be None"): + plate.add_image(row="B", column="03", image_path=None) # ty: ignore[invalid-argument-type] + with pytest.raises(ValueError, match="Image path cannot be None"): + plate.atomic_add_image(row="B", column="03", image_path=None) # ty: ignore[invalid-argument-type] + + +def test_atomic_remove_image(tmp_path: Path): + plate = create_empty_plate(tmp_path / "plate.zarr", name="plate") + plate.add_image(row="B", column="03", image_path="0") + plate.add_image(row="B", column="03", image_path="1") + + plate.atomic_remove_image(row="B", column="03", image_path="1") + assert plate.images_paths() == ["B/03/0"] + assert plate.wells_paths() == ["B/03"] + + # Removing the last image of a well also removes the well + plate.atomic_remove_image(row="B", column="03", image_path="0") + assert plate.images_paths() == [] + assert plate.wells_paths() == [] + + +def test_tables_container_missing_on_readonly_plate(tmp_path: Path): + create_empty_plate(tmp_path / "plate.zarr", name="plate") + plate = open_ome_zarr_plate(tmp_path / "plate.zarr", mode="r") + # No tables group exists and it cannot be created in read-only mode + assert plate.list_tables() == [] + with pytest.raises(NgioValueError, match="No tables container found"): + _ = plate.tables_container + + +def test_typed_table_getters(tmp_path: Path): + plate = create_empty_plate(tmp_path / "plate.zarr", name="plate") + plate.add_table("roi", RoiTable(rois=[_make_roi("roi_1")])) + plate.add_table("masking", MaskingRoiTable(rois=[_make_roi("1", label=1)])) + plate.add_table("feature", FeatureTable(pd.DataFrame({"label": [1], "x": [0.5]}))) + plate.add_table( + "condition", ConditionTable(table_data=pd.DataFrame({"cond": ["a"]})) + ) + + assert isinstance(plate.get_roi_table("roi"), RoiTable) + with pytest.raises(NgioValueError, match="is not a ROI table"): + plate.get_roi_table("masking") + + assert isinstance(plate.get_masking_roi_table("masking"), MaskingRoiTable) + with pytest.raises(NgioValueError, match="is not a masking ROI table"): + plate.get_masking_roi_table("roi") + + assert isinstance(plate.get_feature_table("feature"), FeatureTable) + with pytest.raises(NgioValueError, match="is not a feature table"): + plate.get_feature_table("roi") + + # RoiTable is a GenericRoiTable subclass, so it passes the generic getter + assert isinstance(plate.get_generic_roi_table("roi"), GenericRoiTable) + with pytest.raises(NgioValueError, match="is not a generic ROI table"): + plate.get_generic_roi_table("feature") + + assert isinstance(plate.get_condition_table("condition"), ConditionTable) + with pytest.raises(NgioValueError, match="is not a condition table"): + plate.get_condition_table("roi") + + +def test_get_table_check_type_deprecated(tmp_path: Path): + plate = create_empty_plate(tmp_path / "plate.zarr", name="plate") + plate.add_table("roi", RoiTable(rois=[_make_roi("roi_1")])) + with pytest.warns(NgioDeprecationWarning, match="'check_type' argument"): + table = plate.get_table("roi", check_type="roi_table") + assert isinstance(table, RoiTable) + + +def test_get_table_as(tmp_path: Path): + plate = create_empty_plate(tmp_path / "plate.zarr", name="plate") + plate.add_table("roi", RoiTable(rois=[_make_roi("roi_1")])) + table = plate.get_table_as("roi", RoiTable) + assert isinstance(table, RoiTable) + assert len(table.rois()) == 1 + + +def test_concatenate_image_tables_as(cardiomyocyte_small_mip_path_readonly: Path): + plate = open_ome_zarr_plate(cardiomyocyte_small_mip_path_readonly, mode="r") + table = plate.concatenate_image_tables_as( + name="regionprops_DAPI", table_cls=FeatureTable + ) + assert isinstance(table, FeatureTable) + + async_table = asyncio.run( + plate.concatenate_image_tables_as_async( + name="regionprops_DAPI", table_cls=FeatureTable + ) + ) + assert isinstance(async_table, FeatureTable) + assert set(table.dataframe.columns) == set(async_table.dataframe.columns) + + +def test_create_empty_plate_version_deprecated(tmp_path: Path): + with pytest.warns(NgioDeprecationWarning, match="'version' argument"): + plate = create_empty_plate(tmp_path / "plate.zarr", name="plate", version="0.4") + assert plate.meta.version == "0.4" + + +def test_derive_plate_version_deprecated(tmp_path: Path): + plate = create_empty_plate( + tmp_path / "plate.zarr", name="plate", ngff_version="0.5" + ) + with pytest.warns(NgioDeprecationWarning, match="'version' argument"): + derived = plate.derive_plate(tmp_path / "derived.zarr", version="0.4") + assert derived.meta.version == "0.4" + + +def test_create_empty_well_version_deprecated(tmp_path: Path): + with pytest.warns(NgioDeprecationWarning, match="'version' argument"): + well = create_empty_well(tmp_path / "well.zarr", version="0.4") + assert well.meta.version == "0.4" + + +def test_get_well_returns_cached_instance(tmp_path: Path): + plate = create_empty_plate(tmp_path / "plate.zarr", name="plate", cache=True) + plate.add_well(row="A", column=1) + + well_1 = plate.get_well(row="A", column=1) + well_2 = plate.get_well(row="A", column=1) + assert well_1 is well_2 diff --git a/tests/unit/hcs/test_well.py b/tests/unit/hcs/test_well.py index 50a3a79f..f4ec8ad9 100644 --- a/tests/unit/hcs/test_well.py +++ b/tests/unit/hcs/test_well.py @@ -7,10 +7,9 @@ from ngio.utils import NgioValueError -def test_open_real_ome_zarr_well(cardiomyocyte_tiny_path: Path): - cardiomyocyte_tiny_path = cardiomyocyte_tiny_path - cardiomyocyte_tiny_path = cardiomyocyte_tiny_path / "B" / "03" - ome_zarr_well = open_ome_zarr_well(cardiomyocyte_tiny_path) +def test_open_real_ome_zarr_well(cardiomyocyte_tiny_path_readonly: Path): + well_path = cardiomyocyte_tiny_path_readonly / "B" / "03" + ome_zarr_well = open_ome_zarr_well(well_path) assert isinstance(ome_zarr_well.__repr__(), str) assert ome_zarr_well.paths() == ["0"] assert ome_zarr_well.acquisition_ids == [] diff --git a/tests/unit/images/test_container_errors.py b/tests/unit/images/test_container_errors.py new file mode 100644 index 00000000..3d116a9c --- /dev/null +++ b/tests/unit/images/test_container_errors.py @@ -0,0 +1,291 @@ +"""Coverage tests for error paths and deprecated shims in OmeZarrContainer.""" + +from pathlib import Path + +import numpy as np +import pandas as pd +import pytest +from zarr.storage import MemoryStore + +from ngio import ( + Roi, + create_empty_ome_zarr, + create_ome_zarr_from_array, + open_label, + open_ome_zarr_container, +) +from ngio.images import Label +from ngio.ome_zarr_meta.ngio_specs import Channel +from ngio.tables import ConditionTable, FeatureTable, MaskingRoiTable, RoiTable +from ngio.utils import ( + NgioDeprecationWarning, + NgioValidationError, + NgioValueError, +) + + +def _make_container(store=None, channels=("DAPI", "GFP")): + """Create a small multi-channel OME-Zarr container in memory.""" + return create_empty_ome_zarr( + store if store is not None else MemoryStore(), + shape=(len(channels), 8, 8), + axes_names=("c", "y", "x"), + pixelsize=0.5, + levels=2, + channels_meta=list(channels), + ) + + +def _add_label(ome_zarr, name: str) -> None: + label = ome_zarr.derive_label(name) + data = np.zeros((8, 8), dtype=np.uint32) + data[:4, :4] = 1 + label.set_array(data) + label.consolidate() + + +def test_repr_few_labels_and_tables(): + ome_zarr = _make_container() + _add_label(ome_zarr, "lbl1") + ome_zarr.add_table("t1", ome_zarr.build_image_roi_table()) + + repr_str = repr(ome_zarr) + assert "labels=['lbl1']" in repr_str + assert "tables=['t1']" in repr_str + + +def test_repr_many_labels_and_tables(): + ome_zarr = _make_container() + for name in ["lbl1", "lbl2", "lbl3"]: + ome_zarr.derive_label(name) + for name in ["t1", "t2", "t3"]: + ome_zarr.add_table(name, ome_zarr.build_image_roi_table()) + + repr_str = repr(ome_zarr) + assert "#labels=3" in repr_str + assert "#tables=3" in repr_str + + +def test_deprecated_image_meta_property(): + ome_zarr = _make_container() + with pytest.warns(NgioDeprecationWarning, match="image_meta"): + meta = ome_zarr.image_meta + assert meta.paths == ome_zarr.meta.paths + + +def test_deprecated_levels_paths_property(): + ome_zarr = _make_container() + with pytest.warns(NgioDeprecationWarning, match="levels_paths"): + paths = ome_zarr.levels_paths + assert paths == ome_zarr.level_paths + + +def test_axes_setup_property(): + ome_zarr = _make_container() + assert ome_zarr.axes_setup == ome_zarr.images_container.axes_setup + + +def test_deprecated_set_channel_percentiles(): + ome_zarr = _make_container() + with pytest.warns(NgioDeprecationWarning, match="set_channel_percentiles"): + ome_zarr.set_channel_percentiles(start_percentile=1.0, end_percentile=99.0) + + +def test_set_axes_units_updates_labels(): + ome_zarr = _make_container() + _add_label(ome_zarr, "lbl1") + ome_zarr.set_axes_units(space_unit="micrometer", set_labels=True) + assert ome_zarr.space_unit == "micrometer" + assert ome_zarr.get_label("lbl1").space_unit == "micrometer" + + +def test_tables_container_raises_on_readonly_without_tables(tmp_path: Path): + store = tmp_path / "no_tables.zarr" + create_empty_ome_zarr(store, shape=(8, 8), pixelsize=0.5, levels=2) + ome_zarr = open_ome_zarr_container(store, mode="r") + + assert ome_zarr.list_tables() == [] + assert ome_zarr.list_labels() == [] + with pytest.raises(NgioValidationError, match="No tables found"): + _ = ome_zarr.tables_container + + +def test_get_masked_image_without_names_raises(): + ome_zarr = _make_container() + with pytest.raises(NgioValueError, match="Neither masking_label_name"): + ome_zarr.get_masked_image() + + +def test_get_masked_image_table_without_reference_label(): + ome_zarr = _make_container() + _add_label(ome_zarr, "lbl1") + roi = Roi.from_values(slices={"y": (0.0, 2.0), "x": (0.0, 2.0)}, name="1", label=1) + table = MaskingRoiTable(rois=[roi]) + ome_zarr.add_table("no_ref", table) + + with pytest.raises(NgioValueError, match="does not have a reference"): + ome_zarr.get_masked_image(masking_table_name="no_ref") + + +@pytest.fixture() +def container_with_tables(): + ome_zarr = _make_container() + ome_zarr.add_table("roi", ome_zarr.build_image_roi_table()) + feature = FeatureTable(pd.DataFrame({"label": [1, 2], "feat": [0.1, 0.2]})) + ome_zarr.add_table("feat", feature) + condition = ConditionTable(table_data=pd.DataFrame({"condition": ["a", "b"]})) + ome_zarr.add_table("cond", condition) + return ome_zarr + + +def test_get_roi_table_wrong_type(container_with_tables): + with pytest.raises(NgioValueError, match="is not a ROI table"): + container_with_tables.get_roi_table("feat") + + +def test_get_masking_roi_table_wrong_type(container_with_tables): + with pytest.raises(NgioValueError, match="is not a masking ROI table"): + container_with_tables.get_masking_roi_table("roi") + + +def test_get_feature_table(container_with_tables): + table = container_with_tables.get_feature_table("feat") + assert isinstance(table, FeatureTable) + with pytest.raises(NgioValueError, match="is not a feature table"): + container_with_tables.get_feature_table("roi") + + +def test_get_generic_roi_table_wrong_type(container_with_tables): + with pytest.raises(NgioValueError, match="is not a generic ROI table"): + container_with_tables.get_generic_roi_table("feat") + + +def test_get_condition_table(container_with_tables): + table = container_with_tables.get_condition_table("cond") + assert isinstance(table, ConditionTable) + with pytest.raises(NgioValueError, match="is not a condition table"): + container_with_tables.get_condition_table("roi") + + +def test_get_table_check_type_deprecated(container_with_tables): + with pytest.warns(NgioDeprecationWarning, match="check_type"): + table = container_with_tables.get_table("roi", check_type="roi_table") + assert isinstance(table, RoiTable) + + +def test_get_table_as(container_with_tables): + table = container_with_tables.get_table_as("roi", RoiTable) + assert isinstance(table, RoiTable) + + +def test_build_masking_roi_table_from_label(): + ome_zarr = _make_container() + _add_label(ome_zarr, "lbl1") + table = ome_zarr.build_masking_roi_table("lbl1") + assert isinstance(table, MaskingRoiTable) + assert len(table.rois()) == 1 + + +@pytest.mark.parametrize( + "zarr_key", ["v04/test_image_yx.zarr", "v05/test_image_yx.zarr"] +) +def test_open_label(images_all_versions_readonly: dict[str, Path], zarr_key: str): + path = images_all_versions_readonly[zarr_key] + + # Open a label group directly (name=None branch) + label = open_label(path / "labels" / "label", mode="r") + assert isinstance(label, Label) + + # Open through the labels container (name provided branch) + label_by_name = open_label(path / "labels", name="label", mode="r") + assert isinstance(label_by_name, Label) + assert label_by_name.path == label.path + assert label_by_name.shape == label.shape + + +def test_create_empty_deprecated_xy_pixelsize(): + with pytest.warns(NgioDeprecationWarning, match="xy_pixelsize"): + ome_zarr = create_empty_ome_zarr( + MemoryStore(), shape=(8, 8), xy_pixelsize=0.5, levels=2 + ) + assert ome_zarr.get_image().pixel_size.x == 0.5 + + +def test_create_empty_deprecated_scaling_factors_2d(): + with pytest.warns(NgioDeprecationWarning, match="xy_scaling_factor"): + ome_zarr = create_empty_ome_zarr( + MemoryStore(), + shape=(16, 16), + pixelsize=0.5, + xy_scaling_factor=2.0, + levels=2, + ) + assert ome_zarr.levels == 2 + assert ome_zarr.get_image(path="1").shape == (8, 8) + + +def test_create_empty_deprecated_scaling_factors_3d(): + with pytest.warns(NgioDeprecationWarning, match="z_scaling_factor"): + ome_zarr = create_empty_ome_zarr( + MemoryStore(), + shape=(4, 16, 16), + pixelsize=0.5, + z_scaling_factor=1.0, + levels=2, + ) + assert ome_zarr.get_image(path="1").shape == (4, 8, 8) + + +def test_create_empty_deprecated_channel_args(): + with pytest.warns(NgioDeprecationWarning): + ome_zarr = create_empty_ome_zarr( + MemoryStore(), + shape=(2, 8, 8), + axes_names=("c", "y", "x"), + pixelsize=0.5, + levels=2, + channel_labels=["ch_a", "ch_b"], + channel_wavelengths=["w1", "w2"], + channel_colors=["FF0000", "00FF00"], + channel_active=[True, False], + ) + assert ome_zarr.channel_labels == ["ch_a", "ch_b"] + assert ome_zarr.wavelength_ids == ["w1", "w2"] + + +def test_create_empty_requires_pixelsize(): + with pytest.raises(NgioValueError, match="pixelsize must be provided"): + create_empty_ome_zarr(MemoryStore(), shape=(8, 8), levels=2) + + +def test_create_from_array_bad_percentiles(): + with pytest.raises(NgioValueError, match="tuple of two values"): + create_ome_zarr_from_array( + MemoryStore(), + array=np.zeros((8, 8), dtype=np.uint16), + pixelsize=0.5, + levels=2, + percentiles=(1.0, 50.0, 99.0), # ty: ignore[invalid-argument-type] + ) + + +def test_create_empty_with_channel_objects(): + channels = [ + Channel.default_init(label="DAPI", wavelength_id="A01_C01"), + Channel.default_init(label="GFP", wavelength_id="A02_C02"), + ] + ome_zarr = _make_container(channels=channels) + assert ome_zarr.wavelength_ids == ["A01_C01", "A02_C02"] + + +def test_labels_missing_on_read_only_container(tmp_path: Path): + from ngio.utils import NgioValidationError + + store = tmp_path / "img.zarr" + create_empty_ome_zarr(store=store, shape=(16, 16), pixelsize=1.0) + container = open_ome_zarr_container(store, mode="r") + + # a read-only container without a labels group degrades gracefully + assert container.list_labels() == [] + with pytest.raises(NgioValidationError): + _ = container.labels_container diff --git a/tests/unit/images/test_create.py b/tests/unit/images/test_create.py index e8ca11d0..b16ba1b4 100644 --- a/tests/unit/images/test_create.py +++ b/tests/unit/images/test_create.py @@ -259,7 +259,7 @@ def test_create_with_sharding(tmp_path: Path): assert img.zarr_array.shards == (4, 3, 16, 16) -def test_fail_derive_singleton(): +def test_pyramid_clamps_singleton_dimensions(): store = MemoryStore() ome_zarr = create_empty_ome_zarr(store=store, shape=(1, 1, 64, 4), pixelsize=0.5) expected_shapes = [ @@ -296,23 +296,33 @@ def test_fail_create_from_non_decreasing_shapes(): ) -def derive_from_legacy_images(tmp_path: Path): +@pytest.mark.parametrize("ngff_version", ["0.4", "0.5"]) +def test_derive_from_legacy_images(tmp_path: Path, ngff_version: str): store = tmp_path / "test_image_legacy.zarr" ome_zarr = create_empty_ome_zarr( - store=store, shape=(4, 3, 127, 128), pixelsize=1.0, overwrite=True + store=store, + shape=(4, 3, 127, 128), + pixelsize=1.0, + overwrite=True, + ngff_version=ngff_version, ) # Simulate legacy multiscale were the scaling factors did not # take into account rounding issues when downsampling - attrs_path = tmp_path / "test_image_legacy.zarr" / "zarr.json" + if ngff_version == "0.4": + attrs_path = store / ".zattrs" + else: + attrs_path = store / "zarr.json" with open(attrs_path) as f: json_dict = json.load(f) - datasets = json_dict["attributes"]["ome"]["multiscales"][0]["datasets"] + if ngff_version == "0.4": + datasets = json_dict["multiscales"][0]["datasets"] + else: + datasets = json_dict["attributes"]["ome"]["multiscales"][0]["datasets"] scale_0 = [1.0, 1.0, 1.0, 1.0] for i in range(len(datasets)): datasets[i]["coordinateTransformations"][0]["scale"] = scale_0 scale_0 = [1.0, 1.0, scale_0[2] * 2, scale_0[3] * 2] - json_dict["attributes"]["ome"]["multiscales"][0]["datasets"] = datasets with open(attrs_path, "w") as f: json.dump(json_dict, f, indent=4) @@ -324,10 +334,35 @@ def derive_from_legacy_images(tmp_path: Path): label_scale_yx = label.dataset.scale[-2:] assert scale_yx == label_scale_yx - # Test 2 derive label from level 1 + # Test 2 derive label from level 1: label level i matches image level i+1 image_1 = ome_zarr.get_image(path="1") ome_zarr.derive_label(name="my_label_level_1", ref_image=image_1) - for path_img, path_lbl in zip(["0", "1", "2"], ["1", "2", "3"], strict=True): + for path_img, path_lbl in zip(["1", "2", "3"], ["0", "1", "2"], strict=True): img = ome_zarr.get_image(path=path_img) lbl = ome_zarr.get_label(name="my_label_level_1", path=path_lbl) - assert img.shape == lbl.shape + assert img.shape[-2:] == lbl.shape[-2:] + + +def test_deprecated_scaling_factors_order(): + from ngio.images._create_utils import _check_deprecated_scaling_factors + from ngio.utils import NgioDeprecationWarning + + # canonical axes order is (..., z, y, x) + with pytest.warns(NgioDeprecationWarning): + factors = _check_deprecated_scaling_factors( + yx_scaling_factor=(2.0, 4.0), + z_scaling_factor=None, + scaling_factors="auto", + shape=(1, 4, 64, 64), + ) + assert factors == (1.0, 1.0, 2.0, 4.0) + + # 2D shape: factors must be trimmed to (y, x) + with pytest.warns(NgioDeprecationWarning): + factors = _check_deprecated_scaling_factors( + yx_scaling_factor=(2.0, 4.0), + z_scaling_factor=None, + scaling_factors="auto", + shape=(64, 64), + ) + assert factors == (2.0, 4.0) diff --git a/tests/unit/images/test_image_api.py b/tests/unit/images/test_image_api.py new file mode 100644 index 00000000..9425dcc0 --- /dev/null +++ b/tests/unit/images/test_image_api.py @@ -0,0 +1,170 @@ +"""Coverage tests for channel metadata and channel selection in ngio images.""" + +import numpy as np +import pytest +from pydantic import ValidationError +from zarr.storage import MemoryStore + +from ngio import create_empty_ome_zarr +from ngio.images import ChannelSelectionModel +from ngio.ome_zarr_meta.ngio_specs import Channel +from ngio.utils import NgioDeprecationWarning, NgioValueError + + +def _make_container(): + """Create a small two-channel OME-Zarr container in memory.""" + channels = [ + Channel.default_init(label="DAPI", wavelength_id="A01_C01"), + Channel.default_init(label="GFP", wavelength_id="A02_C02"), + ] + return create_empty_ome_zarr( + MemoryStore(), + shape=(2, 8, 8), + axes_names=("c", "y", "x"), + pixelsize=0.5, + levels=2, + channels_meta=channels, + ) + + +def test_channel_selection_model_index_must_be_int(): + with pytest.raises(ValidationError, match="must be an integer"): + ChannelSelectionModel(mode="index", identifier="not_an_int") + + +def test_images_container_levels_paths_deprecated(): + container = _make_container().images_container + with pytest.warns(NgioDeprecationWarning, match="levels_paths"): + paths = container.levels_paths + assert paths == container.level_paths + + +def test_set_channel_meta_default_init(): + ome_zarr = _make_container() + ome_zarr.set_channel_meta() + assert ome_zarr.channel_labels == ["channel_0", "channel_1"] + + +@pytest.mark.parametrize( + "kwargs, match", + [ + ({"start": [0.0, 0.0]}, "end must be provided"), + ({"end": [1.0, 1.0]}, "start must be provided"), + ( + { + "start": [0.0, 0.0], + "end": [1.0, 1.0], + "percentiles": (0.1, 99.9), + }, + "percentiles must be None", + ), + ({"start": [0.0], "end": [1.0, 2.0]}, "same length"), + ({"start": [0.0], "end": [1.0]}, "number of channels"), + ], +) +def test_set_channel_meta_legacy_errors(kwargs, match): + ome_zarr = _make_container() + with ( + pytest.warns(NgioDeprecationWarning, match="deprecated"), + pytest.raises(NgioValueError, match=match), + ): + ome_zarr.set_channel_meta(**kwargs) + + +def test_set_channel_meta_legacy_start_end(): + ome_zarr = _make_container() + with pytest.warns(NgioDeprecationWarning, match="deprecated"): + ome_zarr.set_channel_meta( + labels=["c1", "c2"], start=[0.0, 5.0], end=[10.0, 20.0] + ) + assert ome_zarr.channel_labels == ["c1", "c2"] + channels = ome_zarr.images_container.channels_meta.channels + assert channels[0].channel_visualisation.start == 0.0 + assert channels[0].channel_visualisation.end == 10.0 + assert channels[1].channel_visualisation.start == 5.0 + assert channels[1].channel_visualisation.end == 20.0 + + +def test_set_channel_meta_legacy_percentiles(): + ome_zarr = _make_container() + with pytest.warns(NgioDeprecationWarning, match="deprecated"): + ome_zarr.set_channel_meta(percentiles=(0.1, 99.9)) + assert ome_zarr.num_channels == 2 + + +def test_set_channel_labels_wrong_length(): + ome_zarr = _make_container() + with pytest.raises(NgioValueError, match="number of labels"): + ome_zarr.set_channel_labels(["only_one"]) + + +def test_set_channel_colors_wrong_length(): + ome_zarr = _make_container() + with pytest.raises(NgioValueError, match="number of colors"): + ome_zarr.set_channel_colors(["FF0000"]) + + +def test_images_container_set_channel_percentiles_deprecated(): + container = _make_container().images_container + with pytest.warns(NgioDeprecationWarning, match="set_channel_percentiles"): + container.set_channel_percentiles(start_percentile=1.0, end_percentile=99.0) + + +def test_set_channel_windows_wrong_lengths(): + ome_zarr = _make_container() + with pytest.raises(NgioValueError, match="start-end pairs"): + ome_zarr.set_channel_windows(starts_ends=[(0.0, 1.0)]) + with pytest.raises(NgioValueError, match="min-max pairs"): + ome_zarr.set_channel_windows( + starts_ends=[(0.0, 1.0), (0.0, 1.0)], min_max=[(0.0, 1.0)] + ) + + +def test_percentiles_input_validation(): + ome_zarr = _make_container() + # Tuple with wrong number of entries + with pytest.raises(NgioValueError, match="tuple of two floats"): + ome_zarr.set_channel_windows_with_percentiles(percentiles=(0.1, 50.0, 99.9)) + # Tuple with non-float entries + with pytest.raises(NgioValueError, match="tuple of two floats"): + ome_zarr.set_channel_windows_with_percentiles(percentiles=(1, 99)) + # List with a length different from the number of channels + with pytest.raises(NgioValueError, match="number of channels"): + ome_zarr.set_channel_windows_with_percentiles(percentiles=[(0.1, 99.9)]) + + +def test_channel_selection_negative_index(): + image = _make_container().get_image() + with pytest.raises(NgioValueError, match="non-negative"): + image.get_array(channel_selection=-1) + + +def test_channel_selection_index_out_of_range(): + image = _make_container().get_image() + with pytest.raises(NgioValueError, match="less than the number"): + image.get_array(channel_selection=5) + + +def test_channel_selection_by_wavelength_id(): + image = _make_container().get_image() + selection = ChannelSelectionModel(mode="wavelength_id", identifier="A02_C02") + array = image.get_array(channel_selection=selection) + np.testing.assert_array_equal(array, image.get_array(c=1)) + + +def test_channel_selection_invalid_type_in_sequence(): + image = _make_container().get_image() + with pytest.raises(NgioValueError, match="Invalid channel selection"): + image.get_array(channel_selection=[0.5]) + + +def test_channel_selection_invalid_type(): + image = _make_container().get_image() + with pytest.raises(NgioValueError, match="Invalid channel selection"): + image.get_array(channel_selection=1.5) + + +def test_channel_selection_ambiguous_with_c_kwarg(): + image = _make_container().get_image() + with pytest.raises(NgioValueError, match="ambiguous"): + image.get_array(channel_selection=0, c=0) diff --git a/tests/unit/images/test_images.py b/tests/unit/images/test_images.py index e106826d..a412e940 100644 --- a/tests/unit/images/test_images.py +++ b/tests/unit/images/test_images.py @@ -8,31 +8,8 @@ from ngio.utils import NgioValueError -@pytest.mark.parametrize( - "zarr_name", - [ - "v04/test_image_yx.zarr", - "v04/test_image_cyx.zarr", - "v04/test_image_zyx.zarr", - "v04/test_image_czyx.zarr", - "v04/test_image_c1yx.zarr", - "v04/test_image_tyx.zarr", - "v04/test_image_tcyx.zarr", - "v04/test_image_tzyx.zarr", - "v04/test_image_tczyx.zarr", - "v05/test_image_yx.zarr", - "v05/test_image_cyx.zarr", - "v05/test_image_zyx.zarr", - "v05/test_image_czyx.zarr", - "v05/test_image_c1yx.zarr", - "v05/test_image_tyx.zarr", - "v05/test_image_tcyx.zarr", - "v05/test_image_tzyx.zarr", - "v05/test_image_tczyx.zarr", - ], -) -def test_open_image(images_all_versions: dict[str, Path], zarr_name: str): - path = images_all_versions[zarr_name] +def test_open_image(images_all_versions_readonly: dict[str, Path], zarr_name: str): + path = images_all_versions_readonly[zarr_name] image = open_image(path) assert isinstance(image, Image) diff --git a/tests/unit/images/test_masked_images.py b/tests/unit/images/test_masked_images.py index 517b995a..e18593da 100644 --- a/tests/unit/images/test_masked_images.py +++ b/tests/unit/images/test_masked_images.py @@ -248,10 +248,10 @@ def test_masking_oneoff_handling(): ], ) def test_real_mask( - cardiomyocyte_small_mip_path: Path, label, c, zoom_factor, expected_shape + cardiomyocyte_small_mip_image_path: Path, label, c, zoom_factor, expected_shape ): # Test on a real example - path = cardiomyocyte_small_mip_path / "B" / "03" / "0" + path = cardiomyocyte_small_mip_image_path ome_zarr = open_ome_zarr_container(path) masked_image = ome_zarr.get_masked_image("nuclei") image_data = masked_image.get_roi_masked_as_numpy( diff --git a/tests/unit/images/test_omezarr_container.py b/tests/unit/images/test_omezarr_container.py index 82b5314b..aef83216 100644 --- a/tests/unit/images/test_omezarr_container.py +++ b/tests/unit/images/test_omezarr_container.py @@ -44,31 +44,10 @@ def set_as_dask_transform( return array -@pytest.mark.parametrize( - "zarr_name", - [ - "v04/test_image_yx.zarr", - "v04/test_image_cyx.zarr", - "v04/test_image_zyx.zarr", - "v04/test_image_czyx.zarr", - "v04/test_image_c1yx.zarr", - "v04/test_image_tyx.zarr", - "v04/test_image_tcyx.zarr", - "v04/test_image_tzyx.zarr", - "v04/test_image_tczyx.zarr", - "v05/test_image_yx.zarr", - "v05/test_image_cyx.zarr", - "v05/test_image_zyx.zarr", - "v05/test_image_czyx.zarr", - "v05/test_image_c1yx.zarr", - "v05/test_image_tyx.zarr", - "v05/test_image_tcyx.zarr", - "v05/test_image_tzyx.zarr", - "v05/test_image_tczyx.zarr", - ], -) -def test_open_ome_zarr_container(images_all_versions: dict[str, Path], zarr_name: str): - path = images_all_versions[zarr_name] +def test_open_ome_zarr_container( + images_all_versions_readonly: dict[str, Path], zarr_name: str +): + path = images_all_versions_readonly[zarr_name] ome_zarr = open_ome_zarr_container(path) whole_image_roi = ome_zarr.build_image_roi_table().get("image") @@ -249,6 +228,7 @@ def test_create_ome_zarr_container(tmp_path: Path, array_mode: str): assert masked_label.shape == image.shape +@pytest.mark.network def test_remote_ome_zarr_container(): url = ( "https://raw.githubusercontent.com/" @@ -261,12 +241,6 @@ def test_remote_ome_zarr_container(): ome_zarr = open_ome_zarr_container(store) assert ome_zarr.list_labels() == ["nuclei"] - # assert ome_zarr.list_tables() == [ - # "FOV_ROI_table", - # "nuclei_ROI_table", - # "well_ROI_table", - # "regionprops_DAPI", - # ] _ = ome_zarr.get_label("nuclei", path="0") _ = ome_zarr.get_table("well_ROI_table").dataframe diff --git a/tests/unit/images/test_table_ops.py b/tests/unit/images/test_table_ops.py index a8c790da..26700a8a 100644 --- a/tests/unit/images/test_table_ops.py +++ b/tests/unit/images/test_table_ops.py @@ -33,9 +33,19 @@ def create_sample_ome_zarr( return ome_zarr_container -def test_list_sync_api(tmp_path: Path): +@pytest.fixture(scope="module") +def sample_ome_zarrs( + tmp_path_factory: pytest.TempPathFactory, +) -> tuple[OmeZarrContainer, OmeZarrContainer]: + """Two containers with tables, shared by all (read-only) tests here.""" + tmp_path = tmp_path_factory.mktemp("table_ops") ome_zarr_1 = create_sample_ome_zarr(tmp_path, "test1", ["table1", "table2"]) ome_zarr_2 = create_sample_ome_zarr(tmp_path, "test2", ["table1"]) + return ome_zarr_1, ome_zarr_2 + + +def test_list_sync_api(sample_ome_zarrs): + ome_zarr_1, ome_zarr_2 = sample_ome_zarrs assert list_image_tables([ome_zarr_1, ome_zarr_2], mode="common") == ["table1"] assert list_image_tables([ome_zarr_1, ome_zarr_2], mode="all") == [ @@ -44,9 +54,8 @@ def test_list_sync_api(tmp_path: Path): ] -def test_list_async_api(tmp_path: Path): - ome_zarr_1 = create_sample_ome_zarr(tmp_path, "test1", ["table1", "table2"]) - ome_zarr_2 = create_sample_ome_zarr(tmp_path, "test2", ["table1"]) +def test_list_async_api(sample_ome_zarrs): + ome_zarr_1, ome_zarr_2 = sample_ome_zarrs assert asyncio.run( list_image_tables_async([ome_zarr_1, ome_zarr_2], mode="common") @@ -69,10 +78,9 @@ def test_list_async_api(tmp_path: Path): ], ) def test_cat_sync_api( - tmp_path: Path, table: str, mode: Literal["eager", "lazy"], strict: bool + sample_ome_zarrs, table: str, mode: Literal["eager", "lazy"], strict: bool ): - ome_zarr_1 = create_sample_ome_zarr(tmp_path, "test1", ["table1", "table2"]) - ome_zarr_2 = create_sample_ome_zarr(tmp_path, "test2", ["table1"]) + ome_zarr_1, ome_zarr_2 = sample_ome_zarrs extras1 = {"column1": "value1"} extras2 = {"column1": "value2"} @@ -105,9 +113,8 @@ def test_cat_sync_api( assert df.shape == (6, 4), df.shape -def test_cat_as_sync(tmp_path: Path): - ome_zarr_1 = create_sample_ome_zarr(tmp_path, "test1", ["table1", "table2"]) - ome_zarr_2 = create_sample_ome_zarr(tmp_path, "test2", ["table1"]) +def test_cat_as_sync(sample_ome_zarrs): + ome_zarr_1, ome_zarr_2 = sample_ome_zarrs extras1 = {"column1": "value1"} extras2 = {"column1": "value2"} @@ -122,9 +129,8 @@ def test_cat_as_sync(tmp_path: Path): assert isinstance(concatenated_table, GenericTable) -def test_set_index(tmp_path: Path): - ome_zarr_1 = create_sample_ome_zarr(tmp_path, "test1", ["table1", "table2"]) - ome_zarr_2 = create_sample_ome_zarr(tmp_path, "test2", ["table1"]) +def test_set_index(sample_ome_zarrs): + ome_zarr_1, ome_zarr_2 = sample_ome_zarrs extras1 = {"column1": "value1"} extras2 = {"column1": "value2"} @@ -141,9 +147,8 @@ def test_set_index(tmp_path: Path): assert df.index.name == "Index" -def test_cat_async_api(tmp_path: Path): - ome_zarr_1 = create_sample_ome_zarr(tmp_path, "test1", ["table1", "table2"]) - ome_zarr_2 = create_sample_ome_zarr(tmp_path, "test2", ["table1"]) +def test_cat_async_api(sample_ome_zarrs): + ome_zarr_1, ome_zarr_2 = sample_ome_zarrs extras1 = {"column1": "value1"} extras2 = {"column1": "value2"} @@ -171,3 +176,44 @@ def test_cat_async_api(tmp_path: Path): ) ) assert isinstance(concatenate_table, GenericTable) + + +def test_cat_eager_lazy_index_parity(sample_ome_zarrs): + ome_zarr_1, ome_zarr_2 = sample_ome_zarrs + + extras = [{"column1": "value1"}, {"column1": "value2"}] + eager_df = concatenate_image_tables_as( + [ome_zarr_1, ome_zarr_2], + extras=extras, + name="table1", + table_cls=GenericTable, + index_key="Index", + mode="eager", + ).dataframe + lazy_df = concatenate_image_tables_as( + [ome_zarr_1, ome_zarr_2], + extras=extras, + name="table1", + table_cls=GenericTable, + index_key="Index", + mode="lazy", + ).dataframe + + assert eager_df.index.name == "Index" + assert lazy_df.index.name == "Index" + # every row must get a unique index derived from extras + original index + assert eager_df.index.is_unique + assert lazy_df.index.is_unique + assert sorted(eager_df.index) == sorted(lazy_df.index) + + async_lazy_df = asyncio.run( + concatenate_image_tables_as_async( + [ome_zarr_1, ome_zarr_2], + extras=extras, + name="table1", + table_cls=GenericTable, + index_key="Index", + mode="lazy", + ) + ).dataframe + assert sorted(async_lazy_df.index) == sorted(eager_df.index) diff --git a/tests/unit/io_pipes/test_ops_errors.py b/tests/unit/io_pipes/test_ops_errors.py new file mode 100644 index 00000000..e61e4896 --- /dev/null +++ b/tests/unit/io_pipes/test_ops_errors.py @@ -0,0 +1,263 @@ +import numpy as np +import pytest +from zarr.storage import MemoryStore + +from ngio import Roi, create_empty_ome_zarr +from ngio.common import Dimensions +from ngio.io_pipes._match_shape import numpy_match_shape +from ngio.io_pipes._ops_slices import build_slicing_ops +from ngio.io_pipes._zoom_transform import BaseZoomTransform +from ngio.ome_zarr_meta import AxesHandler, Dataset +from ngio.ome_zarr_meta.ngio_specs import AxesSetup, Axis +from ngio.transforms import ZoomTransform +from ngio.utils import NgioValueError + + +def _make_dims( + axes: list[str], + shape: tuple[int, ...], + axes_setup: AxesSetup | None = None, +) -> Dimensions: + axes_list = [Axis(name=name) for name in axes] + handler = AxesHandler(axes=axes_list, axes_setup=axes_setup) + dataset = Dataset( + path="0", + axes_handler=handler, + scale=[1.0] * len(axes_list), + translation=[0.0] * len(axes_list), + ) + return Dimensions(shape=shape, chunks=shape, dataset=dataset) + + +############################################################## +# _match_shape.py: _check_axes error branches +############################################################## + + +def test_match_shape_array_axes_length_mismatch(): + with pytest.raises(NgioValueError, match="same number of dimensions"): + numpy_match_shape( + array=np.zeros((2, 2)), + reference_shape=(2, 2), + array_axes=["z", "y", "x"], + reference_axes=["y", "x"], + ) + + +def test_match_shape_reference_axes_length_mismatch(): + with pytest.raises(NgioValueError, match="same number of dimensions"): + numpy_match_shape( + array=np.zeros((2, 2)), + reference_shape=(2, 2, 2), + array_axes=["y", "x"], + reference_axes=["y", "x"], + ) + + +def test_match_shape_axes_not_subset(): + with pytest.raises(NgioValueError, match="not a subset"): + numpy_match_shape( + array=np.zeros((2, 2)), + reference_shape=(2, 2), + array_axes=["w", "x"], + reference_axes=["y", "x"], + ) + + +def test_match_shape_more_dims_than_reference(): + # Duplicated axis names make the subset check pass while the array + # still has more dimensions than the reference + with pytest.raises(NgioValueError, match="more dimensions"): + numpy_match_shape( + array=np.zeros((2, 2)), + reference_shape=(2,), + array_axes=["x", "x"], + reference_axes=["x"], + ) + + +############################################################## +# _zoom_transform.py: _normalize_shape and dask set transform +############################################################## + + +def test_zoom_normalize_shape_branches(): + dims = _make_dims(["y", "x"], (8, 8)) + zoom = BaseZoomTransform(input_dimensions=dims, target_dimensions=dims) + + # Integer slicing selects a single element regardless of the scale + assert zoom._normalize_shape(slice_=3, scale=2.0, max_dim=10) == 1 + # Open-ended slice: the stop defaults to the axis size + assert zoom._normalize_shape(slice_=slice(2, None), scale=1.0, max_dim=10) == 8 + # List slicing scales with the number of selected elements + assert zoom._normalize_shape(slice_=[0, 1, 2], scale=2.0, max_dim=10) == 6 + with pytest.raises(ValueError, match="Unsupported slice type"): + zoom._normalize_shape(slice_="bad", scale=1.0, max_dim=10) # ty: ignore[invalid-argument-type] + + +def test_zoom_set_as_dask_transform(): + target_img = create_empty_ome_zarr( + store=MemoryStore(), + shape=(100, 100), + axes_names="yx", + pixelsize=1.0, + levels=1, + ).get_image() + input_img = create_empty_ome_zarr( + store=MemoryStore(), + shape=(50, 50), + axes_names="yx", + pixelsize=2.0, + levels=1, + ).get_image() + + zoom = ZoomTransform(input_image=input_img, target_image=target_img) + roi = Roi.from_values(name=None, slices={"x": (10, 30), "y": (10, 30)}) + + target_data = target_img.get_roi_as_dask(roi=roi) + rescaled = input_img.get_roi_as_dask(roi=roi, transforms=[zoom]) + assert target_data.shape == rescaled.shape + # Setting a dask patch triggers the inverse (set) dask zoom transform + input_img.set_roi(roi=roi, patch=rescaled, transforms=[zoom]) + + # Same round trip with a numpy patch for the numpy set transform + rescaled_np = input_img.get_roi_as_numpy(roi=roi, transforms=[zoom]) + assert target_data.shape == rescaled_np.shape + input_img.set_roi(roi=roi, patch=rescaled_np, transforms=[zoom]) + + +############################################################## +# _ops_slices.py: error branches and slicing normalization +############################################################## + + +def test_slicing_int_out_of_bounds(): + dims = _make_dims(["c", "y", "x"], (3, 8, 8)) + + ops = build_slicing_ops(dimensions=dims, slicing_dict={"y": 100}) + with pytest.raises(NgioValueError, match="out of bounds"): + _ = ops.normalized_slicing_tuple + + ops = build_slicing_ops(dimensions=dims, slicing_dict={"y": -1}) + with pytest.raises(NgioValueError, match="out of bounds"): + _ = ops.normalized_slicing_tuple + + # Out-of-bounds index inside a non-contiguous list selection + ops = build_slicing_ops(dimensions=dims, slicing_dict={"y": (0, 100)}) + with pytest.raises(NgioValueError, match="out of bounds"): + _ = ops.normalized_slicing_tuple + + +def test_slicing_empty_sequence(): + dims = _make_dims(["y", "x"], (8, 8)) + with pytest.raises(NgioValueError, match="empty sequences"): + build_slicing_ops(dimensions=dims, slicing_dict={"y": ()}) + + +def test_slicing_sequence_of_numpy_ints(): + dims = _make_dims(["c", "y", "x"], (3, 8, 8)) + # numpy integers are not `int` instances and must be coerced + ops = build_slicing_ops( + dimensions=dims, + slicing_dict={"c": (np.uint8(0), np.uint8(2))}, # ty: ignore[invalid-argument-type] + ) + assert ops.slicing_tuple[0] == [0, 2] + + # Contiguous numpy integers are converted to a slice + ops = build_slicing_ops( + dimensions=dims, + slicing_dict={"c": (np.uint8(0), np.uint8(1))}, # ty: ignore[invalid-argument-type] + ) + assert ops.slicing_tuple[0] == slice(0, 2) + + +def test_slicing_sequence_of_invalid_values(): + dims = _make_dims(["y", "x"], (8, 8)) + with pytest.raises(NgioValueError, match="Invalid value"): + build_slicing_ops(dimensions=dims, slicing_dict={"y": ("a", "b")}) # ty: ignore[invalid-argument-type] + + +def test_slicing_channel_selection_kept_on_multichannel(): + dims = _make_dims(["c", "y", "x"], (3, 8, 8)) + ops = build_slicing_ops( + dimensions=dims, + slicing_dict={"c": 0}, + remove_channel_selection=True, + ) + # Multi-channel images keep the channel selection + assert ops.slicing_tuple[0] == 0 + + +def test_slicing_channel_selection_removed_on_singleton(): + dims = _make_dims(["c", "y", "x"], (1, 8, 8)) + ops = build_slicing_ops( + dimensions=dims, + slicing_dict={"c": 0}, + remove_channel_selection=True, + ) + # Singleton channel axis: the channel selection is dropped + assert ops.slicing_tuple[0] == slice(None) + + +@pytest.mark.parametrize( + "slice_", + [ + slice(0, None), + slice(None, 0), + [0], + None, + 0, + slice(None), + slice(0, 1), + ], +) +def test_slicing_valid_virtual_axis(slice_): + dims = _make_dims(["y", "x"], (8, 8)) + # "c" is not an on-disk axis, but trivial selections are allowed + ops = build_slicing_ops(dimensions=dims, slicing_dict={"c": slice_}) + assert ops.slicing_tuple == (slice(None), slice(None)) + + +@pytest.mark.parametrize("slice_", [5, slice(1, 3), [1], [0, 1]]) +def test_slicing_invalid_virtual_axis(slice_): + dims = _make_dims(["y", "x"], (8, 8)) + with pytest.raises(NgioValueError, match="Invalid axis selection"): + build_slicing_ops(dimensions=dims, slicing_dict={"c": slice_}) + + +def test_slicing_duplicate_axis(): + # "channel" is the on-disk name for the canonical "c" axis, so both + # names resolve to the same axis + dims = _make_dims( + ["channel", "y", "x"], + (3, 8, 8), + axes_setup=AxesSetup(c="channel"), + ) + with pytest.raises(NgioValueError, match="Duplicate axis"): + build_slicing_ops( + dimensions=dims, + slicing_dict={"channel": 0, "c": 0}, + ) + + +def test_slicing_explicit_none_value(): + dims = _make_dims(["y", "x"], (8, 8)) + ops = build_slicing_ops(dimensions=dims, slicing_dict={"y": None}) + assert ops.slicing_tuple == (slice(None), slice(None)) + # Querying an axis that is not on disk returns a full slice + assert ops.get("t") == slice(None) + + +def test_slicing_invalid_value_type(): + dims = _make_dims(["y", "x"], (8, 8)) + with pytest.raises(NgioValueError, match="Invalid slice definition"): + build_slicing_ops(dimensions=dims, slicing_dict={"y": 1.5}) # ty: ignore[invalid-argument-type] + + +def test_negative_value_in_slicing_sequence_raises_ngio_error(): + dims = _make_dims(["y", "x"], (10, 10)) + with pytest.raises(NgioValueError): + build_slicing_ops( + dimensions=dims, + slicing_dict={"y": [-1, 0]}, + ) diff --git a/tests/unit/io_pipes/test_slicing_ops.py b/tests/unit/io_pipes/test_slicing_ops.py index 69a1061a..bd40a015 100644 --- a/tests/unit/io_pipes/test_slicing_ops.py +++ b/tests/unit/io_pipes/test_slicing_ops.py @@ -9,7 +9,10 @@ set_slice_as_dask, set_slice_as_numpy, ) -from ngio.io_pipes._ops_slices_utils import check_if_regions_overlap +from ngio.io_pipes._ops_slices_utils import ( + check_elem_intersection, + check_if_regions_overlap, +) from ngio.ome_zarr_meta import AxesHandler, Dataset from ngio.ome_zarr_meta.ngio_specs import Axis @@ -110,3 +113,10 @@ def test_chunk_slice(): assert check_if_regions_overlap( [slicing_ops1.normalized_slicing_tuple, slicing_ops2.normalized_slicing_tuple] ) + + +def test_check_elem_intersection_step_not_implemented(): + with pytest.raises(NotImplementedError): + check_elem_intersection(slice(0, 5, 2), slice(1, 3)) + with pytest.raises(NotImplementedError): + check_elem_intersection(slice(1, 3), slice(0, 5, 2)) diff --git a/tests/unit/iterators/test_feature_iterator.py b/tests/unit/iterators/test_feature_iterator.py new file mode 100644 index 00000000..dc2c99aa --- /dev/null +++ b/tests/unit/iterators/test_feature_iterator.py @@ -0,0 +1,75 @@ +import dask.array as da +import numpy as np +from zarr.storage import MemoryStore + +from ngio import create_ome_zarr_from_array +from ngio.experimental.iterators import FeatureExtractorIterator + + +def _build_iterator() -> FeatureExtractorIterator: + rng = np.random.default_rng(0) + array = rng.integers(0, 255, size=(2, 16, 16)).astype("uint8") + ome_zarr = create_ome_zarr_from_array( + store=MemoryStore(), + array=array, + pixelsize=1.0, + axes_names="cyx", + levels=1, + ) + label = ome_zarr.derive_label(name="label") + image = ome_zarr.get_image() + iterator = FeatureExtractorIterator( + input_image=image, + input_label=label, + channel_selection=0, + axes_order="yx", + ) + return iterator.by_yx() + + +def test_feature_iterator_numpy(): + iterator = _build_iterator() + + n_items = 0 + for data, seg, roi in iterator.iter_as_numpy(): + assert isinstance(data, np.ndarray) + assert isinstance(seg, np.ndarray) + assert data.shape == seg.shape + assert roi is not None + n_items += 1 + assert n_items == len(iterator.rois) + + # Lazy iteration yields the getter objects with image/label properties + for getter in iterator.iter(lazy=True, data_mode="numpy", iterator_mode="readonly"): + assert isinstance(getter.image, np.ndarray) # ty: ignore[unresolved-attribute] + assert isinstance(getter.label, np.ndarray) # ty: ignore[unresolved-attribute] + assert getter.image.shape == getter.label.shape # ty: ignore[unresolved-attribute] + + +def test_feature_iterator_dask(): + iterator = _build_iterator() + + n_items = 0 + for data, seg, roi in iterator.iter_as_dask(): + assert isinstance(data, da.Array) + assert isinstance(seg, da.Array) + assert data.shape == seg.shape + assert roi is not None + n_items += 1 + assert n_items == len(iterator.rois) + + # Lazy iteration yields the getter objects with image/label properties + for getter in iterator.iter(lazy=True, data_mode="dask", iterator_mode="readonly"): + assert isinstance(getter.image, da.Array) # ty: ignore[unresolved-attribute] + assert isinstance(getter.label, da.Array) # ty: ignore[unresolved-attribute] + assert getter.image.shape == getter.label.shape # ty: ignore[unresolved-attribute] + + +def test_feature_iterator_is_readonly(): + iterator = _build_iterator() + + # The feature extractor is a read-only iterator: no setters are built + roi = iterator.rois[0] + assert iterator.build_numpy_setter(roi) is None + assert iterator.build_dask_setter(roi) is None + assert iterator.post_consolidate() is None diff --git a/tests/unit/iterators/test_iterators.py b/tests/unit/iterators/test_iterators.py index c21e8bb4..1ea63639 100644 --- a/tests/unit/iterators/test_iterators.py +++ b/tests/unit/iterators/test_iterators.py @@ -14,33 +14,22 @@ ) from ngio.utils import NgioValueError - -@pytest.mark.parametrize( - "zarr_name", - [ - "v04/test_image_yx.zarr", - "v04/test_image_cyx.zarr", - "v04/test_image_zyx.zarr", - "v04/test_image_czyx.zarr", - "v04/test_image_c1yx.zarr", - "v04/test_image_tyx.zarr", - "v04/test_image_tcyx.zarr", - "v04/test_image_tzyx.zarr", - "v04/test_image_tczyx.zarr", - "v05/test_image_yx.zarr", - "v05/test_image_cyx.zarr", - "v05/test_image_zyx.zarr", - "v05/test_image_czyx.zarr", - "v05/test_image_c1yx.zarr", - "v05/test_image_tyx.zarr", - "v05/test_image_tcyx.zarr", - "v05/test_image_tzyx.zarr", - "v05/test_image_tczyx.zarr", - ], -) -def test_segmentation_iterator(images_all_versions: dict[str, Path], zarr_name: str): +# The iterators run through the version-agnostic container API, so the +# expensive write tests cover the full axes matrix on v05 only, plus a v04 +# smoke subset; the full v04 write path is monitored by creation/store tests. +WRITER_ZARR_NAMES = [ + f"v05/test_image_{axes}.zarr" + for axes in ("yx", "cyx", "zyx", "czyx", "c1yx", "tyx", "tcyx", "tzyx", "tczyx") +] + [ + "v04/test_image_yx.zarr", + "v04/test_image_tczyx.zarr", +] + + +@pytest.mark.parametrize("zarr_name", WRITER_ZARR_NAMES) +def test_segmentation_iterator(single_image_copy: Path): # Base test only the API, not the actual segmentation logic - path = images_all_versions[zarr_name] + path = single_image_copy ome_zarr = open_ome_zarr_container(path) image = ome_zarr.get_image() label = ome_zarr.get_label("label") @@ -75,34 +64,10 @@ def test_segmentation_iterator(images_all_versions: dict[str, Path], zarr_name: iterator.map_as_dask(lambda x: da.zeros_like(x, dtype=np.uint8)) -@pytest.mark.parametrize( - "zarr_name", - [ - "v04/test_image_yx.zarr", - "v04/test_image_cyx.zarr", - "v04/test_image_zyx.zarr", - "v04/test_image_czyx.zarr", - "v04/test_image_c1yx.zarr", - "v04/test_image_tyx.zarr", - "v04/test_image_tcyx.zarr", - "v04/test_image_tzyx.zarr", - "v04/test_image_tczyx.zarr", - "v05/test_image_yx.zarr", - "v05/test_image_cyx.zarr", - "v05/test_image_zyx.zarr", - "v05/test_image_czyx.zarr", - "v05/test_image_c1yx.zarr", - "v05/test_image_tyx.zarr", - "v05/test_image_tcyx.zarr", - "v05/test_image_tzyx.zarr", - "v05/test_image_tczyx.zarr", - ], -) -def test_masked_segmentation_iterator( - images_all_versions: dict[str, Path], zarr_name: str -): +@pytest.mark.parametrize("zarr_name", WRITER_ZARR_NAMES) +def test_masked_segmentation_iterator(single_image_copy: Path): # Base test only the API, not the actual segmentation logic - path = images_all_versions[zarr_name] + path = single_image_copy ome_zarr = open_ome_zarr_container(path) masked_label = ome_zarr.derive_label("masking_label") @@ -134,32 +99,11 @@ def test_masked_segmentation_iterator( iterator.map_as_dask(lambda x: da.zeros_like(x, dtype=np.uint8)) -@pytest.mark.parametrize( - "zarr_name", - [ - "v04/test_image_yx.zarr", - "v04/test_image_cyx.zarr", - "v04/test_image_zyx.zarr", - "v04/test_image_czyx.zarr", - "v04/test_image_c1yx.zarr", - "v04/test_image_tyx.zarr", - "v04/test_image_tcyx.zarr", - "v04/test_image_tzyx.zarr", - "v04/test_image_tczyx.zarr", - "v05/test_image_yx.zarr", - "v05/test_image_cyx.zarr", - "v05/test_image_zyx.zarr", - "v05/test_image_czyx.zarr", - "v05/test_image_c1yx.zarr", - "v05/test_image_tyx.zarr", - "v05/test_image_tcyx.zarr", - "v05/test_image_tzyx.zarr", - "v05/test_image_tczyx.zarr", - ], -) -def test_img_processing_iterator(images_all_versions: dict[str, Path], zarr_name: str): +def test_img_processing_iterator( + images_all_versions_readonly: dict[str, Path], zarr_name: str +): # Base test only the API, not the actual segmentation logic - path = images_all_versions[zarr_name] + path = images_all_versions_readonly[zarr_name] ome_zarr = open_ome_zarr_container(path) image = ome_zarr.get_image() t_ome_zarr = ome_zarr.derive_image(store=MemoryStore()) @@ -189,32 +133,11 @@ def test_img_processing_iterator(images_all_versions: dict[str, Path], zarr_name iterator.map_as_dask(lambda x: da.zeros_like(x, dtype=np.uint8)) -@pytest.mark.parametrize( - "zarr_name", - [ - "v04/test_image_yx.zarr", - "v04/test_image_cyx.zarr", - "v04/test_image_zyx.zarr", - "v04/test_image_czyx.zarr", - "v04/test_image_c1yx.zarr", - "v04/test_image_tyx.zarr", - "v04/test_image_tcyx.zarr", - "v04/test_image_tzyx.zarr", - "v04/test_image_tczyx.zarr", - "v05/test_image_yx.zarr", - "v05/test_image_cyx.zarr", - "v05/test_image_zyx.zarr", - "v05/test_image_czyx.zarr", - "v05/test_image_c1yx.zarr", - "v05/test_image_tyx.zarr", - "v05/test_image_tcyx.zarr", - "v05/test_image_tzyx.zarr", - "v05/test_image_tczyx.zarr", - ], -) -def test_features_iterator(images_all_versions: dict[str, Path], zarr_name: str): +def test_features_iterator( + images_all_versions_readonly: dict[str, Path], zarr_name: str +): # Base test only the API, not the actual segmentation logic - path = images_all_versions[zarr_name] + path = images_all_versions_readonly[zarr_name] ome_zarr = open_ome_zarr_container(path) image = ome_zarr.get_image() diff --git a/tests/unit/iterators/test_rois_utils.py b/tests/unit/iterators/test_rois_utils.py new file mode 100644 index 00000000..de826eea --- /dev/null +++ b/tests/unit/iterators/test_rois_utils.py @@ -0,0 +1,47 @@ +import numpy as np +import pytest +from zarr.storage import MemoryStore + +from ngio import Roi, create_ome_zarr_from_array +from ngio.experimental.iterators._rois_utils import by_chunks, grid +from ngio.utils import NgioValueError + + +@pytest.fixture +def sample_image(): + container = create_ome_zarr_from_array( + store=MemoryStore(), + array=np.zeros((64, 64), dtype="uint8"), + pixelsize=1.0, + axes_names=["y", "x"], + levels=1, + ) + return container.get_image() + + +def full_image_roi() -> Roi: + return Roi.from_values( + name="image", slices={"x": (0, 64), "y": (0, 64)}, space="world" + ) + + +def test_grid_rois_have_unique_names(sample_image): + tiles = grid( + rois=[full_image_roi()], + ref_image=sample_image, + size_x=32, + size_y=32, + ) + assert len(tiles) == 4 + names = [tile.name for tile in tiles] + assert len(set(names)) == len(names) + + +def test_by_chunks_overlap_equal_to_chunk_size_raises(sample_image): + chunk_x = sample_image.chunks[-1] + with pytest.raises(NgioValueError): + by_chunks( + rois=[full_image_roi()], + ref_image=sample_image, + overlap_xy=chunk_x, + ) diff --git a/tests/unit/ome_zarr_meta/test_meta_autodetect.py b/tests/unit/ome_zarr_meta/test_meta_autodetect.py new file mode 100644 index 00000000..98c37fd3 --- /dev/null +++ b/tests/unit/ome_zarr_meta/test_meta_autodetect.py @@ -0,0 +1,88 @@ +"""Guard the version-autodetect trial-decode loop in _meta_handlers. + +get_ngio_meta with version=None tries every registered decoder and returns +the first that succeeds. This is only safe while the v0.4 and v0.5 decoders +are mutually exclusive: each must reject the other version's attributes. +""" + +import numpy as np +import pytest +from zarr.storage import MemoryStore + +from ngio import create_empty_plate, create_ome_zarr_from_array +from ngio.hcs import create_empty_well +from ngio.ome_zarr_meta._meta_handlers import ( + _image_decoder_registry, + _plate_decoder_registry, + _well_decoder_registry, +) +from ngio.utils import ZarrGroupHandler + + +def _image_attrs(version: str) -> dict: + store = MemoryStore() + create_ome_zarr_from_array( + store=store, + array=np.zeros((16, 16), dtype="uint8"), + pixelsize=1.0, + axes_names=["y", "x"], + levels=1, + ngff_version=version, + ) + return ZarrGroupHandler(store=store, mode="r").load_attrs() + + +def _plate_attrs(version: str) -> dict: + store = MemoryStore() + create_empty_plate(store=store, name="plate", ngff_version=version) + return ZarrGroupHandler(store=store, mode="r").load_attrs() + + +def _well_attrs(version: str) -> dict: + store = MemoryStore() + create_empty_well(store=store, ngff_version=version) + return ZarrGroupHandler(store=store, mode="r").load_attrs() + + +def _assert_rejected(decoder, attrs, **kwargs): + """Assert that a decoder refuses the given attrs. + + The autodetect loop treats any exception as "not this version", so the + exact exception type is deliberately not part of the contract. + """ + try: + decoder(attrs, **kwargs) + except Exception: + return + pytest.fail("decoder unexpectedly accepted attrs of another version") + + +@pytest.mark.parametrize("attrs_version", ["0.4", "0.5"]) +def test_image_decoders_are_version_exclusive(attrs_version): + attrs = _image_attrs(attrs_version) + for decoder_version, decoder in _image_decoder_registry.items(): + if decoder_version == attrs_version: + meta = decoder(attrs, axes_setup=None) + assert meta.version == attrs_version + else: + _assert_rejected(decoder, attrs, axes_setup=None) + + +@pytest.mark.parametrize("attrs_version", ["0.4", "0.5"]) +def test_plate_decoders_are_version_exclusive(attrs_version): + attrs = _plate_attrs(attrs_version) + for decoder_version, decoder in _plate_decoder_registry.items(): + if decoder_version == attrs_version: + assert decoder(attrs).version == attrs_version + else: + _assert_rejected(decoder, attrs) + + +@pytest.mark.parametrize("attrs_version", ["0.4", "0.5"]) +def test_well_decoders_are_version_exclusive(attrs_version): + attrs = _well_attrs(attrs_version) + for decoder_version, decoder in _well_decoder_registry.items(): + if decoder_version == attrs_version: + assert decoder(attrs).version == attrs_version + else: + _assert_rejected(decoder, attrs) diff --git a/tests/unit/ome_zarr_meta/test_ngio_hcs_specs.py b/tests/unit/ome_zarr_meta/test_ngio_hcs_specs.py new file mode 100644 index 00000000..25cbf740 --- /dev/null +++ b/tests/unit/ome_zarr_meta/test_ngio_hcs_specs.py @@ -0,0 +1,61 @@ +"""Unit tests for the plate/well metadata models in ngio_specs._ngio_hcs. + +These focus on the functional API contract: add/remove operations must +return an updated copy and leave the receiver untouched. +""" + +from ngio.ome_zarr_meta.ngio_specs import NgioPlateMeta, NgioWellMeta + + +def test_well_add_image_does_not_mutate_receiver(): + well = NgioWellMeta.default_init() + well2 = well.add_image(path="0") + + assert well.paths() == [] + assert well2.paths() == ["0"] + + +def test_well_remove_image_does_not_mutate_receiver(): + well = NgioWellMeta.default_init().add_image(path="0").add_image(path="1") + well2 = well.remove_image(path="0") + + assert well.paths() == ["0", "1"] + assert well2.paths() == ["1"] + + +def test_plate_add_well_does_not_mutate_receiver(): + plate = NgioPlateMeta.default_init() + plate2 = plate.add_well(row="A", column=1) + + assert plate.wells_paths == [] + assert plate2.wells_paths == ["A/01"] + + +def test_plate_add_well_existing_row_column_does_not_mutate_receiver(): + # Removing a well keeps its row/column, so re-adding takes the + # short-circuit path in add_row/add_column. + plate = NgioPlateMeta.default_init().add_well(row="A", column=1) + plate_empty = plate.remove_well(row="A", column=1) + + assert plate.wells_paths == ["A/01"] + assert plate_empty.wells_paths == [] + + plate_again = plate_empty.add_well(row="A", column=1) + assert plate_empty.wells_paths == [] + assert plate_again.wells_paths == ["A/01"] + + +def test_plate_add_acquisition_does_not_mutate_receiver(): + plate = NgioPlateMeta.default_init().add_acquisition(acquisition_id=0) + plate2 = plate.add_acquisition(acquisition_id=1) + + assert plate.acquisition_ids == [0] + assert plate2.acquisition_ids == [0, 1] + + +def test_plate_remove_well_does_not_mutate_receiver(): + plate = NgioPlateMeta.default_init().add_well(row="A", column=1) + plate2 = plate.remove_well(row="A", column=1) + + assert plate.wells_paths == ["A/01"] + assert plate2.wells_paths == [] diff --git a/tests/unit/ome_zarr_meta/test_unit_ngio_specs.py b/tests/unit/ome_zarr_meta/test_unit_ngio_specs.py index cb27a4e1..600ee0b7 100644 --- a/tests/unit/ome_zarr_meta/test_unit_ngio_specs.py +++ b/tests/unit/ome_zarr_meta/test_unit_ngio_specs.py @@ -458,3 +458,42 @@ def test_channels_label_meta(): scale = [s * f for s, f in zip(scale, [1, 1, 1, 2, 2], strict=True)] _ = NgioLabelMeta(version="0.4", name="test", datasets=datasets) + + +def test_pixel_size_eq_with_non_pixel_size(): + ps = PixelSize(x=1.0, y=1.0, z=1.0) + assert ps != "not a pixel size" + assert ps != None # noqa: E711 + assert ps not in [1, "a", None] + + +def test_pixel_size_eq_time_unit_symmetry(): + ps_no_time = PixelSize(x=1.0, y=1.0, z=1.0, time_unit=None) + ps_time = PixelSize(x=1.0, y=1.0, z=1.0, time_unit=DefaultTimeUnit) + + assert (ps_no_time == ps_time) == (ps_time == ps_no_time) + assert ps_time != ps_no_time + + +def test_axes_setup_from_ordered_list(): + canonical = ("t", "c", "z", "y", "x") + + setup = AxesSetup.from_ordered_list(["z", "y", "x"], canonical) + assert (setup.z, setup.y, setup.x) == ("z", "y", "x") + + # all non-canonical: right-aligned slot assignment + setup = AxesSetup.from_ordered_list(["a", "b"], canonical) + assert setup.x == "b" + assert setup.y == "a" + + # non-canonical name to the left of the canonical ones + setup = AxesSetup.from_ordered_list(["custom", "z", "y", "x"], canonical) + assert (setup.z, setup.y, setup.x) == ("z", "y", "x") + assert setup.c == "custom" + + # canonical name to the left of a non-canonical one: the custom axis + # must not be silently dropped + setup = AxesSetup.from_ordered_list(["z", "custom", "y", "x"], canonical) + values = [setup.t, setup.c, setup.z, setup.y, setup.x] + assert "custom" in values + assert (setup.z, setup.y, setup.x) == ("z", "y", "x") diff --git a/tests/unit/ome_zarr_meta/test_unit_v04_utils.py b/tests/unit/ome_zarr_meta/test_unit_v04_utils.py index c25ca7f5..5e9cf80d 100644 --- a/tests/unit/ome_zarr_meta/test_unit_v04_utils.py +++ b/tests/unit/ome_zarr_meta/test_unit_v04_utils.py @@ -1,4 +1,5 @@ import json +from pathlib import Path from ome_zarr_models.v04.image import ImageAttrs as ImageAttrsV04 from ome_zarr_models.v04.image_label import ImageLabelAttrs as LabelAttrsV04 @@ -15,9 +16,11 @@ v04_to_ngio_well_meta, ) +META_DIR = Path(__file__).parents[2] / "data" / "v04" / "meta" + def test_image_round_trip(): - path = "tests/data/v04/meta/base_ome_zarr_image_meta.json" + path = META_DIR / "base_ome_zarr_image_meta.json" with open(path) as f: input_metadata = json.load(f) @@ -28,7 +31,7 @@ def test_image_round_trip(): def test_label_round_trip(): - path = "tests/data/v04/meta/base_ome_zarr_label_meta.json" + path = META_DIR / "base_ome_zarr_label_meta.json" with open(path) as f: metadata = json.load(f) @@ -39,7 +42,7 @@ def test_label_round_trip(): def test_well_meta(): - path = "tests/data/v04/meta/base_ome_zarr_well_meta.json" + path = META_DIR / "base_ome_zarr_well_meta.json" with open(path) as f: metadata = json.load(f) @@ -51,7 +54,7 @@ def test_well_meta(): def test_well_meta_path_normalization(): - path = "tests/data/v04/meta/ome_zarr_well_path_normalization_meta.json" + path = META_DIR / "ome_zarr_well_path_normalization_meta.json" with open(path) as f: metadata = json.load(f) diff --git a/tests/unit/ome_zarr_meta/test_unit_v05_utils.py b/tests/unit/ome_zarr_meta/test_unit_v05_utils.py new file mode 100644 index 00000000..343a109b --- /dev/null +++ b/tests/unit/ome_zarr_meta/test_unit_v05_utils.py @@ -0,0 +1,218 @@ +import numpy as np +import pytest +from zarr.storage import MemoryStore + +from ngio import create_ome_zarr_from_array +from ngio.ome_zarr_meta import ( + NgioImageMeta, + NgioLabelMeta, + NgioLabelsGroupMeta, + NgioPlateMeta, + NgioWellMeta, +) +from ngio.ome_zarr_meta.ngio_specs import AxesSetup +from ngio.ome_zarr_meta.v05._v05_spec import ( + ngio_to_v05_image_meta, + ngio_to_v05_label_meta, + ngio_to_v05_labels_group_meta, + ngio_to_v05_plate_meta, + ngio_to_v05_well_meta, + v05_to_ngio_image_meta, + v05_to_ngio_label_meta, + v05_to_ngio_labels_group_meta, + v05_to_ngio_plate_meta, + v05_to_ngio_well_meta, +) +from ngio.utils import ZarrGroupHandler + + +def _base_multiscale(name="image") -> dict: + return { + "axes": [ + {"name": "y", "type": "space", "unit": "micrometer"}, + {"name": "x", "type": "space", "unit": "micrometer"}, + ], + "datasets": [ + { + "path": "0", + "coordinateTransformations": [{"type": "scale", "scale": [1.0, 1.0]}], + }, + { + "path": "1", + "coordinateTransformations": [{"type": "scale", "scale": [2.0, 2.0]}], + }, + ], + "name": name, + } + + +def _image_attrs(multiscales=None, omero=None) -> dict: + ome: dict = { + "version": "0.5", + "multiscales": multiscales if multiscales is not None else [_base_multiscale()], + } + if omero is not None: + ome["omero"] = omero + return {"ome": ome} + + +def test_image_round_trip(): + omero = { + "channels": [ + { + "color": "00FF00", + "window": {"start": 0, "end": 100, "min": 0, "max": 255}, + "label": "DAPI", + "wavelength_id": "A01_C01", + "active": False, + }, + # bare channel: label/wavelength_id/active fall back to defaults + { + "color": "FF0000", + "window": {"start": 0, "end": 100, "min": 0, "max": 255}, + }, + ] + } + input_attrs = _image_attrs(omero=omero) + + ngio_image = v05_to_ngio_image_meta(input_attrs, axes_setup=AxesSetup()) + assert isinstance(ngio_image, NgioImageMeta) + assert ngio_image.version == "0.5" + assert ngio_image.name == "image" + + channels = ngio_image.channels_meta + assert channels is not None + assert channels.channels[0].label == "DAPI" + assert channels.channels[0].wavelength_id == "A01_C01" + assert channels.channels[0].channel_visualisation.active is False + # defaults for the bare channel + assert channels.channels[1].label == channels.channels[1].wavelength_id + assert channels.channels[1].channel_visualisation.active is True + + output_attrs = ngio_to_v05_image_meta(ngio_image) + ngio_image_2 = v05_to_ngio_image_meta(output_attrs, axes_setup=AxesSetup()) + + assert ngio_image_2.name == ngio_image.name + for ds_1, ds_2 in zip(ngio_image.datasets, ngio_image_2.datasets, strict=True): + assert ds_1.path == ds_2.path + assert ds_1.scale == ds_2.scale + assert ds_1.translation == ds_2.translation + channels_2 = ngio_image_2.channels_meta + assert channels_2 is not None + assert [c.label for c in channels_2.channels] == [ + c.label for c in channels.channels + ] + + +def test_image_multiple_multiscales_not_supported(): + attrs = _image_attrs(multiscales=[_base_multiscale(), _base_multiscale()]) + with pytest.raises(NotImplementedError): + v05_to_ngio_image_meta(attrs, axes_setup=AxesSetup()) + + +def test_image_non_string_multiscale_name_is_stringified(): + multiscale = _base_multiscale(name=123) + ngio_image = v05_to_ngio_image_meta( + _image_attrs(multiscales=[multiscale]), axes_setup=AxesSetup() + ) + assert ngio_image.name == "123" + + +def test_image_non_string_axis_unit_is_stringified(): + multiscale = _base_multiscale() + multiscale["axes"][0]["unit"] = 5 + ngio_image = v05_to_ngio_image_meta( + _image_attrs(multiscales=[multiscale]), axes_setup=AxesSetup() + ) + y_axis = ngio_image.datasets[0].axes_handler.axes[0] + assert y_axis.unit == "5" + + +def test_image_global_coordinate_transformations(): + multiscale = _base_multiscale() + multiscale["coordinateTransformations"] = [{"type": "scale", "scale": [2.0, 2.0]}] + ngio_image = v05_to_ngio_image_meta( + _image_attrs(multiscales=[multiscale]), axes_setup=AxesSetup() + ) + # the multiscale-level scale multiplies every dataset scale + assert ngio_image.datasets[0].scale == (2.0, 2.0) + assert ngio_image.datasets[1].scale == (4.0, 4.0) + + +def _label_attrs() -> dict: + """Attrs of a real derived label, read back from an in-memory container.""" + store = MemoryStore() + container = create_ome_zarr_from_array( + store=store, + array=np.zeros((16, 16), dtype="uint8"), + pixelsize=1.0, + axes_names=["y", "x"], + levels=2, + ngff_version="0.5", + ) + container.derive_label("lbl") + handler = ZarrGroupHandler(store=store, mode="r") + return handler.group["labels/lbl"].attrs.asdict() + + +def test_label_round_trip(): + input_attrs = _label_attrs() + + ngio_label = v05_to_ngio_label_meta(input_attrs, axes_setup=AxesSetup()) + assert isinstance(ngio_label, NgioLabelMeta) + assert ngio_label.version == "0.5" + + output_attrs = ngio_to_v05_label_meta(ngio_label) + ngio_label_2 = v05_to_ngio_label_meta(output_attrs, axes_setup=AxesSetup()) + + for ds_1, ds_2 in zip(ngio_label.datasets, ngio_label_2.datasets, strict=True): + assert ds_1.path == ds_2.path + assert ds_1.scale == ds_2.scale + assert ngio_label.image_label == ngio_label_2.image_label + + +def test_label_multiple_multiscales_not_supported(): + attrs = _label_attrs() + attrs["ome"]["multiscales"] = attrs["ome"]["multiscales"] * 2 + with pytest.raises(NotImplementedError): + v05_to_ngio_label_meta(attrs, axes_setup=AxesSetup()) + + +def test_well_round_trip(): + ngio_well = ( + NgioWellMeta.default_init(ngff_version="0.5") + .add_image(path="0") + .add_image(path="1", acquisition=0, strict=False) + ) + output_attrs = ngio_to_v05_well_meta(ngio_well) + ngio_well_2 = v05_to_ngio_well_meta(output_attrs) + + assert isinstance(ngio_well_2, NgioWellMeta) + assert ngio_well_2.version == "0.5" + assert ngio_well_2.paths() == ["0", "1"] + assert ngio_well_2.get_image_acquisition_id("1") == 0 + + +def test_plate_round_trip(): + ngio_plate = ( + NgioPlateMeta.default_init(name="plate", ngff_version="0.5") + .add_well(row="A", column=1) + .add_acquisition(acquisition_id=0, acquisition_name="acq") + ) + output_attrs = ngio_to_v05_plate_meta(ngio_plate) + ngio_plate_2 = v05_to_ngio_plate_meta(output_attrs) + + assert isinstance(ngio_plate_2, NgioPlateMeta) + assert ngio_plate_2.version == "0.5" + assert ngio_plate_2.wells_paths == ["A/01"] + assert ngio_plate_2.acquisition_ids == [0] + assert ngio_plate_2.plate.name == "plate" + + +def test_labels_group_round_trip(): + ngio_labels = NgioLabelsGroupMeta(labels=["a", "b"], version="0.5") + output_attrs = ngio_to_v05_labels_group_meta(ngio_labels) + ngio_labels_2 = v05_to_ngio_labels_group_meta(output_attrs) + + assert isinstance(ngio_labels_2, NgioLabelsGroupMeta) + assert list(ngio_labels_2.labels) == ["a", "b"] diff --git a/tests/unit/tables/test_backends.py b/tests/unit/tables/test_backends.py index d929a90b..5d5b078a 100644 --- a/tests/unit/tables/test_backends.py +++ b/tests/unit/tables/test_backends.py @@ -62,134 +62,65 @@ def test_backend_manager(tmp_path: Path): manager.add_backend(JsonTableBackend) -def test_json_backend(tmp_path: Path): - store = tmp_path / "test_json_backend.zarr" - handler = ZarrGroupHandler(store=store, cache=True, mode="a") - backend = JsonTableBackend() - backend.set_group_handler(handler, index_type="str") - - assert backend.backend_name() == "json" - assert not backend.implements_anndata() - assert backend.implements_pandas() - - test_table = pd.DataFrame( - {"a": [1, 2, 3], "b": [4.0, 5.0, 6.0], "c": ["a", "b", "c"]} - ) - test_table.index = test_table.index.astype(str) - - backend.write(test_table, metadata={"test": "test"}) - loaded_table = backend.load_as_pandas_df() - - assert loaded_table.equals(test_table) - - meta = backend._group_handler.load_attrs() - assert meta["test"] == "test" - assert meta["backend"] == "json" - - a_data = backend.load_as_anndata() - - with pytest.raises(NotImplementedError): - backend.write(a_data, metadata={"test": "test"}) - - lf_data = backend.load_as_polars_lf() - backend.write(lf_data, metadata={"test": "test"}) - - -def test_csv_backend(tmp_path: Path): - store = tmp_path / "test_csv_backend.zarr" - handler = ZarrGroupHandler(store=store, cache=True, mode="a") - backend = CsvTableBackend() - backend.set_group_handler(handler) - - assert backend.backend_name() == "csv" - assert not backend.implements_anndata() +@pytest.mark.parametrize( + "backend_cls, backend_name, implements_anndata, handler_kwargs, backend_kwargs", + [ + (JsonTableBackend, "json", False, {}, {"index_type": "str"}), + (CsvTableBackend, "csv", False, {}, {}), + (ParquetTableBackend, "parquet", False, {}, {}), + (AnnDataBackend, "anndata", True, {"zarr_format": 2}, {"index_type": "int"}), + ], +) +def test_backend_round_trip( + tmp_path: Path, + backend_cls: type, + backend_name: str, + implements_anndata: bool, + handler_kwargs: dict, + backend_kwargs: dict, +): + store = tmp_path / f"test_{backend_name}_backend.zarr" + handler = ZarrGroupHandler(store=store, cache=True, mode="a", **handler_kwargs) + backend = backend_cls() + backend.set_group_handler(handler, **backend_kwargs) + + assert backend.backend_name() == backend_name + assert backend.implements_anndata() == implements_anndata assert backend.implements_pandas() test_table = pd.DataFrame( {"a": [1, 2, 3], "b": [4.1, 5.1, 6.1], "c": ["a", "b", "c"]} ) - - backend.write(test_table, metadata={"test": "test"}) - loaded_table = backend.load_as_pandas_df() - assert loaded_table.equals(test_table), loaded_table - meta = backend._group_handler.load_attrs() - assert meta["test"] == "test" - assert meta["backend"] == "csv" - - a_data = backend.load_as_anndata() - with pytest.raises(NotImplementedError): - backend.write(a_data, metadata={"test": "test"}) - - lf_data = backend.load_as_polars_lf() - backend.write(lf_data, metadata={"test": "test"}) - - -def test_parquet_backend(tmp_path: Path): - store = tmp_path / "test_parquet_backend.zarr" - handler = ZarrGroupHandler(store=store, cache=True, mode="a") - backend = ParquetTableBackend() - backend.set_group_handler(handler) - - assert backend.backend_name() == "parquet" - assert not backend.implements_anndata() - assert backend.implements_pandas() - - test_table = pd.DataFrame( - {"a": [1, 2, 3], "b": [4.0, 5.0, 6.0], "c": ["a", "b", "c"]} - ) - - backend.write(test_table, metadata={"test": "test"}) - loaded_table = backend.load_as_pandas_df() - assert loaded_table.equals(test_table), loaded_table - meta = backend._group_handler.load_attrs() - assert meta["test"] == "test" - assert meta["backend"] == "parquet" - - a_data = backend.load_as_anndata() - with pytest.raises(NotImplementedError): - backend.write(a_data, metadata={"test": "test"}) - - lf_data = backend.load_as_polars_lf() - backend.write(lf_data, metadata={"test": "test"}) - - -def test_anndata_backend(tmp_path: Path): - store = tmp_path / "test_anndata_backend.zarr" - handler = ZarrGroupHandler(store=store, cache=True, mode="a", zarr_format=2) - backend = AnnDataBackend() - backend.set_group_handler(handler, index_type="int") - - assert backend.backend_name() == "anndata" - assert backend.implements_anndata() - assert backend.implements_pandas() - - test_table = pd.DataFrame( - {"a": [1, 2, 3], "b": [4.0, 5.0, 6.0], "c": ["a", "b", "c"]} - ) + if backend_kwargs.get("index_type") == "str": + test_table.index = test_table.index.astype(str) backend.write(test_table, metadata={"test": "test"}) loaded_table = backend.load_as_pandas_df() + # The anndata round trip does not preserve the index, compare columns only for column in loaded_table.columns: - # Since the transformation from anndata to dataframe is not perfect - # We can only compare the columns pd.testing.assert_series_equal(loaded_table[column], test_table[column]) + if not implements_anndata: + assert loaded_table.equals(test_table), loaded_table meta = backend._group_handler.load_attrs() assert meta["test"] == "test" - assert meta["backend"] == "anndata" + assert meta["backend"] == backend_name a_data = backend.load_as_anndata() - backend.write(a_data, metadata={"test": "test"}) + if implements_anndata: + backend.write(a_data, metadata={"test": "test"}) + # Test that the "raw" entry with encoding-type "null" is + # removed after writing for compatibility with older anndata versions + with pytest.raises(KeyError): + backend._group_handler._group["raw"] + else: + with pytest.raises(NotImplementedError): + backend.write(a_data, metadata={"test": "test"}) lf_data = backend.load_as_polars_lf() backend.write(lf_data, metadata={"test": "test"}) - # Test that the "raw" entry with encoding-type "null" is - # removed after writing for compatibility with older anndata versions - with pytest.raises(KeyError): - backend._group_handler._group["raw"] - @pytest.mark.parametrize( "index_label, index_type", diff --git a/tests/unit/tables/test_roi_table_edge_cases.py b/tests/unit/tables/test_roi_table_edge_cases.py new file mode 100644 index 00000000..a49e9111 --- /dev/null +++ b/tests/unit/tables/test_roi_table_edge_cases.py @@ -0,0 +1,233 @@ +"""Edge-case coverage tests for `ngio.tables.v1._roi_table`.""" + +import logging +from pathlib import Path + +import pandas as pd +import pytest + +from ngio.common import Roi +from ngio.tables._tables_container import open_table, write_table +from ngio.tables.v1._roi_table import ( + GenericRoiTableV1, + MaskingRoiTableV1, + MaskingRoiTableV1Meta, + RoiDictWrapper, + RoiTableV1, + RoiTableV1Meta, +) +from ngio.utils import NgioTableValidationError, NgioValueError + + +def _make_roi(name: str, label: int | None = None, **extras) -> Roi: + return Roi.from_values( + name=name, + slices={"x": (0, 10), "y": (0, 10), "z": (0, 10)}, + label=label, + **extras, + ) + + +def _valid_roi_df() -> pd.DataFrame: + df = pd.DataFrame( + { + "x_micrometer": [0.0, 1.0], + "y_micrometer": [0.0, 1.0], + "z_micrometer": [0.0, 1.0], + "len_x_micrometer": [1.0, 1.0], + "len_y_micrometer": [1.0, 1.0], + "len_z_micrometer": [1.0, 1.0], + } + ) + df.index = pd.Index(["roi_1", "roi_2"], name="FieldIndex") + return df + + +def test_set_table_data_missing_required_columns(): + table = RoiTableV1() + df = _valid_roi_df().drop(columns=["len_x_micrometer", "len_y_micrometer"]) + with pytest.raises(NgioTableValidationError): + table.set_table_data(df) + + +def test_set_table_data_invalid_type(): + table = RoiTableV1() + with pytest.raises(NgioValueError): + table.set_table_data([1, 2, 3]) # ty: ignore[invalid-argument-type] + + +def test_set_table_data_valid_dataframe(): + table = RoiTableV1() + table.set_table_data(_valid_roi_df()) + assert len(table.rois()) == 2 + assert table.get("roi_1").name == "roi_1" + + +def test_set_table_data_refresh_from_backend(tmp_path: Path): + table = RoiTableV1(rois=[_make_roi("roi_1"), _make_roi("roi_2")]) + write_table(store=tmp_path / "roi.zarr", table=table, backend="anndata") + + loaded = open_table(store=tmp_path / "roi.zarr") + assert isinstance(loaded, RoiTableV1) + assert len(loaded.rois()) == 2 + + # Reload the in-memory state from the backend + loaded.set_table_data(refresh=True) + assert len(loaded.rois()) == 2 + assert loaded.get("roi_1").name == "roi_1" + + +def test_serialization_requires_x_slice(): + roi = Roi.from_values(name="roi", slices={"y": (0, 10), "z": (0, 10)}) + with pytest.raises(NgioValueError, match="missing 'x' slice"): + RoiTableV1(rois=[roi]) + + +def test_serialization_requires_y_slice(): + roi = Roi.from_values(name="roi", slices={"x": (0, 10), "z": (0, 10)}) + with pytest.raises(NgioValueError, match="missing 'y' slice"): + RoiTableV1(rois=[roi]) + + +def test_label_written_as_column_when_index_is_not_label(): + # RoiTableV1 uses "FieldIndex" as index, so the label must be + # serialized as a dedicated "label" column. + table = RoiTableV1(rois=[_make_roi("roi_1", label=5)]) + df = table.dataframe + assert "label" in df.columns + assert df.loc["roi_1", "label"] == 5 + + +def test_extra_columns_roundtrip_and_unknown_column_warning(caplog): + roi = _make_roi("roi_1", plate_name="plate_a", not_a_known_column="foo") + with caplog.at_level(logging.WARNING): + table = RoiTableV1(rois=[roi]) + df = table.dataframe + assert df.loc["roi_1", "plate_name"] == "plate_a" + assert df.loc["roi_1", "not_a_known_column"] == "foo" + # Unknown (non-optional) columns are reported via a logger warning + assert "not_a_known_column" in caplog.text + assert "is not in the optional columns" in caplog.text + + +def test_read_unknown_extra_column_warning(caplog): + df = _valid_roi_df() + df["strange_extra"] = ["a", "b"] + table = RoiTableV1() + with caplog.at_level(logging.WARNING): + table.set_table_data(df) + assert "strange_extra" in caplog.text + rois = table.rois() + assert len(rois) == 2 + + +def test_duplicate_roi_names_are_deduplicated(): + # Two ROIs with the same name: the second one is stored under a + # uuid-suffixed key so both survive. + wrapper = RoiDictWrapper([_make_roi("roi"), _make_roi("roi")]) + assert len(wrapper.to_list()) == 2 + assert wrapper.get_by_name("roi") is not None + + table = RoiTableV1(rois=[_make_roi("roi"), _make_roi("roi")]) + assert len(table.rois()) == 2 + + +def test_roi_dict_wrapper_add_single_roi(): + wrapper = RoiDictWrapper([]) + wrapper.add_rois(_make_roi("roi_1", label=3)) + assert len(wrapper.to_list()) == 1 + assert wrapper.get_by_name("roi_1") is not None + assert wrapper.get_by_label(3) is not None + assert wrapper.get_by_label(99) is None + + +def test_generic_roi_table_type(): + assert GenericRoiTableV1.table_type() == "generic_roi_table" + + +def test_from_table_data(): + table = RoiTableV1.from_table_data(_valid_roi_df(), meta=RoiTableV1Meta()) + assert isinstance(table, RoiTableV1) + assert len(table.rois()) == 2 + assert table.get("roi_2").name == "roi_2" + + +def test_roi_table_meta_none_defaults(): + meta = RoiTableV1Meta(index_key=None, index_type=None) + table = RoiTableV1(meta=meta) + assert table.meta.index_key == "FieldIndex" + assert table.meta.index_type == "str" + + +def test_masking_roi_table_meta_none_defaults(): + meta = MaskingRoiTableV1Meta(index_key=None, index_type=None) + table = MaskingRoiTableV1(meta=meta) + assert table.meta.index_key == "label" + assert table.meta.index_type == "int" + assert table.meta.instance_key == "label" + + +def test_masking_roi_table_repr_without_reference_label(): + table = MaskingRoiTableV1(rois=[_make_roi("1", label=1)]) + assert table.reference_label is None + assert repr(table) == "MaskingRoiTableV1(num_rois=1)" + + +def test_roi_without_z_and_with_time_roundtrip(): + # No z slice: serialized with defaults z=0.0, len_z=1.0. + # A t slice is serialized to t_second / len_t_second and read back. + roi = Roi.from_values( + name="roi_1", + slices={"x": (0, 10), "y": (0, 10), "t": (2, 5)}, + ) + table = RoiTableV1(rois=[roi]) + df = table.dataframe + assert df.loc["roi_1", "z_micrometer"] == 0.0 + assert df.loc["roi_1", "len_z_micrometer"] == 1.0 + assert df.loc["roi_1", "t_second"] == 2.0 + assert df.loc["roi_1", "len_t_second"] == 5.0 + + reloaded = RoiTableV1() + reloaded.set_table_data(df) + t_slice = reloaded.get("roi_1").get("t") + assert t_slice is not None + assert t_slice.start == 2.0 + assert t_slice.length == 5.0 + + +def test_masking_roi_table_label_from_index(): + df = _valid_roi_df() + df.index = pd.Index([10, 20], name="label") + table = MaskingRoiTableV1.from_table_data(df, meta=MaskingRoiTableV1Meta()) + assert isinstance(table, MaskingRoiTableV1) + assert table.get_label(10).label == 10 + assert table.get_label(20).label == 20 + + +def test_masking_roi_table_get_label(): + table = MaskingRoiTableV1(rois=[]) + table.add(_make_roi("1", label=1)) + roi = table.get_label(1) + assert roi.label == 1 + with pytest.raises(NgioValueError, match="label 2 not found"): + table.get_label(2) + + +def test_empty_roi_table_without_backend_is_usable(): + table = RoiTableV1() + assert table.rois() == [] + + table.add(_make_roi("r", label=1)) + assert [roi.name for roi in table.rois()] == ["r"] + + +def test_duplicate_roi_names_survive_roundtrip(tmp_path: Path): + table = RoiTableV1(rois=[_make_roi("roi"), _make_roi("roi")]) + names = [roi.name for roi in table.rois()] + assert len(set(names)) == 2 + + store = tmp_path / "rois.zarr" + write_table(store=store, table=table) + reloaded = open_table(store=store) + reloaded_names = [roi.name for roi in reloaded.rois()] # ty: ignore[unresolved-attribute] + assert len(set(reloaded_names)) == 2 diff --git a/tests/unit/tables/test_table_group.py b/tests/unit/tables/test_table_group.py index a2d953b7..4f6462dc 100644 --- a/tests/unit/tables/test_table_group.py +++ b/tests/unit/tables/test_table_group.py @@ -34,29 +34,6 @@ def test_table_container(tmp_path: Path): assert table.dataframe.equals(expected) -@pytest.mark.parametrize("backend", ["anndata", "json", "csv", "parquet"]) -def test_add_preserves_table_backend(tmp_path: Path, backend: str): - """Re-adding a loaded table without an explicit backend keeps its backend. - - Testing for #207. - """ - src_group = open_tables_container(tmp_path / "src.zarr", mode="a") - dst_group = open_tables_container(tmp_path / "dst.zarr", mode="a") - - table = FeatureTable( - table_data=DataFrame({"label": [1, 2, 3], "a": [4.0, 5.0, 6.0]}) - ) - src_group.add(name="table", table=table, backend=backend) - - loaded_table = src_group.get("table") - assert loaded_table.backend_name == backend - - dst_group.add(name="table", table=loaded_table) - copied_table = dst_group.get("table") - assert copied_table.backend_name == backend - assert copied_table.meta.backend == backend - - def test_add_explicit_backend_overrides(tmp_path: Path): """An explicit backend argument still converts the table.""" src_group = open_tables_container(tmp_path / "src.zarr", mode="a") diff --git a/tests/unit/utils/test_cache.py b/tests/unit/utils/test_cache.py new file mode 100644 index 00000000..fcd9782f --- /dev/null +++ b/tests/unit/utils/test_cache.py @@ -0,0 +1,51 @@ +import pytest + +from ngio.utils import NgioCache + + +def test_cache_enabled(): + cache: NgioCache[int] = NgioCache(use_cache=True) + assert cache.use_cache + assert cache.is_empty + assert cache.cache == {} + + cache.set("a", 1) + assert not cache.is_empty + assert cache.get("a") == 1 + assert cache.cache == {"a": 1} + assert cache.get("missing") is None + assert cache.get("missing", default=42) == 42 + + cache.set("a", 2) + assert cache.get("a") == 2 + + cache.clear() + assert cache.is_empty + assert cache.get("a") is None + + +def test_cache_disabled(): + cache: NgioCache[int] = NgioCache(use_cache=False) + assert not cache.use_cache + + # All operations are no-ops when the cache is disabled + cache.set("a", 1) + assert cache.is_empty + assert cache.get("a") is None + assert cache.get("a", default=7) == 7 + + cache.clear() + assert cache.is_empty + + +def test_cache_disabled_sanity_check(): + cache: NgioCache[int] = NgioCache(use_cache=False) + # Simulate a logic error: items in the internal dict while disabled + cache._cache["a"] = 1 + + with pytest.raises(RuntimeError, match="Cache is disabled"): + cache.get("a") + with pytest.raises(RuntimeError, match="Cache is disabled"): + cache.set("b", 2) + with pytest.raises(RuntimeError, match="Cache is disabled"): + cache.clear() diff --git a/tests/unit/utils/test_zarr_utils.py b/tests/unit/utils/test_zarr_utils.py index d5b42255..edeeaef2 100644 --- a/tests/unit/utils/test_zarr_utils.py +++ b/tests/unit/utils/test_zarr_utils.py @@ -170,7 +170,7 @@ def add_item(i): handler.write_attrs(attrs, overwrite=True) results = [] - num_items = 1000 + num_items = 100 for i in range(num_items): results.append(add_item(i)) @@ -190,6 +190,7 @@ def add_item(i): handler._create_lock() +@pytest.mark.network def test_remote_storage(): url = ( "https://raw.githubusercontent.com/"