Skip to content

Commit dc704aa

Browse files
authored
Handle case-insensitive platforms in Zarr hierarchy (#291)
* detect case insensitive file system * catch other platforms * warn case mismatch * fail early when well exists * clarify function scope * test case mismatch in creation
1 parent 82d6c1e commit dc704aa

File tree

2 files changed

+83
-1
lines changed

2 files changed

+83
-1
lines changed

iohub/ngff/nodes.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,11 @@ def _scale_integers(values: Sequence[int], factor: int) -> tuple[int, ...]:
8989
return tuple(int(math.ceil(v / factor)) for v in values)
9090

9191

92+
def _case_insensitive_local_fs() -> bool:
93+
"""Check if the local filesystem is case-insensitive."""
94+
return Path(__file__.lower()).exists() and Path(__file__.upper()).exists()
95+
96+
9297
class NGFFNode:
9398
"""A node (group level in Zarr) in an NGFF dataset."""
9499

@@ -123,6 +128,9 @@ def __init__(
123128
self._parse_meta()
124129
if not hasattr(self, "axes"):
125130
self.axes = self._DEFAULT_AXES
131+
# TODO: properly check the underlying storage type
132+
# This works for now as only the local filesystem is supported
133+
self._case_insensitive_fs = _case_insensitive_local_fs()
126134

127135
@property
128136
def zgroup(self):
@@ -196,7 +204,18 @@ def __delitem__(self, key):
196204

197205
def __contains__(self, key):
198206
key = normalize_storage_path(key)
199-
return key.lower() in [name.lower() for name in self._member_names]
207+
if not self._case_insensitive_fs:
208+
return key in self._member_names
209+
for name in self._member_names:
210+
if key.lower() != name.lower():
211+
continue
212+
if key != name:
213+
_logger.warning(
214+
f"Key '{key}' matched member '{name}'. "
215+
"This may not work on case-sensitive filesystems."
216+
)
217+
return True
218+
return False
200219

201220
def __iter__(self):
202221
yield from self._member_names
@@ -1681,6 +1700,11 @@ def create_well(
16811700
# normalize input
16821701
row_name = normalize_storage_path(row_name)
16831702
col_name = normalize_storage_path(col_name)
1703+
if row_name in self:
1704+
if col_name in self[row_name]:
1705+
raise FileExistsError(
1706+
f"Well '{row_name}/{col_name}' already exists."
1707+
)
16841708
row_meta = PlateAxisMeta(name=row_name)
16851709
col_meta = PlateAxisMeta(name=col_name)
16861710
row_index = self._auto_idx(row_name, row_index, "row")

tests/ngff/test_ngff.py

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

33
import os
4+
import platform
45
import shutil
56
import string
67
from contextlib import contextmanager
@@ -22,8 +23,10 @@
2223

2324
from iohub.ngff.nodes import (
2425
TO_DICT_SETTINGS,
26+
NGFFNode,
2527
Plate,
2628
TransformationMeta,
29+
_case_insensitive_local_fs,
2730
_open_store,
2831
_pad_shape,
2932
open_ome_zarr,
@@ -150,6 +153,19 @@ def test_open_store_read_nonexist():
150153
_ = _open_store(store_path, mode=mode, version="0.4")
151154

152155

156+
def test_case_insensitive_local_fs():
157+
"""Test `iohub.ngff._case_insensitive_local_fs()`"""
158+
match platform.system():
159+
case "Windows":
160+
assert _case_insensitive_local_fs() is True
161+
case "Darwin":
162+
assert _case_insensitive_local_fs() is True
163+
case "Linux":
164+
assert _case_insensitive_local_fs() is False
165+
case _:
166+
_ = _case_insensitive_local_fs()
167+
168+
153169
@given(channel_names=channel_names_st)
154170
@settings(max_examples=16)
155171
def test_init_ome_zarr(channel_names):
@@ -920,6 +936,20 @@ def test_get_axis_index():
920936
_ = position.get_axis_index("DOG")
921937

922938

939+
def test_ngff_node_contains_cross_platform(caplog):
940+
"""Test `iohub.ngff.NGFFNode.__contains__()` on multiple platforms."""
941+
with open_ome_zarr(hcs_ref, layout="hcs", mode="r") as dataset:
942+
assert "B" in dataset
943+
match platform.system():
944+
case "Linux":
945+
assert "b" not in dataset
946+
case "Windows" | "Darwin":
947+
assert "b" in dataset
948+
assert any(
949+
"Key 'b' matched" in r.message for r in caplog.records
950+
)
951+
952+
923953
@given(
924954
row=short_alpha_numeric, col=short_alpha_numeric, pos=short_alpha_numeric
925955
)
@@ -963,6 +993,34 @@ def test_create_well(row_names: list[str], col_names: list[str]):
963993
] == row_names
964994

965995

996+
def test_create_case_sensitive_well(tmp_path):
997+
"""Test `iohub.ngff.Plate.create_well()` with case-sensitive names."""
998+
store_path = tmp_path / "hcs.zarr"
999+
with open_ome_zarr(
1000+
store_path, layout="hcs", mode="w-", channel_names=["1", "2"]
1001+
) as dataset:
1002+
well = dataset.create_well("A", "B")
1003+
fov = well.create_position("0")
1004+
fov.create_zeros("0", shape=(1, 2, 3, 4, 5), dtype=int)
1005+
match platform.system():
1006+
case "Windows" | "Darwin":
1007+
with pytest.raises(FileExistsError):
1008+
dataset.create_well("a", "B")
1009+
with pytest.raises(FileExistsError):
1010+
dataset.create_well("A", "b")
1011+
new_well = dataset.create_well("a", "1")
1012+
expected_rows = 1
1013+
case "Linux":
1014+
new_well = dataset.create_well("a", "b")
1015+
expected_rows = 2
1016+
new_fov = new_well.create_position("0")
1017+
new_fov.create_zeros("0", shape=(1, 2, 3, 4, 5), dtype=int)
1018+
with open_ome_zarr(store_path) as dataset:
1019+
assert len(dataset.metadata.rows) == expected_rows
1020+
assert len(list(dataset.rows())) == expected_rows
1021+
assert len(dataset.metadata.columns) == 2
1022+
1023+
9661024
@given(
9671025
row=short_alpha_numeric, col=short_alpha_numeric, pos=short_alpha_numeric
9681026
)

0 commit comments

Comments
 (0)