Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions examples/demo_from_uri.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
#!/usr/bin/env python3
"""Demonstration script for the ZarrGroupModel.from_uri functionality."""

import builtins
import sys

from yaozarrs import from_uri

try:
from rich import print
except ImportError:
print = builtins.print # type: ignore # noqa


def demo_zarr_uri(uri: str) -> None:
"""Demonstrate loading from a zarr URI."""
print(f"🔬 Loading Zarr Group from: {uri}")
print("=" * 80)

try:
# Load the zarr group
zarr_group = from_uri(uri)

print(zarr_group.model_dump(exclude_unset=True, exclude_none=True))
print("✅ Successfully loaded!")

except Exception as e:
builtins.print(f"❌ Failed to load URI: {e}")


if __name__ == "__main__":
if len(sys.argv) != 2:
print("Usage: python demo_from_uri.py <uri>")
print()
print("Examples:")
print(" python demo_from_uri.py /path/to/data.zarr")
print(" python demo_from_uri.py https://example.com/data.zarr")
print(" python demo_from_uri.py https://example.com/data.zarr/zarr.json")
sys.exit(1)

uri = sys.argv[1]
demo_zarr_uri(uri)
11 changes: 3 additions & 8 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,22 +32,22 @@ dependencies = [
"typing-extensions>=4.6.0",
]

[project.optional-dependencies]
io = ["fsspec[http]>=2025.0"]

[project.urls]
homepage = "https://github.com/tlambert03/yaozarrs"
repository = "https://github.com/tlambert03/yaozarrs"


[dependency-groups]
test = ["pytest>=7.0.0", "pytest-cov>=4.0.0"]
test = ["yaozarrs[io]", "pytest>=7.0.0", "pytest-cov>=4.0.0"]
dev = [
{ include-group = "test" },
{ include-group = "docs" },
"ipython>=8.37.0",
"mypy>=1.18.2",
"pdbpp>=0.11.7; sys_platform != 'win32'",
"pre-commit-uv>=4.1.5",
"pyright>=1.1.405",
"rich>=14.1.0",
"ruff>=0.13.1",
"ty>=0.0.1a21",
Expand Down Expand Up @@ -118,11 +118,6 @@ show_error_codes = true
pretty = true
plugins = ["pydantic.mypy"]

[tool.pyright]
pythonVersion = "3.10"
reportArgumentType = false
enableExperimentalFeatures = true

# https://coverage.readthedocs.io/
[tool.coverage.report]
show_missing = true
Expand Down
2 changes: 1 addition & 1 deletion scripts/fetch.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ def metadata_mirror(remote_url: str, local_parent: str, verbose: bool = True) ->
str
Path to the local mirrored store directory.
"""
fs, root = fsspec.core.url_to_fs(remote_url)
fs, root = fsspec.url_to_fs(remote_url) # type: ignore

root_path = PurePosixPath(root.rstrip("/"))
root_name = root_path.name or root_path.parent.name
Expand Down
12 changes: 9 additions & 3 deletions src/yaozarrs/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,13 @@
except PackageNotFoundError: # pragma: no cover
__version__ = "uninstalled"

from . import v05
from ._validate import validate_ome_json, validate_ome_object
from . import v04, v05
from ._validate import from_uri, validate_ome_json, validate_ome_object

__all__ = ["v05", "validate_ome_json", "validate_ome_object"]
__all__ = [
"from_uri",
"v04",
"v05",
"validate_ome_json",
"validate_ome_object",
]
35 changes: 30 additions & 5 deletions src/yaozarrs/_base.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from __future__ import annotations

from typing import TYPE_CHECKING, Any, ClassVar

from pydantic import BaseModel, ConfigDict
from pydantic import BaseModel, ConfigDict, Field

__all__ = ["_BaseModel"]

Expand All @@ -15,13 +17,36 @@ class _BaseModel(BaseModel):
)

if not TYPE_CHECKING:

# "by_alias" is required for round-tripping on pydantic <2.10.0
def model_dump_json(self, **kwargs: Any) -> str:
# but required for round-tripping on pydantic <2.10.0
kwargs.setdefault("by_alias", True)
return super().model_dump_json(**kwargs)

def model_dump(self, **kwargs: Any) -> str: # pragma: no-cover
# but required for round-tripping on pydantic <2.10.0
def model_dump(self, **kwargs: Any) -> str: # pragma: no cover
kwargs.setdefault("by_alias", True)
return super().model_dump(**kwargs)


class ZarrGroupModel(_BaseModel):
"""Base class for models that have a direct mapping to a file or URI.

e.g. v04 .zattrs or v05 zarr.json

See Also
--------
v04.ZarrGroupJSON
v05.ZarrGroupJSON
"""

uri: str | None = Field(
default=None,
description=(
"The URI this model was loaded from, if any. Note, if `from_uri()` is "
"used, and a group directory is given, uri will resolve to the actual "
"JSON file inside that directory that corresponds to this model."
),
examples=[
"https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.5/idr0062A/6001240_labels.zarr/zarr.json",
"/path/to/some_file.zarr/zarr.json",
],
)
91 changes: 91 additions & 0 deletions src/yaozarrs/_io.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import os
from collections.abc import Iterable
from functools import wraps
from typing import TYPE_CHECKING, Any, Callable, TypeVar, cast

if TYPE_CHECKING:
import io

import fsspec
import fsspec.utils
else:
try:
import fsspec
import fsspec.utils
except ImportError:
fsspec = None


F = TypeVar("F", bound=Callable[..., object])


def _require_fsspec(func: F) -> F:
"""Decorator to ensure fsspec is available for functions that need it."""

@wraps(func)
def wrapper(*args: Any, **kwargs: Any) -> Any:
if fsspec is None: # pragma: no cover
msg = (
f"fsspec is required for {func.__name__!r}.\n"
"Install with: 'pip install yaozarrs[io]' or 'pip install fsspec'"
)
raise ImportError(msg)
return func(*args, **kwargs)

return cast("F", wrapper)


@_require_fsspec
def read_json_from_uri(uri: str | os.PathLike) -> tuple[str, str]:
"""Read JSON content from a URI (local or remote) using fsspec.

Parameters
----------
uri : str or os.PathLike
The URI to read the JSON data from. This can be a local file path,
or a remote URL (e.g. s3://bucket/key/some_file.zarr). It can be a zarr
group directory, or a direct path to a JSON file (e.g. zarr.json or
.zattrs) inside a zarr group.

Returns
-------
tuple[str, str]
A tuple containing the JSON content as a string, and the normalized URI string.
"""
uri_str = os.fspath(uri)
json_uri = _find_zarr_group_metadata(uri_str)

# Load JSON content using fsspec
try:
with fsspec.open(json_uri, "r") as f:
json_content = cast("io.TextIOBase", f).read()

except FileNotFoundError as e:
msg = f"Could not load JSON from URI: {json_uri}:\n{e}"
raise FileNotFoundError(msg) from e

return json_content, json_uri


def _find_zarr_group_metadata(
uri_str: str, candidates: Iterable[str] = ("zarr.json", ".zattrs")
) -> str:
"""Return path to zarr group metadata file inside a zarr group directory."""
# If the URI already points to a known metadata file, return it directly
if uri_str.endswith(("zarr.json", ".zattrs")):
return uri_str

# we assume it's a zarr group directory
# we now need to use fsspec to use the filesystem
# (which may be local or remote)
# to find either zarr.json or .zattrs
options = fsspec.utils.infer_storage_options(uri_str)
protocol = options.get("protocol", "file")
fs = cast("fsspec.AbstractFileSystem", fsspec.filesystem(protocol))

for candidate in candidates:
json_uri = uri_str + fs.sep + candidate
if fs.exists(json_uri):
return json_uri

raise FileNotFoundError(f"Could not find zarr group metadata in: {uri_str}")
43 changes: 42 additions & 1 deletion src/yaozarrs/_validate.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import os
from typing import Any, TypeAlias, TypeVar, overload

from pydantic import TypeAdapter

from . import v04, v05

AnyOME: TypeAlias = v05.OMEZarrGroupJSON | v04.OMEZarrGroupJSON | v05.OMEMetadata
AnyOMEGroup: TypeAlias = v04.OMEZarrGroupJSON | v05.OMEZarrGroupJSON
AnyOME: TypeAlias = AnyOMEGroup | v05.OMEMetadata
T = TypeVar("T", bound=AnyOME)


Expand Down Expand Up @@ -62,3 +64,42 @@ def validate_ome_json(
"""
adapter = TypeAdapter[T](cls or AnyOME)
return adapter.validate_json(data)


def from_uri(uri: str | os.PathLike) -> AnyOME:
"""Load and validate any OME-Zarr group from a URI or local path.

This function will attempt to load the OME-Zarr group metadata from the given
URI or local path. It supports both v0.4 and v0.5 of the OME-Zarr specification.
The URI should be a path to a zarr group (directory or URL) with valid ome-zarr
metadata, or a path directly to the metadata JSON file itself (e.g. zarr.json or
.zattrs).

This requires that you have installed yaozarrs with the `io` extra, e.g.
`pip install yaozarrs[io]`.

Parameters
----------
uri : str | os.PathLike
The URI or local path to the OME-Zarr group. This can be a file path,
a directory path, or a URL.

Returns
-------
AnyOME
An instance of `v05.OMEZarrGroupJSON`, `v04.OMEZarrGroupJSON`, or another
valid OME-Zarr node type, depending on the object detected.

Raises
------
FileNotFoundError
If the URI does not point to a valid OME-Zarr group.
pydantic.ValidationError
If the loaded metadata is not valid according to the OME-Zarr specification.
"""
from ._io import read_json_from_uri

json_content, uri_str = read_json_from_uri(uri)
obj = validate_ome_json(json_content, AnyOMEGroup)
obj.uri = uri_str
return obj
8 changes: 0 additions & 8 deletions src/yaozarrs/v04/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,11 +66,3 @@
"Well",
"WellDef",
]


# OMENode: TypeAlias = Image | Plate | LabelImage | Well | OME | Bf2Raw
# """Anything that can live in the "ome" key of a v0.4 ome-zarr file."""


# class OMEZarr(BaseModel):
# ome: OMENode
4 changes: 2 additions & 2 deletions src/yaozarrs/v04/_bf2raw.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@

from pydantic import Field

from yaozarrs._base import _BaseModel
from yaozarrs._base import ZarrGroupModel


class Bf2Raw(_BaseModel):
class Bf2Raw(ZarrGroupModel):
bioformats2raw_layout: Literal[3] = Field(
alias="bioformats2raw.layout",
description="The top-level identifier metadata added by bioformats2raw",
Expand Down
4 changes: 2 additions & 2 deletions src/yaozarrs/v04/_image.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from pydantic import AfterValidator, Field, WrapValidator, model_validator
from typing_extensions import Self

from yaozarrs._base import _BaseModel
from yaozarrs._base import ZarrGroupModel, _BaseModel
from yaozarrs._units import SpaceUnits, TimeUnits
from yaozarrs._utils import UniqueList

Expand Down Expand Up @@ -270,6 +270,6 @@ class Omero(_BaseModel):
# ------------------------------------------------------------------------------


class Image(_BaseModel):
class Image(ZarrGroupModel):
multiscales: Annotated[UniqueList[Multiscale], MinLen(1)]
omero: Omero | None = None
4 changes: 2 additions & 2 deletions src/yaozarrs/v04/_ome.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@
from annotated_types import MinLen
from pydantic import Field

from yaozarrs._base import _BaseModel
from yaozarrs._base import ZarrGroupModel


class OME(_BaseModel):
class OME(ZarrGroupModel):
"""Model for the ome group that contains OME-XML metadata."""

series: Annotated[list[str], MinLen(1)] = Field(
Expand Down
4 changes: 2 additions & 2 deletions src/yaozarrs/v04/_plate.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from pydantic import Field, NonNegativeInt, PositiveInt, model_validator
from typing_extensions import Self

from yaozarrs._base import _BaseModel
from yaozarrs._base import ZarrGroupModel, _BaseModel
from yaozarrs._utils import UniqueList

# ------------------------------------------------------------------------------
Expand Down Expand Up @@ -139,5 +139,5 @@ def _validate_well_indices(self) -> Self:
# ------------------------------------------------------------------------------


class Plate(_BaseModel):
class Plate(ZarrGroupModel):
plate: PlateDef
4 changes: 2 additions & 2 deletions src/yaozarrs/v04/_well.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from annotated_types import MinLen
from pydantic import Field

from yaozarrs._base import _BaseModel
from yaozarrs._base import ZarrGroupModel, _BaseModel
from yaozarrs._utils import UniqueList


Expand All @@ -30,7 +30,7 @@ class WellDef(_BaseModel):
# ------------------------------------------------------------------------------


class Well(_BaseModel):
class Well(ZarrGroupModel):
"""A well at the top-level of an ome-zarr file."""

well: WellDef
Loading
Loading