Skip to content

Commit 074d272

Browse files
authored
Merge pull request #281 from tlambert03/cli
feat: Add CLI validate/info
2 parents 65cf759 + 862f67e commit 074d272

File tree

5 files changed

+260
-11
lines changed

5 files changed

+260
-11
lines changed

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ docs = [
3232
"fsspec[http]",
3333
]
3434

35+
[project.scripts]
36+
ome-zarr-models = "ome_zarr_models._cli:main"
37+
3538
[tool.hatch.version]
3639
source = "vcs"
3740

src/ome_zarr_models/__init__.py

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from importlib.metadata import PackageNotFoundError, version
2-
from typing import Any
2+
from typing import TYPE_CHECKING, Any, Literal
33

44
import zarr
55

@@ -22,6 +22,8 @@
2222
except PackageNotFoundError: # pragma: no cover
2323
__version__ = "uninstalled"
2424

25+
if TYPE_CHECKING:
26+
from collections.abc import Sequence
2527

2628
_V04_groups: list[type[BaseGroupv04[Any]]] = [
2729
ome_zarr_models.v04.hcs.HCS,
@@ -48,7 +50,9 @@
4850
]
4951

5052

51-
def open_ome_zarr(group: zarr.Group) -> BaseGroup:
53+
def open_ome_zarr(
54+
group: zarr.Group, *, version: Literal["0.4", "0.5"] | None = None
55+
) -> BaseGroup:
5256
"""
5357
Create an ome-zarr-models object from an existing OME-Zarr group.
5458
@@ -63,6 +67,10 @@ def open_ome_zarr(group: zarr.Group) -> BaseGroup:
6367
----------
6468
group : zarr.Group
6569
Zarr group containing OME-Zarr data.
70+
version : Literal['0.4', '0.5'], optional
71+
If you know which version of OME-Zarr your data is, you can
72+
specify it here. If not specified, all versions will be tried.
73+
The default is None, which means all versions will be tried.
6674
6775
Raises
6876
------
@@ -76,17 +84,31 @@ def open_ome_zarr(group: zarr.Group) -> BaseGroup:
7684
take a long time. It will be quicker to directly use the OME-Zarr group class if you
7785
know which version and group you expect.
7886
"""
79-
group_cls: type[BaseGroup]
80-
for group_cls in _V05_groups + _V04_groups:
87+
# because 'from_zarr' isn't defined on a shared super-class, list all variants here
88+
groups: Sequence[type[BaseGroupv05[Any] | BaseGroupv04[Any]]]
89+
match version:
90+
case None:
91+
groups = [*_V05_groups, *_V04_groups]
92+
case "0.4":
93+
groups = _V04_groups
94+
case "0.5":
95+
groups = _V05_groups
96+
case _:
97+
_versions = ("0.4", "0.5") # type: ignore[unreachable]
98+
raise ValueError(
99+
f"Unsupported version '{version}', must be one of {_versions}, or None"
100+
)
101+
102+
errors: list[Exception] = []
103+
for group_cls in groups:
81104
try:
82105
return group_cls.from_zarr(group)
83-
except Exception:
84-
continue
106+
except Exception as e:
107+
errors.append(e)
85108

86109
raise RuntimeError(
87-
f"Could not successfully validate {group} with any OME-Zarr group models.\n"
110+
f"Could not successfully validate {group} against any OME-Zarr group model.\n"
88111
"\n"
89-
"If you know what type of group you are trying to open, using the "
90-
"<group class>.from_zarr() method will give you a more informative "
91-
"error message explaining why validation failed."
112+
"The following errors were encountered while trying to validate:\n\n"
113+
+ "\n\n".join(f"- {type(e).__name__}: {e}" for e in errors)
92114
)

src/ome_zarr_models/_cli.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
from __future__ import annotations
2+
3+
import argparse
4+
import sys
5+
from typing import TYPE_CHECKING
6+
7+
from ome_zarr_models import __version__, open_ome_zarr
8+
9+
if TYPE_CHECKING:
10+
from zarr.storage import StoreLike
11+
12+
13+
def main() -> None:
14+
"""Main entry point for the ome-zarr-models CLI."""
15+
parser = argparse.ArgumentParser(
16+
prog="ome-zarr-models",
17+
description="OME-Zarr Models CLI",
18+
)
19+
parser.add_argument(
20+
"--version",
21+
action="version",
22+
version=f"ome-zarr-models version {__version__}",
23+
)
24+
subparsers = parser.add_subparsers(
25+
dest="command", help="Available commands", metavar="COMMAND"
26+
)
27+
28+
# validate sub-command
29+
validate_cmd = subparsers.add_parser("validate", help="Validate an OME-Zarr")
30+
validate_cmd.add_argument(
31+
"path", type=str, help="Path to OME-Zarr group to validate"
32+
)
33+
34+
# info sub-command
35+
info_cmd = subparsers.add_parser(
36+
"info", help="Get information about an OME-Zarr group"
37+
)
38+
info_cmd.add_argument(
39+
"path", type=str, help="Path to OME-Zarr group to get information about"
40+
)
41+
42+
args = parser.parse_args()
43+
44+
# Execute the appropriate command
45+
match args.command:
46+
case "validate":
47+
validate(args.path)
48+
case "info":
49+
info(args.path)
50+
case None:
51+
parser.print_help()
52+
sys.exit(1)
53+
54+
55+
def validate(path: StoreLike, version: str | None = None) -> None:
56+
"""Validate an OME-Zarr at the given path.
57+
58+
Parameters
59+
----------
60+
path : str
61+
Path to the OME-Zarr group to validate. May be
62+
version : str | None, optional
63+
OME-Zarr version to validate against. If `None`, the version will be
64+
inferred from the metadata, by default `None`.
65+
66+
Examples
67+
--------
68+
```bash
69+
ome-zarr-models validate https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.5/idr0066/ExpD_chicken_embryo_MIP.ome.zarr
70+
```
71+
"""
72+
import zarr
73+
74+
group = zarr.open_group(path, mode="r")
75+
try:
76+
open_ome_zarr(group, version=version) # type: ignore[arg-type]
77+
except Exception as e:
78+
print(f"{e}\n")
79+
print(f"❌ Invalid OME-Zarr: {path}")
80+
sys.exit(1)
81+
print("✅ Valid OME-Zarr")
82+
83+
84+
def info(path: StoreLike) -> None:
85+
"""Print information about an OME-Zarr at the given path.
86+
87+
Examples
88+
--------
89+
```bash
90+
ome-zarr-models info https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.5/idr0066/ExpD_chicken_embryo_MIP.ome.zarr
91+
ome-zarr-models info https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.5/idr0062A/6001240_labels.zarr
92+
ome-zarr-models info https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.5/idr0083/9822152.zarr
93+
ome-zarr-models info https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.5/idr0010/76-45.ome.zarr
94+
```
95+
"""
96+
import zarr
97+
98+
try:
99+
from rich import print
100+
except ImportError:
101+
from builtins import print
102+
103+
try:
104+
group = zarr.open_group(path, mode="r")
105+
obj = open_ome_zarr(group)
106+
except Exception as e:
107+
print(f"{e}\n")
108+
print(f"❌ Invalid OME-Zarr: {path}")
109+
sys.exit(1)
110+
else:
111+
print(obj)
112+
113+
114+
if __name__ == "__main__":
115+
main()

tests/test_cli.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
from __future__ import annotations
2+
3+
from typing import TYPE_CHECKING, cast
4+
5+
import pytest
6+
import zarr
7+
from zarr.storage import LocalStore
8+
9+
from ome_zarr_models._cli import main
10+
11+
from .conftest import Version, json_to_zarr_group
12+
13+
if TYPE_CHECKING:
14+
from collections.abc import Mapping, Sequence
15+
from pathlib import Path
16+
from typing import Any
17+
18+
19+
def populate_fake_data(
20+
zarr_group: zarr.Group,
21+
default_dtype: str = "uint8",
22+
) -> None:
23+
# Get the ome metadata from the group attributes
24+
# version 0.4 uses the root attributes, version 0.5 uses the "ome" attribute
25+
ome_attrs = cast("Mapping[str, Any]", zarr_group.attrs.get("ome", zarr_group.attrs))
26+
multiscales = ome_attrs.get("multiscales")
27+
if isinstance(multiscales, list):
28+
create_multiscales_data(zarr_group, multiscales, default_dtype)
29+
return
30+
31+
# TODO? could support fake data for other node types
32+
33+
34+
def create_multiscales_data(
35+
zarr_group: zarr.Group,
36+
multiscales: Sequence[Mapping[str, Any]],
37+
default_dtype: str = "uint8",
38+
) -> None:
39+
# Use the first multiscale (most common case)
40+
for multiscale in multiscales:
41+
if not (axes := multiscale.get("axes")):
42+
raise ValueError(
43+
f"No axes found in multiscale metadata from {zarr_group.store_path}"
44+
)
45+
if not (datasets := multiscale.get("datasets")):
46+
raise ValueError(
47+
f"No datasets found in multiscale metadata from {zarr_group.store_path}"
48+
)
49+
50+
dimension_names = [axis["name"] for axis in axes]
51+
shape = (1,) * len(dimension_names)
52+
kwargs = {}
53+
if zarr_group.metadata.zarr_format >= 3:
54+
kwargs.update({"dimension_names": dimension_names})
55+
56+
# Create arrays for each dataset path
57+
for dataset in datasets:
58+
if path := dataset.get("path"):
59+
zarr_group.create_array(
60+
path,
61+
shape=shape,
62+
dtype=default_dtype,
63+
**kwargs, # type: ignore[arg-type]
64+
)
65+
66+
67+
@pytest.mark.parametrize(
68+
"version,json_fname",
69+
[
70+
("0.4", "multiscales_example.json"),
71+
("0.5", "image_example.json"),
72+
("0.5", "image_label_example.json"),
73+
("0.5", "plate_example_1.json"),
74+
],
75+
)
76+
@pytest.mark.parametrize("cmd", ["validate", "info"])
77+
def test_cli_validate(
78+
version: Version,
79+
json_fname: str,
80+
cmd: str,
81+
monkeypatch: pytest.MonkeyPatch,
82+
tmp_path: Path,
83+
capsys: pytest.CaptureFixture[str],
84+
) -> None:
85+
"""Test the CLI commands."""
86+
87+
zarr_group = json_to_zarr_group(
88+
version=version, json_fname=json_fname, store=LocalStore(root=tmp_path)
89+
)
90+
populate_fake_data(zarr_group)
91+
monkeypatch.setattr("sys.argv", ["ome-zarr-models", cmd, str(tmp_path)])
92+
main()
93+
if cmd == "validate":
94+
assert "Valid OME-Zarr" in capsys.readouterr().out
95+
96+
97+
@pytest.mark.parametrize("cmd", ["validate", "info"])
98+
def test_cli_invalid(
99+
tmp_path: Path,
100+
cmd: str,
101+
monkeypatch: pytest.MonkeyPatch,
102+
capsys: pytest.CaptureFixture[str],
103+
) -> None:
104+
"""Test the CLI with no command."""
105+
zarr.create_group(tmp_path)
106+
monkeypatch.setattr("sys.argv", ["ome-zarr-models", cmd, str(tmp_path)])
107+
with pytest.raises(SystemExit) as excinfo:
108+
main()
109+
assert excinfo.value.code == 1
110+
assert "Invalid OME-Zarr" in capsys.readouterr().out

tests/test_root.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@ def test_load_ome_zarr_group_bad(tmp_path: Path) -> None:
2525
RuntimeError,
2626
match=re.escape(
2727
f"Could not successfully validate <Group file://{tmp_path / 'test'}> "
28-
"with any OME-Zarr group models."
2928
),
3029
):
3130
open_ome_zarr(hcs_group)

0 commit comments

Comments
 (0)