Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
5718cba
add basic support and remote SpatialData tests
berombau Jan 24, 2025
a8be620
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jan 24, 2025
ce686e4
fix pre-commit
LucaMarconato Jan 31, 2025
e7fa020
Revert "Update pyproject.toml"
LucaMarconato Jan 31, 2025
a9c0801
removed 3.13 from test ci
LucaMarconato Jan 31, 2025
5cfdaec
Merge branch 'no_python_313' into remote2
LucaMarconato Jan 31, 2025
4fe6a47
fix
LucaMarconato Jan 31, 2025
d97a1d2
uploading sdata to local s3 storage
LucaMarconato Jan 31, 2025
5e26b5e
add _open_zarr_store
berombau Jan 31, 2025
5794871
revert changing write function signature
berombau Jan 31, 2025
0207ff7
update _open_zarr_store with StoreLike
berombau Jan 31, 2025
7e497ff
read image element from base store
berombau Jan 31, 2025
c674281
clean up remote mock tests, focus only on reading raster elements
berombau Feb 1, 2025
fb953a0
improve remote http test, add alternative
berombau Feb 1, 2025
52bb5fc
add support for consolidated metadata store in util function, add _cr…
berombau Feb 1, 2025
ca82493
allow for groups as store input
berombau Feb 1, 2025
ecea0e6
handle consolidated metadata with upath
berombau Feb 1, 2025
734eb45
split remote reading tests between http and http with consolidated me…
berombau Feb 1, 2025
c0ffb1c
remove f_store_path, support remote raster types fully and keep local…
berombau Feb 1, 2025
d60bd85
Fix metadata_key bug now that store is not always FSStore. Add extra …
berombau Feb 1, 2025
c3fa8cf
add mypy fixes
berombau Feb 1, 2025
d16a638
Merge branch 'main' into remote2
ap-- Mar 17, 2025
23f4a89
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Mar 17, 2025
a80588c
Fix linting errors
ap-- Mar 17, 2025
020810b
fixed majority of tests
ap-- Mar 17, 2025
ba25564
spatialdata._io._utils: _open_zarr_store has to set dimension_separat…
ap-- Mar 17, 2025
b2ff8f8
stay in sync with ome zarr format
ap-- Mar 17, 2025
70480ce
spatialdata._io.io_raster: support remote stores
ap-- Mar 17, 2025
10cef3f
prevent crashing tests on 3.10
ap-- Mar 17, 2025
d9e4eac
Merge branch 'spatial-data-crash310' into remote2
ap-- Mar 17, 2025
41825fb
spatialdata.io.io_shapes: fix support for remote shapes
ap-- Mar 18, 2025
b47838f
spatialdata.io.io_points: fix support for remote points
ap-- Mar 18, 2025
c2e2d0d
tests: update tests to use remote paths
ap-- Mar 18, 2025
28466fd
spatialdata._io._utils: support consolidated remote stores
ap-- Mar 18, 2025
1ae94c7
spatialdata._io.format: supprot remote embl datasets on s3
ap-- Mar 18, 2025
aed327d
spatialdata._io: test remote tables support and fix repr
ap-- Mar 18, 2025
437d0d3
Merge branch 'cloud-fix-remote-shapes' into cloud-future
ap-- Mar 18, 2025
2d7aa96
Merge branch 'cloud-fix-remote-points' into cloud-future
ap-- Mar 18, 2025
8335a99
Merge branch 'cloud-fix-remote-tables' into cloud-future
ap-- Mar 18, 2025
0fa1df9
Merge branch 'cloud-fix-remote-embl-datasets' into cloud-future
ap-- Mar 18, 2025
e9e2c7b
add support for remote write of non-raster elements
berombau Apr 4, 2025
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
7 changes: 5 additions & 2 deletions pyproject.toml
Original file line numberDiff line numberDiff line change
Expand Up@@ -26,20 +26,22 @@ dependencies = [
"click",
"dask-image",
"dask>=2024.4.1,<=2024.11.2",
"fsspec",
"fsspec[s3,http]",
"geopandas>=0.14",
"multiscale_spatial_image>=2.0.2",
"networkx",
"numba>=0.55.0",
"numpy",
"ome_zarr>=0.8.4",
"universal_pathlib>=0.2.6",
"pandas",
"pooch",
"pyarrow",
"rich",
"setuptools",
"shapely>=2.0.1",
"spatial_image>=1.1.0",
"spatial_image>=1.2.1",
"xarray-dataclasses>=1.9.1",
"scikit-image",
"scipy",
"typing_extensions>=4.8.0",
Expand All@@ -58,6 +60,7 @@ test = [
"pytest-cov",
"pytest-mock",
"torch",
"moto[s3,server]"
]
docs = [
"sphinx>=4.5",
Expand Down
76 changes: 43 additions & 33 deletions src/spatialdata/_core/spatialdata.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -30,11 +30,8 @@
validate_table_attr_keys,
)
from spatialdata._logging import logger
from spatialdata._types import ArrayLike, Raster_T
from spatialdata._utils import (
_deprecation_alias,
_error_message_add_element,
)
from spatialdata._types import ArrayLike, Raster_T, StoreLike
from spatialdata._utils import _deprecation_alias, _error_message_add_element
from spatialdata.models import (
Image2DModel,
Image3DModel,
Expand DownExpand Up@@ -601,7 +598,7 @@ def path(self, value: Path | None) -> None:
)

def _get_groups_for_element(
self, zarr_path: Path, element_type: str, element_name: str
self, zarr_path: StoreLike, element_type: str, element_name: str
) -> tuple[zarr.Group, zarr.Group, zarr.Group]:
"""
Get the Zarr groups for the root, element_type and element for a specific element.
Expand All@@ -621,9 +618,9 @@ def _get_groups_for_element(
-------
either the existing Zarr subgroup or a new one.
"""
if not isinstance(zarr_path, Path):
raise ValueError("zarr_path should be a Path object")
store = parse_url(zarr_path, mode="r+").store
from spatialdata._io._utils import _open_zarr_store

store = _open_zarr_store(zarr_path, mode="r+")
root = zarr.group(store=store)
if element_type not in ["images", "labels", "points", "polygons", "shapes", "tables"]:
raise ValueError(f"Unknown element type {element_type}")
Expand DownExpand Up@@ -1068,9 +1065,12 @@ def elements_paths_on_disk(self) -> list[str]:
-------
A list of paths of the elements saved in the Zarr store.
"""
from spatialdata._io._utils import _open_zarr_store

if self.path is None:
raise ValueError("The SpatialData object is not backed by a Zarr store.")
store = parse_url(self.path, mode="r").store

store = _open_zarr_store(self.path)
root = zarr.group(store=store)
elements_in_zarr = []

Expand DownExpand Up@@ -1175,7 +1175,7 @@ def _validate_all_elements(self) -> None:

def write(
self,
file_path: str | Path,
file_path: StoreLike,
overwrite: bool = False,
consolidate_metadata: bool = True,
format: SpatialDataFormat | list[SpatialDataFormat] | None = None,
Expand DownExpand Up@@ -1205,12 +1205,16 @@ def write(
:class:`~spatialdata._io.format.CurrentRasterFormat`, :class:`~spatialdata._io.format.CurrentShapesFormat`,
:class:`~spatialdata._io.format.CurrentPointsFormat`, :class:`~spatialdata._io.format.CurrentTablesFormat`.
"""
from spatialdata._io._utils import _open_zarr_store

if isinstance(file_path, str):
file_path = Path(file_path)
self._validate_can_safely_write_to_path(file_path, overwrite=overwrite)
self._validate_all_elements()
if isinstance(file_path, Path):
# TODO: also validate remote paths
self._validate_can_safely_write_to_path(file_path, overwrite=overwrite)
self._validate_all_elements()

store = parse_url(file_path, mode="w").store
store = _open_zarr_store(file_path, mode="w")
zarr_group = zarr.group(store=store, overwrite=overwrite)
self.write_attrs(zarr_group=zarr_group)
store.close()
Expand All@@ -1236,20 +1240,21 @@ def write(
def _write_element(
self,
element: SpatialElement | AnnData,
zarr_container_path: Path,
zarr_container_path: StoreLike,
element_type: str,
element_name: str,
overwrite: bool,
format: SpatialDataFormat | list[SpatialDataFormat] | None = None,
) -> None:
if not isinstance(zarr_container_path, Path):
if not isinstance(zarr_container_path, StoreLike):
raise ValueError(
f"zarr_container_path must be a Path object, type(zarr_container_path) = {type(zarr_container_path)}."
)
file_path_of_element = zarr_container_path / element_type / element_name
self._validate_can_safely_write_to_path(
file_path=file_path_of_element, overwrite=overwrite, saving_an_element=True
)
if isinstance(zarr_container_path, Path):
file_path_of_element = zarr_container_path / element_type / element_name
self._validate_can_safely_write_to_path(
file_path=file_path_of_element, overwrite=overwrite, saving_an_element=True
)

root_group, element_type_group, _ = self._get_groups_for_element(
zarr_path=zarr_container_path, element_type=element_type, element_name=element_name
Expand DownExpand Up@@ -1376,7 +1381,7 @@ def delete_element_from_disk(self, element_name: str | list[str]) -> None:
self.delete_element_from_disk(name)
return

from spatialdata._io._utils import _backed_elements_contained_in_path
from spatialdata._io._utils import _backed_elements_contained_in_path, _open_zarr_store

if self.path is None:
raise ValueError("The SpatialData object is not backed by a Zarr store.")
Expand DownExpand Up@@ -1417,7 +1422,7 @@ def delete_element_from_disk(self, element_name: str | list[str]) -> None:
)

# delete the element
store = parse_url(self.path, mode="r+").store
store = _open_zarr_store(self.path)
root = zarr.group(store=store)
root[element_type].pop(element_name)
store.close()
Expand All@@ -1438,15 +1443,24 @@ def _check_element_not_on_disk_with_different_type(self, element_type: str, elem
)

def write_consolidated_metadata(self) -> None:
store = parse_url(self.path, mode="r+").store
# consolidate metadata to more easily support remote reading bug in zarr. In reality, 'zmetadata' is written
# instead of '.zmetadata' see discussion https://github.com/zarr-developers/zarr-python/issues/1121
zarr.consolidate_metadata(store, metadata_key=".zmetadata")
from spatialdata._io._utils import _open_zarr_store

store = _open_zarr_store(self.path)
# Note that the store can be local (which does not have the zmetadata bug)
# or a remote FSStore (which has the bug).
# Consolidate metadata to more easily support remote reading bug in zarr.
# We write 'zmetadata' instead of the standard '.zmetadata' to avoid the FSStore bug.
# See discussion https://github.com/zarr-developers/zarr-python/issues/1121
zarr.consolidate_metadata(store, metadata_key="zmetadata")
store.close()

def has_consolidated_metadata(self) -> bool:
from spatialdata._io._utils import _open_zarr_store

return_value = False
store = parse_url(self.path, mode="r").store
store = _open_zarr_store(self.path)
# Note that the store can be local (which does not have the zmetadata bug)
# or a remote FSStore (which has the bug).
if "zmetadata" in store:
return_value = True
store.close()
Expand DownExpand Up@@ -1575,15 +1589,11 @@ def write_transformations(self, element_name: str | None = None) -> None:
)
axes = get_axes_names(element)
if isinstance(element, DataArray | DataTree):
from spatialdata._io._utils import (
overwrite_coordinate_transformations_raster,
)
from spatialdata._io._utils import overwrite_coordinate_transformations_raster

overwrite_coordinate_transformations_raster(group=element_group, axes=axes, transformations=transformations)
elif isinstance(element, DaskDataFrame | GeoDataFrame | AnnData):
from spatialdata._io._utils import (
overwrite_coordinate_transformations_non_raster,
)
from spatialdata._io._utils import overwrite_coordinate_transformations_non_raster

overwrite_coordinate_transformations_non_raster(
group=element_group, axes=axes, transformations=transformations
Expand Down
53 changes: 48 additions & 5 deletions src/spatialdata/_io/_utils.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,14 +15,18 @@
from pathlib import Path
from typing import Any, Literal

import zarr
import zarr.storage
from anndata import AnnData
from dask.array import Array as DaskArray
from dask.dataframe import DataFrame as DaskDataFrame
from geopandas import GeoDataFrame
from upath import UPath
from upath.implementations.local import PosixUPath, WindowsUPath
from xarray import DataArray, DataTree
from zarr.storage import FSStore

from spatialdata._core.spatialdata import SpatialData
from spatialdata._types import StoreLike
from spatialdata._utils import get_pyramid_levels
from spatialdata.models._utils import (
MappingToCoordinateSystem_t,
Expand All@@ -31,10 +35,7 @@
_validate_mapping_to_coordinate_system_type,
)
from spatialdata.transformations.ngff.ngff_transformations import NgffBaseTransformation
from spatialdata.transformations.transformations import (
BaseTransformation,
_get_current_output_axes,
)
from spatialdata.transformations.transformations import BaseTransformation, _get_current_output_axes


# suppress logger debug from ome_zarr with context manager
Expand DownExpand Up@@ -388,6 +389,48 @@ def save_transformations(sdata: SpatialData) -> None:
sdata.write_transformations()


def _open_zarr_store(path: StoreLike, **kwargs: Any) -> zarr.storage.BaseStore:
# TODO: ensure kwargs like mode are enforced everywhere and passed correctly to the store
if isinstance(path, str | Path):
# if the input is str or Path, map it to UPath
path = UPath(path)
if isinstance(path, PosixUPath | WindowsUPath):
# if the input is a local path, use DirectoryStore
return zarr.storage.DirectoryStore(path.path, dimension_separator="/")
if isinstance(path, zarr.Group):
# if the input is a zarr.Group, wrap it with a store
if isinstance(path.store, zarr.storage.DirectoryStore):
# create a simple FSStore if the store is a DirectoryStore with just the path
return FSStore(os.path.join(path.store.path, path.path), **kwargs)
if isinstance(path.store, FSStore):
# if the store within the zarr.Group is an FSStore, return it
# but extend the path of the store with that of the zarr.Group
return FSStore(path.store.path + "/" + path.path, fs=path.store.fs, **kwargs)
if isinstance(path.store, zarr.storage.ConsolidatedMetadataStore):
# if the store is a ConsolidatedMetadataStore, just return the underlying FSSpec store
return path.store.store
raise ValueError(f"Unsupported store type or zarr.Group: {type(path.store)}")
if isinstance(path, zarr.storage.StoreLike):
# if the input already a store, wrap it in an FSStore
return FSStore(path, **kwargs)
if isinstance(path, UPath):
# if input is a remote UPath, map it to an FSStore
return FSStore(path.path, fs=path.fs, **kwargs)
raise TypeError(f"Unsupported type: {type(path)}")


def _create_upath(path: StoreLike) -> UPath | None:
# try to create a UPath from the input
if isinstance(path, zarr.storage.ConsolidatedMetadataStore):
path = path.store # get the fsstore from the consolidated store
if isinstance(path, FSStore):
protocol = path.fs.protocol if isinstance(path.fs.protocol, str) else path.fs.protocol[0]
return UPath(path.path, protocol=protocol, **path.fs.storage_options)
if isinstance(path, zarr.storage.DirectoryStore):
return UPath(path.path)
return None


class BadFileHandleMethod(Enum):
ERROR = "error"
WARN = "warn"
Expand Down
2 changes: 1 addition & 1 deletion src/spatialdata/_io/format.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -257,7 +257,7 @@ def validate_table(
def format_implementations() -> Iterator[Format]:
"""Return an instance of each format implementation, newest to oldest."""
yield RasterFormatV02()
# yield RasterFormatV01() # same format string as FormatV04
yield RasterFormatV01() # same format string as FormatV04
yield FormatV04()
yield FormatV03()
yield FormatV02()
Expand Down
19 changes: 6 additions & 13 deletions src/spatialdata/_io/io_points.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,27 +14,19 @@
)
from spatialdata._io.format import CurrentPointsFormat, PointsFormats, _parse_version
from spatialdata.models import get_axes_names
from spatialdata.transformations._utils import (
_get_transformations,
_set_transformations,
)
from spatialdata.transformations._utils import _get_transformations, _set_transformations


def _read_points(
store: str | Path | MutableMapping | zarr.Group, # type: ignore[type-arg]
) -> DaskDataFrame:
"""Read points from a zarr store."""
assert isinstance(store, str | Path)
f = zarr.open(store, mode="r")

f = zarr.open(store, mode="r") if isinstance(store, str | Path | MutableMapping) else store
version = _parse_version(f, expect_attrs_key=True)
assert version is not None
format = PointsFormats[version]

path = os.path.join(f._store.path, f.path, "points.parquet")
# cache on remote file needed for parquet reader to work
# TODO: allow reading in the metadata without caching all the data
points = read_parquet("simplecache::" + path if path.startswith("http") else path)
points = read_parquet(f.store.path, filesystem=getattr(f.store, "fs", None))
assert isinstance(points, DaskDataFrame)

transformations = _get_transformations_from_ngff_dict(f.attrs.asdict()["coordinateTransformations"])
Expand All@@ -57,7 +49,8 @@ def write_points(
t = _get_transformations(points)

points_groups = group.require_group(name)
path = Path(points_groups._store.path) / points_groups.path / "points.parquet"
store = points_groups._store
new_path = os.path.join(store.path, points_groups.path, "points.parquet")

# The following code iterates through all columns in the 'points' DataFrame. If the column's datatype is
# 'category', it checks whether the categories of this column are known. If not, it explicitly converts the
Expand All@@ -70,7 +63,7 @@ def write_points(
c = c.cat.as_known()
points[column_name] = c

points.to_parquet(path)
points.to_parquet(new_path, filesystem=getattr(store, "fs", None))

attrs = format.attrs_to_dict(points.attrs)
attrs["version"] = format.spatialdata_format_version
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
5718cba
add basic support and remote SpatialData tests
berombau Jan 24, 2025
a8be620
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jan 24, 2025
ce686e4
fix pre-commit
LucaMarconato Jan 31, 2025
e7fa020
Revert "Update pyproject.toml"
LucaMarconato Jan 31, 2025
a9c0801
removed 3.13 from test ci
LucaMarconato Jan 31, 2025
5cfdaec
Merge branch 'no_python_313' into remote2
LucaMarconato Jan 31, 2025
4fe6a47
fix
LucaMarconato Jan 31, 2025
d97a1d2
uploading sdata to local s3 storage
LucaMarconato Jan 31, 2025
5e26b5e
add _open_zarr_store
berombau Jan 31, 2025
5794871
revert changing write function signature
berombau Jan 31, 2025
0207ff7
update _open_zarr_store with StoreLike
berombau Jan 31, 2025
7e497ff
read image element from base store
berombau Jan 31, 2025
c674281
clean up remote mock tests, focus only on reading raster elements
berombau Feb 1, 2025
fb953a0
improve remote http test, add alternative
berombau Feb 1, 2025
52bb5fc
add support for consolidated metadata store in util function, add _cr…
berombau Feb 1, 2025
ca82493
allow for groups as store input
berombau Feb 1, 2025
ecea0e6
handle consolidated metadata with upath
berombau Feb 1, 2025
734eb45
split remote reading tests between http and http with consolidated me…
berombau Feb 1, 2025
c0ffb1c
remove f_store_path, support remote raster types fully and keep local…
berombau Feb 1, 2025
d60bd85
Fix metadata_key bug now that store is not always FSStore. Add extra …
berombau Feb 1, 2025
c3fa8cf
add mypy fixes
berombau Feb 1, 2025
d16a638
Merge branch 'main' into remote2
ap-- Mar 17, 2025
23f4a89
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Mar 17, 2025
a80588c
Fix linting errors
ap-- Mar 17, 2025
020810b
fixed majority of tests
ap-- Mar 17, 2025
ba25564
spatialdata._io._utils: _open_zarr_store has to set dimension_separat…
ap-- Mar 17, 2025
b2ff8f8
stay in sync with ome zarr format
ap-- Mar 17, 2025
70480ce
spatialdata._io.io_raster: support remote stores
ap-- Mar 17, 2025
10cef3f
prevent crashing tests on 3.10
ap-- Mar 17, 2025
d9e4eac
Merge branch 'spatial-data-crash310' into remote2
ap-- Mar 17, 2025
41825fb
spatialdata.io.io_shapes: fix support for remote shapes
ap-- Mar 18, 2025
b47838f
spatialdata.io.io_points: fix support for remote points
ap-- Mar 18, 2025
c2e2d0d
tests: update tests to use remote paths
ap-- Mar 18, 2025
28466fd
spatialdata._io._utils: support consolidated remote stores
ap-- Mar 18, 2025
1ae94c7
spatialdata._io.format: supprot remote embl datasets on s3
ap-- Mar 18, 2025
aed327d
spatialdata._io: test remote tables support and fix repr
ap-- Mar 18, 2025
437d0d3
Merge branch 'cloud-fix-remote-shapes' into cloud-future
ap-- Mar 18, 2025
2d7aa96
Merge branch 'cloud-fix-remote-points' into cloud-future
ap-- Mar 18, 2025
8335a99
Merge branch 'cloud-fix-remote-tables' into cloud-future
ap-- Mar 18, 2025
0fa1df9
Merge branch 'cloud-fix-remote-embl-datasets' into cloud-future
ap-- Mar 18, 2025
e9e2c7b
add support for remote write of non-raster elements
berombau Apr 4, 2025
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
7 changes: 5 additions & 2 deletions pyproject.toml
Original file line numberDiff line numberDiff line change
Expand Up@@ -26,20 +26,22 @@ dependencies = [
"click",
"dask-image",
"dask>=2024.4.1,<=2024.11.2",
"fsspec",
"fsspec[s3,http]",
"geopandas>=0.14",
"multiscale_spatial_image>=2.0.2",
"networkx",
"numba>=0.55.0",
"numpy",
"ome_zarr>=0.8.4",
"universal_pathlib>=0.2.6",
"pandas",
"pooch",
"pyarrow",
"rich",
"setuptools",
"shapely>=2.0.1",
"spatial_image>=1.1.0",
"spatial_image>=1.2.1",
"xarray-dataclasses>=1.9.1",
"scikit-image",
"scipy",
"typing_extensions>=4.8.0",
Expand All@@ -58,6 +60,7 @@ test = [
"pytest-cov",
"pytest-mock",
"torch",
"moto[s3,server]"
]
docs = [
"sphinx>=4.5",
Expand Down
76 changes: 43 additions & 33 deletions src/spatialdata/_core/spatialdata.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -30,11 +30,8 @@
validate_table_attr_keys,
)
from spatialdata._logging import logger
from spatialdata._types import ArrayLike, Raster_T
from spatialdata._utils import (
_deprecation_alias,
_error_message_add_element,
)
from spatialdata._types import ArrayLike, Raster_T, StoreLike
from spatialdata._utils import _deprecation_alias, _error_message_add_element
from spatialdata.models import (
Image2DModel,
Image3DModel,
Expand DownExpand Up@@ -601,7 +598,7 @@ def path(self, value: Path | None) -> None:
)

def _get_groups_for_element(
self, zarr_path: Path, element_type: str, element_name: str
self, zarr_path: StoreLike, element_type: str, element_name: str
) -> tuple[zarr.Group, zarr.Group, zarr.Group]:
"""
Get the Zarr groups for the root, element_type and element for a specific element.
Expand All@@ -621,9 +618,9 @@ def _get_groups_for_element(
-------
either the existing Zarr subgroup or a new one.
"""
if not isinstance(zarr_path, Path):
raise ValueError("zarr_path should be a Path object")
store = parse_url(zarr_path, mode="r+").store
from spatialdata._io._utils import _open_zarr_store

store = _open_zarr_store(zarr_path, mode="r+")
root = zarr.group(store=store)
if element_type not in ["images", "labels", "points", "polygons", "shapes", "tables"]:
raise ValueError(f"Unknown element type {element_type}")
Expand DownExpand Up@@ -1068,9 +1065,12 @@ def elements_paths_on_disk(self) -> list[str]:
-------
A list of paths of the elements saved in the Zarr store.
"""
from spatialdata._io._utils import _open_zarr_store

if self.path is None:
raise ValueError("The SpatialData object is not backed by a Zarr store.")
store = parse_url(self.path, mode="r").store

store = _open_zarr_store(self.path)
root = zarr.group(store=store)
elements_in_zarr = []

Expand DownExpand Up@@ -1175,7 +1175,7 @@ def _validate_all_elements(self) -> None:

def write(
self,
file_path: str | Path,
file_path: StoreLike,
overwrite: bool = False,
consolidate_metadata: bool = True,
format: SpatialDataFormat | list[SpatialDataFormat] | None = None,
Expand DownExpand Up@@ -1205,12 +1205,16 @@ def write(
:class:`~spatialdata._io.format.CurrentRasterFormat`, :class:`~spatialdata._io.format.CurrentShapesFormat`,
:class:`~spatialdata._io.format.CurrentPointsFormat`, :class:`~spatialdata._io.format.CurrentTablesFormat`.
"""
from spatialdata._io._utils import _open_zarr_store

if isinstance(file_path, str):
file_path = Path(file_path)
self._validate_can_safely_write_to_path(file_path, overwrite=overwrite)
self._validate_all_elements()
if isinstance(file_path, Path):
# TODO: also validate remote paths
self._validate_can_safely_write_to_path(file_path, overwrite=overwrite)
self._validate_all_elements()

store = parse_url(file_path, mode="w").store
store = _open_zarr_store(file_path, mode="w")
zarr_group = zarr.group(store=store, overwrite=overwrite)
self.write_attrs(zarr_group=zarr_group)
store.close()
Expand All@@ -1236,20 +1240,21 @@ def write(
def _write_element(
self,
element: SpatialElement | AnnData,
zarr_container_path: Path,
zarr_container_path: StoreLike,
element_type: str,
element_name: str,
overwrite: bool,
format: SpatialDataFormat | list[SpatialDataFormat] | None = None,
) -> None:
if not isinstance(zarr_container_path, Path):
if not isinstance(zarr_container_path, StoreLike):
raise ValueError(
f"zarr_container_path must be a Path object, type(zarr_container_path) = {type(zarr_container_path)}."
)
file_path_of_element = zarr_container_path / element_type / element_name
self._validate_can_safely_write_to_path(
file_path=file_path_of_element, overwrite=overwrite, saving_an_element=True
)
if isinstance(zarr_container_path, Path):
file_path_of_element = zarr_container_path / element_type / element_name
self._validate_can_safely_write_to_path(
file_path=file_path_of_element, overwrite=overwrite, saving_an_element=True
)

root_group, element_type_group, _ = self._get_groups_for_element(
zarr_path=zarr_container_path, element_type=element_type, element_name=element_name
Expand DownExpand Up@@ -1376,7 +1381,7 @@ def delete_element_from_disk(self, element_name: str | list[str]) -> None:
self.delete_element_from_disk(name)
return

from spatialdata._io._utils import _backed_elements_contained_in_path
from spatialdata._io._utils import _backed_elements_contained_in_path, _open_zarr_store

if self.path is None:
raise ValueError("The SpatialData object is not backed by a Zarr store.")
Expand DownExpand Up@@ -1417,7 +1422,7 @@ def delete_element_from_disk(self, element_name: str | list[str]) -> None:
)

# delete the element
store = parse_url(self.path, mode="r+").store
store = _open_zarr_store(self.path)
root = zarr.group(store=store)
root[element_type].pop(element_name)
store.close()
Expand All@@ -1438,15 +1443,24 @@ def _check_element_not_on_disk_with_different_type(self, element_type: str, elem
)

def write_consolidated_metadata(self) -> None:
store = parse_url(self.path, mode="r+").store
# consolidate metadata to more easily support remote reading bug in zarr. In reality, 'zmetadata' is written
# instead of '.zmetadata' see discussion https://github.com/zarr-developers/zarr-python/issues/1121
zarr.consolidate_metadata(store, metadata_key=".zmetadata")
from spatialdata._io._utils import _open_zarr_store

store = _open_zarr_store(self.path)
# Note that the store can be local (which does not have the zmetadata bug)
# or a remote FSStore (which has the bug).
# Consolidate metadata to more easily support remote reading bug in zarr.
# We write 'zmetadata' instead of the standard '.zmetadata' to avoid the FSStore bug.
# See discussion https://github.com/zarr-developers/zarr-python/issues/1121
zarr.consolidate_metadata(store, metadata_key="zmetadata")
store.close()

def has_consolidated_metadata(self) -> bool:
from spatialdata._io._utils import _open_zarr_store

return_value = False
store = parse_url(self.path, mode="r").store
store = _open_zarr_store(self.path)
# Note that the store can be local (which does not have the zmetadata bug)
# or a remote FSStore (which has the bug).
if "zmetadata" in store:
return_value = True
store.close()
Expand DownExpand Up@@ -1575,15 +1589,11 @@ def write_transformations(self, element_name: str | None = None) -> None:
)
axes = get_axes_names(element)
if isinstance(element, DataArray | DataTree):
from spatialdata._io._utils import (
overwrite_coordinate_transformations_raster,
)
from spatialdata._io._utils import overwrite_coordinate_transformations_raster

overwrite_coordinate_transformations_raster(group=element_group, axes=axes, transformations=transformations)
elif isinstance(element, DaskDataFrame | GeoDataFrame | AnnData):
from spatialdata._io._utils import (
overwrite_coordinate_transformations_non_raster,
)
from spatialdata._io._utils import overwrite_coordinate_transformations_non_raster

overwrite_coordinate_transformations_non_raster(
group=element_group, axes=axes, transformations=transformations
Expand Down
53 changes: 48 additions & 5 deletions src/spatialdata/_io/_utils.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,14 +15,18 @@
from pathlib import Path
from typing import Any, Literal

import zarr
import zarr.storage
from anndata import AnnData
from dask.array import Array as DaskArray
from dask.dataframe import DataFrame as DaskDataFrame
from geopandas import GeoDataFrame
from upath import UPath
from upath.implementations.local import PosixUPath, WindowsUPath
from xarray import DataArray, DataTree
from zarr.storage import FSStore

from spatialdata._core.spatialdata import SpatialData
from spatialdata._types import StoreLike
from spatialdata._utils import get_pyramid_levels
from spatialdata.models._utils import (
MappingToCoordinateSystem_t,
Expand All@@ -31,10 +35,7 @@
_validate_mapping_to_coordinate_system_type,
)
from spatialdata.transformations.ngff.ngff_transformations import NgffBaseTransformation
from spatialdata.transformations.transformations import (
BaseTransformation,
_get_current_output_axes,
)
from spatialdata.transformations.transformations import BaseTransformation, _get_current_output_axes


# suppress logger debug from ome_zarr with context manager
Expand DownExpand Up@@ -388,6 +389,48 @@ def save_transformations(sdata: SpatialData) -> None:
sdata.write_transformations()


def _open_zarr_store(path: StoreLike, **kwargs: Any) -> zarr.storage.BaseStore:
# TODO: ensure kwargs like mode are enforced everywhere and passed correctly to the store
if isinstance(path, str | Path):
# if the input is str or Path, map it to UPath
path = UPath(path)
if isinstance(path, PosixUPath | WindowsUPath):
# if the input is a local path, use DirectoryStore
return zarr.storage.DirectoryStore(path.path, dimension_separator="/")
if isinstance(path, zarr.Group):
# if the input is a zarr.Group, wrap it with a store
if isinstance(path.store, zarr.storage.DirectoryStore):
# create a simple FSStore if the store is a DirectoryStore with just the path
return FSStore(os.path.join(path.store.path, path.path), **kwargs)
if isinstance(path.store, FSStore):
# if the store within the zarr.Group is an FSStore, return it
# but extend the path of the store with that of the zarr.Group
return FSStore(path.store.path + "/" + path.path, fs=path.store.fs, **kwargs)
if isinstance(path.store, zarr.storage.ConsolidatedMetadataStore):
# if the store is a ConsolidatedMetadataStore, just return the underlying FSSpec store
return path.store.store
raise ValueError(f"Unsupported store type or zarr.Group: {type(path.store)}")
if isinstance(path, zarr.storage.StoreLike):
# if the input already a store, wrap it in an FSStore
return FSStore(path, **kwargs)
if isinstance(path, UPath):
# if input is a remote UPath, map it to an FSStore
return FSStore(path.path, fs=path.fs, **kwargs)
raise TypeError(f"Unsupported type: {type(path)}")


def _create_upath(path: StoreLike) -> UPath | None:
# try to create a UPath from the input
if isinstance(path, zarr.storage.ConsolidatedMetadataStore):
path = path.store # get the fsstore from the consolidated store
if isinstance(path, FSStore):
protocol = path.fs.protocol if isinstance(path.fs.protocol, str) else path.fs.protocol[0]
return UPath(path.path, protocol=protocol, **path.fs.storage_options)
if isinstance(path, zarr.storage.DirectoryStore):
return UPath(path.path)
return None


class BadFileHandleMethod(Enum):
ERROR = "error"
WARN = "warn"
Expand Down
2 changes: 1 addition & 1 deletion src/spatialdata/_io/format.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -257,7 +257,7 @@ def validate_table(
def format_implementations() -> Iterator[Format]:
"""Return an instance of each format implementation, newest to oldest."""
yield RasterFormatV02()
# yield RasterFormatV01() # same format string as FormatV04
yield RasterFormatV01() # same format string as FormatV04
yield FormatV04()
yield FormatV03()
yield FormatV02()
Expand Down
19 changes: 6 additions & 13 deletions src/spatialdata/_io/io_points.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,27 +14,19 @@
)
from spatialdata._io.format import CurrentPointsFormat, PointsFormats, _parse_version
from spatialdata.models import get_axes_names
from spatialdata.transformations._utils import (
_get_transformations,
_set_transformations,
)
from spatialdata.transformations._utils import _get_transformations, _set_transformations


def _read_points(
store: str | Path | MutableMapping | zarr.Group, # type: ignore[type-arg]
) -> DaskDataFrame:
"""Read points from a zarr store."""
assert isinstance(store, str | Path)
f = zarr.open(store, mode="r")

f = zarr.open(store, mode="r") if isinstance(store, str | Path | MutableMapping) else store
version = _parse_version(f, expect_attrs_key=True)
assert version is not None
format = PointsFormats[version]

path = os.path.join(f._store.path, f.path, "points.parquet")
# cache on remote file needed for parquet reader to work
# TODO: allow reading in the metadata without caching all the data
points = read_parquet("simplecache::" + path if path.startswith("http") else path)
points = read_parquet(f.store.path, filesystem=getattr(f.store, "fs", None))
assert isinstance(points, DaskDataFrame)

transformations = _get_transformations_from_ngff_dict(f.attrs.asdict()["coordinateTransformations"])
Expand All@@ -57,7 +49,8 @@ def write_points(
t = _get_transformations(points)

points_groups = group.require_group(name)
path = Path(points_groups._store.path) / points_groups.path / "points.parquet"
store = points_groups._store
new_path = os.path.join(store.path, points_groups.path, "points.parquet")

# The following code iterates through all columns in the 'points' DataFrame. If the column's datatype is
# 'category', it checks whether the categories of this column are known. If not, it explicitly converts the
Expand All@@ -70,7 +63,7 @@ def write_points(
c = c.cat.as_known()
points[column_name] = c

points.to_parquet(path)
points.to_parquet(new_path, filesystem=getattr(store, "fs", None))

attrs = format.attrs_to_dict(points.attrs)
attrs["version"] = format.spatialdata_format_version
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
5718cba
add basic support and remote SpatialData tests
berombau Jan 24, 2025
a8be620
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jan 24, 2025
ce686e4
fix pre-commit
LucaMarconato Jan 31, 2025
e7fa020
Revert "Update pyproject.toml"
LucaMarconato Jan 31, 2025
a9c0801
removed 3.13 from test ci
LucaMarconato Jan 31, 2025
5cfdaec
Merge branch 'no_python_313' into remote2
LucaMarconato Jan 31, 2025
4fe6a47
fix
LucaMarconato Jan 31, 2025
d97a1d2
uploading sdata to local s3 storage
LucaMarconato Jan 31, 2025
5e26b5e
add _open_zarr_store
berombau Jan 31, 2025
5794871
revert changing write function signature
berombau Jan 31, 2025
0207ff7
update _open_zarr_store with StoreLike
berombau Jan 31, 2025
7e497ff
read image element from base store
berombau Jan 31, 2025
c674281
clean up remote mock tests, focus only on reading raster elements
berombau Feb 1, 2025
fb953a0
improve remote http test, add alternative
berombau Feb 1, 2025
52bb5fc
add support for consolidated metadata store in util function, add _cr…
berombau Feb 1, 2025
ca82493
allow for groups as store input
berombau Feb 1, 2025
ecea0e6
handle consolidated metadata with upath
berombau Feb 1, 2025
734eb45
split remote reading tests between http and http with consolidated me…
berombau Feb 1, 2025
c0ffb1c
remove f_store_path, support remote raster types fully and keep local…
berombau Feb 1, 2025
d60bd85
Fix metadata_key bug now that store is not always FSStore. Add extra …
berombau Feb 1, 2025
c3fa8cf
add mypy fixes
berombau Feb 1, 2025
d16a638
Merge branch 'main' into remote2
ap-- Mar 17, 2025
23f4a89
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Mar 17, 2025
a80588c
Fix linting errors
ap-- Mar 17, 2025
020810b
fixed majority of tests
ap-- Mar 17, 2025
ba25564
spatialdata._io._utils: _open_zarr_store has to set dimension_separat…
ap-- Mar 17, 2025
b2ff8f8
stay in sync with ome zarr format
ap-- Mar 17, 2025
70480ce
spatialdata._io.io_raster: support remote stores
ap-- Mar 17, 2025
10cef3f
prevent crashing tests on 3.10
ap-- Mar 17, 2025
d9e4eac
Merge branch 'spatial-data-crash310' into remote2
ap-- Mar 17, 2025
41825fb
spatialdata.io.io_shapes: fix support for remote shapes
ap-- Mar 18, 2025
b47838f
spatialdata.io.io_points: fix support for remote points
ap-- Mar 18, 2025
c2e2d0d
tests: update tests to use remote paths
ap-- Mar 18, 2025
28466fd
spatialdata._io._utils: support consolidated remote stores
ap-- Mar 18, 2025
1ae94c7
spatialdata._io.format: supprot remote embl datasets on s3
ap-- Mar 18, 2025
aed327d
spatialdata._io: test remote tables support and fix repr
ap-- Mar 18, 2025
437d0d3
Merge branch 'cloud-fix-remote-shapes' into cloud-future
ap-- Mar 18, 2025
2d7aa96
Merge branch 'cloud-fix-remote-points' into cloud-future
ap-- Mar 18, 2025
8335a99
Merge branch 'cloud-fix-remote-tables' into cloud-future
ap-- Mar 18, 2025
0fa1df9
Merge branch 'cloud-fix-remote-embl-datasets' into cloud-future
ap-- Mar 18, 2025
e9e2c7b
add support for remote write of non-raster elements
berombau Apr 4, 2025
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
7 changes: 5 additions & 2 deletions pyproject.toml
Original file line numberDiff line numberDiff line change
Expand Up@@ -26,20 +26,22 @@ dependencies = [
"click",
"dask-image",
"dask>=2024.4.1,<=2024.11.2",
"fsspec",
"fsspec[s3,http]",
"geopandas>=0.14",
"multiscale_spatial_image>=2.0.2",
"networkx",
"numba>=0.55.0",
"numpy",
"ome_zarr>=0.8.4",
"universal_pathlib>=0.2.6",
"pandas",
"pooch",
"pyarrow",
"rich",
"setuptools",
"shapely>=2.0.1",
"spatial_image>=1.1.0",
"spatial_image>=1.2.1",
"xarray-dataclasses>=1.9.1",
"scikit-image",
"scipy",
"typing_extensions>=4.8.0",
Expand All@@ -58,6 +60,7 @@ test = [
"pytest-cov",
"pytest-mock",
"torch",
"moto[s3,server]"
]
docs = [
"sphinx>=4.5",
Expand Down
76 changes: 43 additions & 33 deletions src/spatialdata/_core/spatialdata.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -30,11 +30,8 @@
validate_table_attr_keys,
)
from spatialdata._logging import logger
from spatialdata._types import ArrayLike, Raster_T
from spatialdata._utils import (
_deprecation_alias,
_error_message_add_element,
)
from spatialdata._types import ArrayLike, Raster_T, StoreLike
from spatialdata._utils import _deprecation_alias, _error_message_add_element
from spatialdata.models import (
Image2DModel,
Image3DModel,
Expand DownExpand Up@@ -601,7 +598,7 @@ def path(self, value: Path | None) -> None:
)

def _get_groups_for_element(
self, zarr_path: Path, element_type: str, element_name: str
self, zarr_path: StoreLike, element_type: str, element_name: str
) -> tuple[zarr.Group, zarr.Group, zarr.Group]:
"""
Get the Zarr groups for the root, element_type and element for a specific element.
Expand All@@ -621,9 +618,9 @@ def _get_groups_for_element(
-------
either the existing Zarr subgroup or a new one.
"""
if not isinstance(zarr_path, Path):
raise ValueError("zarr_path should be a Path object")
store = parse_url(zarr_path, mode="r+").store
from spatialdata._io._utils import _open_zarr_store

store = _open_zarr_store(zarr_path, mode="r+")
root = zarr.group(store=store)
if element_type not in ["images", "labels", "points", "polygons", "shapes", "tables"]:
raise ValueError(f"Unknown element type {element_type}")
Expand DownExpand Up@@ -1068,9 +1065,12 @@ def elements_paths_on_disk(self) -> list[str]:
-------
A list of paths of the elements saved in the Zarr store.
"""
from spatialdata._io._utils import _open_zarr_store

if self.path is None:
raise ValueError("The SpatialData object is not backed by a Zarr store.")
store = parse_url(self.path, mode="r").store

store = _open_zarr_store(self.path)
root = zarr.group(store=store)
elements_in_zarr = []

Expand DownExpand Up@@ -1175,7 +1175,7 @@ def _validate_all_elements(self) -> None:

def write(
self,
file_path: str | Path,
file_path: StoreLike,
overwrite: bool = False,
consolidate_metadata: bool = True,
format: SpatialDataFormat | list[SpatialDataFormat] | None = None,
Expand DownExpand Up@@ -1205,12 +1205,16 @@ def write(
:class:`~spatialdata._io.format.CurrentRasterFormat`, :class:`~spatialdata._io.format.CurrentShapesFormat`,
:class:`~spatialdata._io.format.CurrentPointsFormat`, :class:`~spatialdata._io.format.CurrentTablesFormat`.
"""
from spatialdata._io._utils import _open_zarr_store

if isinstance(file_path, str):
file_path = Path(file_path)
self._validate_can_safely_write_to_path(file_path, overwrite=overwrite)
self._validate_all_elements()
if isinstance(file_path, Path):
# TODO: also validate remote paths
self._validate_can_safely_write_to_path(file_path, overwrite=overwrite)
self._validate_all_elements()

store = parse_url(file_path, mode="w").store
store = _open_zarr_store(file_path, mode="w")
zarr_group = zarr.group(store=store, overwrite=overwrite)
self.write_attrs(zarr_group=zarr_group)
store.close()
Expand All@@ -1236,20 +1240,21 @@ def write(
def _write_element(
self,
element: SpatialElement | AnnData,
zarr_container_path: Path,
zarr_container_path: StoreLike,
element_type: str,
element_name: str,
overwrite: bool,
format: SpatialDataFormat | list[SpatialDataFormat] | None = None,
) -> None:
if not isinstance(zarr_container_path, Path):
if not isinstance(zarr_container_path, StoreLike):
raise ValueError(
f"zarr_container_path must be a Path object, type(zarr_container_path) = {type(zarr_container_path)}."
)
file_path_of_element = zarr_container_path / element_type / element_name
self._validate_can_safely_write_to_path(
file_path=file_path_of_element, overwrite=overwrite, saving_an_element=True
)
if isinstance(zarr_container_path, Path):
file_path_of_element = zarr_container_path / element_type / element_name
self._validate_can_safely_write_to_path(
file_path=file_path_of_element, overwrite=overwrite, saving_an_element=True
)

root_group, element_type_group, _ = self._get_groups_for_element(
zarr_path=zarr_container_path, element_type=element_type, element_name=element_name
Expand DownExpand Up@@ -1376,7 +1381,7 @@ def delete_element_from_disk(self, element_name: str | list[str]) -> None:
self.delete_element_from_disk(name)
return

from spatialdata._io._utils import _backed_elements_contained_in_path
from spatialdata._io._utils import _backed_elements_contained_in_path, _open_zarr_store

if self.path is None:
raise ValueError("The SpatialData object is not backed by a Zarr store.")
Expand DownExpand Up@@ -1417,7 +1422,7 @@ def delete_element_from_disk(self, element_name: str | list[str]) -> None:
)

# delete the element
store = parse_url(self.path, mode="r+").store
store = _open_zarr_store(self.path)
root = zarr.group(store=store)
root[element_type].pop(element_name)
store.close()
Expand All@@ -1438,15 +1443,24 @@ def _check_element_not_on_disk_with_different_type(self, element_type: str, elem
)

def write_consolidated_metadata(self) -> None:
store = parse_url(self.path, mode="r+").store
# consolidate metadata to more easily support remote reading bug in zarr. In reality, 'zmetadata' is written
# instead of '.zmetadata' see discussion https://github.com/zarr-developers/zarr-python/issues/1121
zarr.consolidate_metadata(store, metadata_key=".zmetadata")
from spatialdata._io._utils import _open_zarr_store

store = _open_zarr_store(self.path)
# Note that the store can be local (which does not have the zmetadata bug)
# or a remote FSStore (which has the bug).
# Consolidate metadata to more easily support remote reading bug in zarr.
# We write 'zmetadata' instead of the standard '.zmetadata' to avoid the FSStore bug.
# See discussion https://github.com/zarr-developers/zarr-python/issues/1121
zarr.consolidate_metadata(store, metadata_key="zmetadata")
store.close()

def has_consolidated_metadata(self) -> bool:
from spatialdata._io._utils import _open_zarr_store

return_value = False
store = parse_url(self.path, mode="r").store
store = _open_zarr_store(self.path)
# Note that the store can be local (which does not have the zmetadata bug)
# or a remote FSStore (which has the bug).
if "zmetadata" in store:
return_value = True
store.close()
Expand DownExpand Up@@ -1575,15 +1589,11 @@ def write_transformations(self, element_name: str | None = None) -> None:
)
axes = get_axes_names(element)
if isinstance(element, DataArray | DataTree):
from spatialdata._io._utils import (
overwrite_coordinate_transformations_raster,
)
from spatialdata._io._utils import overwrite_coordinate_transformations_raster

overwrite_coordinate_transformations_raster(group=element_group, axes=axes, transformations=transformations)
elif isinstance(element, DaskDataFrame | GeoDataFrame | AnnData):
from spatialdata._io._utils import (
overwrite_coordinate_transformations_non_raster,
)
from spatialdata._io._utils import overwrite_coordinate_transformations_non_raster

overwrite_coordinate_transformations_non_raster(
group=element_group, axes=axes, transformations=transformations
Expand Down
53 changes: 48 additions & 5 deletions src/spatialdata/_io/_utils.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,14 +15,18 @@
from pathlib import Path
from typing import Any, Literal

import zarr
import zarr.storage
from anndata import AnnData
from dask.array import Array as DaskArray
from dask.dataframe import DataFrame as DaskDataFrame
from geopandas import GeoDataFrame
from upath import UPath
from upath.implementations.local import PosixUPath, WindowsUPath
from xarray import DataArray, DataTree
from zarr.storage import FSStore

from spatialdata._core.spatialdata import SpatialData
from spatialdata._types import StoreLike
from spatialdata._utils import get_pyramid_levels
from spatialdata.models._utils import (
MappingToCoordinateSystem_t,
Expand All@@ -31,10 +35,7 @@
_validate_mapping_to_coordinate_system_type,
)
from spatialdata.transformations.ngff.ngff_transformations import NgffBaseTransformation
from spatialdata.transformations.transformations import (
BaseTransformation,
_get_current_output_axes,
)
from spatialdata.transformations.transformations import BaseTransformation, _get_current_output_axes


# suppress logger debug from ome_zarr with context manager
Expand DownExpand Up@@ -388,6 +389,48 @@ def save_transformations(sdata: SpatialData) -> None:
sdata.write_transformations()


def _open_zarr_store(path: StoreLike, **kwargs: Any) -> zarr.storage.BaseStore:
# TODO: ensure kwargs like mode are enforced everywhere and passed correctly to the store
if isinstance(path, str | Path):
# if the input is str or Path, map it to UPath
path = UPath(path)
if isinstance(path, PosixUPath | WindowsUPath):
# if the input is a local path, use DirectoryStore
return zarr.storage.DirectoryStore(path.path, dimension_separator="/")
if isinstance(path, zarr.Group):
# if the input is a zarr.Group, wrap it with a store
if isinstance(path.store, zarr.storage.DirectoryStore):
# create a simple FSStore if the store is a DirectoryStore with just the path
return FSStore(os.path.join(path.store.path, path.path), **kwargs)
if isinstance(path.store, FSStore):
# if the store within the zarr.Group is an FSStore, return it
# but extend the path of the store with that of the zarr.Group
return FSStore(path.store.path + "/" + path.path, fs=path.store.fs, **kwargs)
if isinstance(path.store, zarr.storage.ConsolidatedMetadataStore):
# if the store is a ConsolidatedMetadataStore, just return the underlying FSSpec store
return path.store.store
raise ValueError(f"Unsupported store type or zarr.Group: {type(path.store)}")
if isinstance(path, zarr.storage.StoreLike):
# if the input already a store, wrap it in an FSStore
return FSStore(path, **kwargs)
if isinstance(path, UPath):
# if input is a remote UPath, map it to an FSStore
return FSStore(path.path, fs=path.fs, **kwargs)
raise TypeError(f"Unsupported type: {type(path)}")


def _create_upath(path: StoreLike) -> UPath | None:
# try to create a UPath from the input
if isinstance(path, zarr.storage.ConsolidatedMetadataStore):
path = path.store # get the fsstore from the consolidated store
if isinstance(path, FSStore):
protocol = path.fs.protocol if isinstance(path.fs.protocol, str) else path.fs.protocol[0]
return UPath(path.path, protocol=protocol, **path.fs.storage_options)
if isinstance(path, zarr.storage.DirectoryStore):
return UPath(path.path)
return None


class BadFileHandleMethod(Enum):
ERROR = "error"
WARN = "warn"
Expand Down
2 changes: 1 addition & 1 deletion src/spatialdata/_io/format.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -257,7 +257,7 @@ def validate_table(
def format_implementations() -> Iterator[Format]:
"""Return an instance of each format implementation, newest to oldest."""
yield RasterFormatV02()
# yield RasterFormatV01() # same format string as FormatV04
yield RasterFormatV01() # same format string as FormatV04
yield FormatV04()
yield FormatV03()
yield FormatV02()
Expand Down
19 changes: 6 additions & 13 deletions src/spatialdata/_io/io_points.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,27 +14,19 @@
)
from spatialdata._io.format import CurrentPointsFormat, PointsFormats, _parse_version
from spatialdata.models import get_axes_names
from spatialdata.transformations._utils import (
_get_transformations,
_set_transformations,
)
from spatialdata.transformations._utils import _get_transformations, _set_transformations


def _read_points(
store: str | Path | MutableMapping | zarr.Group, # type: ignore[type-arg]
) -> DaskDataFrame:
"""Read points from a zarr store."""
assert isinstance(store, str | Path)
f = zarr.open(store, mode="r")

f = zarr.open(store, mode="r") if isinstance(store, str | Path | MutableMapping) else store
version = _parse_version(f, expect_attrs_key=True)
assert version is not None
format = PointsFormats[version]

path = os.path.join(f._store.path, f.path, "points.parquet")
# cache on remote file needed for parquet reader to work
# TODO: allow reading in the metadata without caching all the data
points = read_parquet("simplecache::" + path if path.startswith("http") else path)
points = read_parquet(f.store.path, filesystem=getattr(f.store, "fs", None))
assert isinstance(points, DaskDataFrame)

transformations = _get_transformations_from_ngff_dict(f.attrs.asdict()["coordinateTransformations"])
Expand All@@ -57,7 +49,8 @@ def write_points(
t = _get_transformations(points)

points_groups = group.require_group(name)
path = Path(points_groups._store.path) / points_groups.path / "points.parquet"
store = points_groups._store
new_path = os.path.join(store.path, points_groups.path, "points.parquet")

# The following code iterates through all columns in the 'points' DataFrame. If the column's datatype is
# 'category', it checks whether the categories of this column are known. If not, it explicitly converts the
Expand All@@ -70,7 +63,7 @@ def write_points(
c = c.cat.as_known()
points[column_name] = c

points.to_parquet(path)
points.to_parquet(new_path, filesystem=getattr(store, "fs", None))

attrs = format.attrs_to_dict(points.attrs)
attrs["version"] = format.spatialdata_format_version
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
5718cba
add basic support and remote SpatialData tests
berombau Jan 24, 2025
a8be620
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jan 24, 2025
ce686e4
fix pre-commit
LucaMarconato Jan 31, 2025
e7fa020
Revert "Update pyproject.toml"
LucaMarconato Jan 31, 2025
a9c0801
removed 3.13 from test ci
LucaMarconato Jan 31, 2025
5cfdaec
Merge branch 'no_python_313' into remote2
LucaMarconato Jan 31, 2025
4fe6a47
fix
LucaMarconato Jan 31, 2025
d97a1d2
uploading sdata to local s3 storage
LucaMarconato Jan 31, 2025
5e26b5e
add _open_zarr_store
berombau Jan 31, 2025
5794871
revert changing write function signature
berombau Jan 31, 2025
0207ff7
update _open_zarr_store with StoreLike
berombau Jan 31, 2025
7e497ff
read image element from base store
berombau Jan 31, 2025
c674281
clean up remote mock tests, focus only on reading raster elements
berombau Feb 1, 2025
fb953a0
improve remote http test, add alternative
berombau Feb 1, 2025
52bb5fc
add support for consolidated metadata store in util function, add _cr…
berombau Feb 1, 2025
ca82493
allow for groups as store input
berombau Feb 1, 2025
ecea0e6
handle consolidated metadata with upath
berombau Feb 1, 2025
734eb45
split remote reading tests between http and http with consolidated me…
berombau Feb 1, 2025
c0ffb1c
remove f_store_path, support remote raster types fully and keep local…
berombau Feb 1, 2025
d60bd85
Fix metadata_key bug now that store is not always FSStore. Add extra …
berombau Feb 1, 2025
c3fa8cf
add mypy fixes
berombau Feb 1, 2025
d16a638
Merge branch 'main' into remote2
ap-- Mar 17, 2025
23f4a89
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Mar 17, 2025
a80588c
Fix linting errors
ap-- Mar 17, 2025
020810b
fixed majority of tests
ap-- Mar 17, 2025
ba25564
spatialdata._io._utils: _open_zarr_store has to set dimension_separat…
ap-- Mar 17, 2025
b2ff8f8
stay in sync with ome zarr format
ap-- Mar 17, 2025
70480ce
spatialdata._io.io_raster: support remote stores
ap-- Mar 17, 2025
10cef3f
prevent crashing tests on 3.10
ap-- Mar 17, 2025
d9e4eac
Merge branch 'spatial-data-crash310' into remote2
ap-- Mar 17, 2025
41825fb
spatialdata.io.io_shapes: fix support for remote shapes
ap-- Mar 18, 2025
b47838f
spatialdata.io.io_points: fix support for remote points
ap-- Mar 18, 2025
c2e2d0d
tests: update tests to use remote paths
ap-- Mar 18, 2025
28466fd
spatialdata._io._utils: support consolidated remote stores
ap-- Mar 18, 2025
1ae94c7
spatialdata._io.format: supprot remote embl datasets on s3
ap-- Mar 18, 2025
aed327d
spatialdata._io: test remote tables support and fix repr
ap-- Mar 18, 2025
437d0d3
Merge branch 'cloud-fix-remote-shapes' into cloud-future
ap-- Mar 18, 2025
2d7aa96
Merge branch 'cloud-fix-remote-points' into cloud-future
ap-- Mar 18, 2025
8335a99
Merge branch 'cloud-fix-remote-tables' into cloud-future
ap-- Mar 18, 2025
0fa1df9
Merge branch 'cloud-fix-remote-embl-datasets' into cloud-future
ap-- Mar 18, 2025
e9e2c7b
add support for remote write of non-raster elements
berombau Apr 4, 2025
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
7 changes: 5 additions & 2 deletions pyproject.toml
Original file line numberDiff line numberDiff line change
Expand Up@@ -26,20 +26,22 @@ dependencies = [
"click",
"dask-image",
"dask>=2024.4.1,<=2024.11.2",
"fsspec",
"fsspec[s3,http]",
"geopandas>=0.14",
"multiscale_spatial_image>=2.0.2",
"networkx",
"numba>=0.55.0",
"numpy",
"ome_zarr>=0.8.4",
"universal_pathlib>=0.2.6",
"pandas",
"pooch",
"pyarrow",
"rich",
"setuptools",
"shapely>=2.0.1",
"spatial_image>=1.1.0",
"spatial_image>=1.2.1",
"xarray-dataclasses>=1.9.1",
"scikit-image",
"scipy",
"typing_extensions>=4.8.0",
Expand All@@ -58,6 +60,7 @@ test = [
"pytest-cov",
"pytest-mock",
"torch",
"moto[s3,server]"
]
docs = [
"sphinx>=4.5",
Expand Down
76 changes: 43 additions & 33 deletions src/spatialdata/_core/spatialdata.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -30,11 +30,8 @@
validate_table_attr_keys,
)
from spatialdata._logging import logger
from spatialdata._types import ArrayLike, Raster_T
from spatialdata._utils import (
_deprecation_alias,
_error_message_add_element,
)
from spatialdata._types import ArrayLike, Raster_T, StoreLike
from spatialdata._utils import _deprecation_alias, _error_message_add_element
from spatialdata.models import (
Image2DModel,
Image3DModel,
Expand DownExpand Up@@ -601,7 +598,7 @@ def path(self, value: Path | None) -> None:
)

def _get_groups_for_element(
self, zarr_path: Path, element_type: str, element_name: str
self, zarr_path: StoreLike, element_type: str, element_name: str
) -> tuple[zarr.Group, zarr.Group, zarr.Group]:
"""
Get the Zarr groups for the root, element_type and element for a specific element.
Expand All@@ -621,9 +618,9 @@ def _get_groups_for_element(
-------
either the existing Zarr subgroup or a new one.
"""
if not isinstance(zarr_path, Path):
raise ValueError("zarr_path should be a Path object")
store = parse_url(zarr_path, mode="r+").store
from spatialdata._io._utils import _open_zarr_store

store = _open_zarr_store(zarr_path, mode="r+")
root = zarr.group(store=store)
if element_type not in ["images", "labels", "points", "polygons", "shapes", "tables"]:
raise ValueError(f"Unknown element type {element_type}")
Expand DownExpand Up@@ -1068,9 +1065,12 @@ def elements_paths_on_disk(self) -> list[str]:
-------
A list of paths of the elements saved in the Zarr store.
"""
from spatialdata._io._utils import _open_zarr_store

if self.path is None:
raise ValueError("The SpatialData object is not backed by a Zarr store.")
store = parse_url(self.path, mode="r").store

store = _open_zarr_store(self.path)
root = zarr.group(store=store)
elements_in_zarr = []

Expand DownExpand Up@@ -1175,7 +1175,7 @@ def _validate_all_elements(self) -> None:

def write(
self,
file_path: str | Path,
file_path: StoreLike,
overwrite: bool = False,
consolidate_metadata: bool = True,
format: SpatialDataFormat | list[SpatialDataFormat] | None = None,
Expand DownExpand Up@@ -1205,12 +1205,16 @@ def write(
:class:`~spatialdata._io.format.CurrentRasterFormat`, :class:`~spatialdata._io.format.CurrentShapesFormat`,
:class:`~spatialdata._io.format.CurrentPointsFormat`, :class:`~spatialdata._io.format.CurrentTablesFormat`.
"""
from spatialdata._io._utils import _open_zarr_store

if isinstance(file_path, str):
file_path = Path(file_path)
self._validate_can_safely_write_to_path(file_path, overwrite=overwrite)
self._validate_all_elements()
if isinstance(file_path, Path):
# TODO: also validate remote paths
self._validate_can_safely_write_to_path(file_path, overwrite=overwrite)
self._validate_all_elements()

store = parse_url(file_path, mode="w").store
store = _open_zarr_store(file_path, mode="w")
zarr_group = zarr.group(store=store, overwrite=overwrite)
self.write_attrs(zarr_group=zarr_group)
store.close()
Expand All@@ -1236,20 +1240,21 @@ def write(
def _write_element(
self,
element: SpatialElement | AnnData,
zarr_container_path: Path,
zarr_container_path: StoreLike,
element_type: str,
element_name: str,
overwrite: bool,
format: SpatialDataFormat | list[SpatialDataFormat] | None = None,
) -> None:
if not isinstance(zarr_container_path, Path):
if not isinstance(zarr_container_path, StoreLike):
raise ValueError(
f"zarr_container_path must be a Path object, type(zarr_container_path) = {type(zarr_container_path)}."
)
file_path_of_element = zarr_container_path / element_type / element_name
self._validate_can_safely_write_to_path(
file_path=file_path_of_element, overwrite=overwrite, saving_an_element=True
)
if isinstance(zarr_container_path, Path):
file_path_of_element = zarr_container_path / element_type / element_name
self._validate_can_safely_write_to_path(
file_path=file_path_of_element, overwrite=overwrite, saving_an_element=True
)

root_group, element_type_group, _ = self._get_groups_for_element(
zarr_path=zarr_container_path, element_type=element_type, element_name=element_name
Expand DownExpand Up@@ -1376,7 +1381,7 @@ def delete_element_from_disk(self, element_name: str | list[str]) -> None:
self.delete_element_from_disk(name)
return

from spatialdata._io._utils import _backed_elements_contained_in_path
from spatialdata._io._utils import _backed_elements_contained_in_path, _open_zarr_store

if self.path is None:
raise ValueError("The SpatialData object is not backed by a Zarr store.")
Expand DownExpand Up@@ -1417,7 +1422,7 @@ def delete_element_from_disk(self, element_name: str | list[str]) -> None:
)

# delete the element
store = parse_url(self.path, mode="r+").store
store = _open_zarr_store(self.path)
root = zarr.group(store=store)
root[element_type].pop(element_name)
store.close()
Expand All@@ -1438,15 +1443,24 @@ def _check_element_not_on_disk_with_different_type(self, element_type: str, elem
)

def write_consolidated_metadata(self) -> None:
store = parse_url(self.path, mode="r+").store
# consolidate metadata to more easily support remote reading bug in zarr. In reality, 'zmetadata' is written
# instead of '.zmetadata' see discussion https://github.com/zarr-developers/zarr-python/issues/1121
zarr.consolidate_metadata(store, metadata_key=".zmetadata")
from spatialdata._io._utils import _open_zarr_store

store = _open_zarr_store(self.path)
# Note that the store can be local (which does not have the zmetadata bug)
# or a remote FSStore (which has the bug).
# Consolidate metadata to more easily support remote reading bug in zarr.
# We write 'zmetadata' instead of the standard '.zmetadata' to avoid the FSStore bug.
# See discussion https://github.com/zarr-developers/zarr-python/issues/1121
zarr.consolidate_metadata(store, metadata_key="zmetadata")
store.close()

def has_consolidated_metadata(self) -> bool:
from spatialdata._io._utils import _open_zarr_store

return_value = False
store = parse_url(self.path, mode="r").store
store = _open_zarr_store(self.path)
# Note that the store can be local (which does not have the zmetadata bug)
# or a remote FSStore (which has the bug).
if "zmetadata" in store:
return_value = True
store.close()
Expand DownExpand Up@@ -1575,15 +1589,11 @@ def write_transformations(self, element_name: str | None = None) -> None:
)
axes = get_axes_names(element)
if isinstance(element, DataArray | DataTree):
from spatialdata._io._utils import (
overwrite_coordinate_transformations_raster,
)
from spatialdata._io._utils import overwrite_coordinate_transformations_raster

overwrite_coordinate_transformations_raster(group=element_group, axes=axes, transformations=transformations)
elif isinstance(element, DaskDataFrame | GeoDataFrame | AnnData):
from spatialdata._io._utils import (
overwrite_coordinate_transformations_non_raster,
)
from spatialdata._io._utils import overwrite_coordinate_transformations_non_raster

overwrite_coordinate_transformations_non_raster(
group=element_group, axes=axes, transformations=transformations
Expand Down
53 changes: 48 additions & 5 deletions src/spatialdata/_io/_utils.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,14 +15,18 @@
from pathlib import Path
from typing import Any, Literal

import zarr
import zarr.storage
from anndata import AnnData
from dask.array import Array as DaskArray
from dask.dataframe import DataFrame as DaskDataFrame
from geopandas import GeoDataFrame
from upath import UPath
from upath.implementations.local import PosixUPath, WindowsUPath
from xarray import DataArray, DataTree
from zarr.storage import FSStore

from spatialdata._core.spatialdata import SpatialData
from spatialdata._types import StoreLike
from spatialdata._utils import get_pyramid_levels
from spatialdata.models._utils import (
MappingToCoordinateSystem_t,
Expand All@@ -31,10 +35,7 @@
_validate_mapping_to_coordinate_system_type,
)
from spatialdata.transformations.ngff.ngff_transformations import NgffBaseTransformation
from spatialdata.transformations.transformations import (
BaseTransformation,
_get_current_output_axes,
)
from spatialdata.transformations.transformations import BaseTransformation, _get_current_output_axes


# suppress logger debug from ome_zarr with context manager
Expand DownExpand Up@@ -388,6 +389,48 @@ def save_transformations(sdata: SpatialData) -> None:
sdata.write_transformations()


def _open_zarr_store(path: StoreLike, **kwargs: Any) -> zarr.storage.BaseStore:
# TODO: ensure kwargs like mode are enforced everywhere and passed correctly to the store
if isinstance(path, str | Path):
# if the input is str or Path, map it to UPath
path = UPath(path)
if isinstance(path, PosixUPath | WindowsUPath):
# if the input is a local path, use DirectoryStore
return zarr.storage.DirectoryStore(path.path, dimension_separator="/")
if isinstance(path, zarr.Group):
# if the input is a zarr.Group, wrap it with a store
if isinstance(path.store, zarr.storage.DirectoryStore):
# create a simple FSStore if the store is a DirectoryStore with just the path
return FSStore(os.path.join(path.store.path, path.path), **kwargs)
if isinstance(path.store, FSStore):
# if the store within the zarr.Group is an FSStore, return it
# but extend the path of the store with that of the zarr.Group
return FSStore(path.store.path + "/" + path.path, fs=path.store.fs, **kwargs)
if isinstance(path.store, zarr.storage.ConsolidatedMetadataStore):
# if the store is a ConsolidatedMetadataStore, just return the underlying FSSpec store
return path.store.store
raise ValueError(f"Unsupported store type or zarr.Group: {type(path.store)}")
if isinstance(path, zarr.storage.StoreLike):
# if the input already a store, wrap it in an FSStore
return FSStore(path, **kwargs)
if isinstance(path, UPath):
# if input is a remote UPath, map it to an FSStore
return FSStore(path.path, fs=path.fs, **kwargs)
raise TypeError(f"Unsupported type: {type(path)}")


def _create_upath(path: StoreLike) -> UPath | None:
# try to create a UPath from the input
if isinstance(path, zarr.storage.ConsolidatedMetadataStore):
path = path.store # get the fsstore from the consolidated store
if isinstance(path, FSStore):
protocol = path.fs.protocol if isinstance(path.fs.protocol, str) else path.fs.protocol[0]
return UPath(path.path, protocol=protocol, **path.fs.storage_options)
if isinstance(path, zarr.storage.DirectoryStore):
return UPath(path.path)
return None


class BadFileHandleMethod(Enum):
ERROR = "error"
WARN = "warn"
Expand Down
2 changes: 1 addition & 1 deletion src/spatialdata/_io/format.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -257,7 +257,7 @@ def validate_table(
def format_implementations() -> Iterator[Format]:
"""Return an instance of each format implementation, newest to oldest."""
yield RasterFormatV02()
# yield RasterFormatV01() # same format string as FormatV04
yield RasterFormatV01() # same format string as FormatV04
yield FormatV04()
yield FormatV03()
yield FormatV02()
Expand Down
19 changes: 6 additions & 13 deletions src/spatialdata/_io/io_points.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,27 +14,19 @@
)
from spatialdata._io.format import CurrentPointsFormat, PointsFormats, _parse_version
from spatialdata.models import get_axes_names
from spatialdata.transformations._utils import (
_get_transformations,
_set_transformations,
)
from spatialdata.transformations._utils import _get_transformations, _set_transformations


def _read_points(
store: str | Path | MutableMapping | zarr.Group, # type: ignore[type-arg]
) -> DaskDataFrame:
"""Read points from a zarr store."""
assert isinstance(store, str | Path)
f = zarr.open(store, mode="r")

f = zarr.open(store, mode="r") if isinstance(store, str | Path | MutableMapping) else store
version = _parse_version(f, expect_attrs_key=True)
assert version is not None
format = PointsFormats[version]

path = os.path.join(f._store.path, f.path, "points.parquet")
# cache on remote file needed for parquet reader to work
# TODO: allow reading in the metadata without caching all the data
points = read_parquet("simplecache::" + path if path.startswith("http") else path)
points = read_parquet(f.store.path, filesystem=getattr(f.store, "fs", None))
assert isinstance(points, DaskDataFrame)

transformations = _get_transformations_from_ngff_dict(f.attrs.asdict()["coordinateTransformations"])
Expand All@@ -57,7 +49,8 @@ def write_points(
t = _get_transformations(points)

points_groups = group.require_group(name)
path = Path(points_groups._store.path) / points_groups.path / "points.parquet"
store = points_groups._store
new_path = os.path.join(store.path, points_groups.path, "points.parquet")

# The following code iterates through all columns in the 'points' DataFrame. If the column's datatype is
# 'category', it checks whether the categories of this column are known. If not, it explicitly converts the
Expand All@@ -70,7 +63,7 @@ def write_points(
c = c.cat.as_known()
points[column_name] = c

points.to_parquet(path)
points.to_parquet(new_path, filesystem=getattr(store, "fs", None))

attrs = format.attrs_to_dict(points.attrs)
attrs["version"] = format.spatialdata_format_version
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
5718cba
add basic support and remote SpatialData tests
berombau Jan 24, 2025
a8be620
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jan 24, 2025
ce686e4
fix pre-commit
LucaMarconato Jan 31, 2025
e7fa020
Revert "Update pyproject.toml"
LucaMarconato Jan 31, 2025
a9c0801
removed 3.13 from test ci
LucaMarconato Jan 31, 2025
5cfdaec
Merge branch 'no_python_313' into remote2
LucaMarconato Jan 31, 2025
4fe6a47
fix
LucaMarconato Jan 31, 2025
d97a1d2
uploading sdata to local s3 storage
LucaMarconato Jan 31, 2025
5e26b5e
add _open_zarr_store
berombau Jan 31, 2025
5794871
revert changing write function signature
berombau Jan 31, 2025
0207ff7
update _open_zarr_store with StoreLike
berombau Jan 31, 2025
7e497ff
read image element from base store
berombau Jan 31, 2025
c674281
clean up remote mock tests, focus only on reading raster elements
berombau Feb 1, 2025
fb953a0
improve remote http test, add alternative
berombau Feb 1, 2025
52bb5fc
add support for consolidated metadata store in util function, add _cr…
berombau Feb 1, 2025
ca82493
allow for groups as store input
berombau Feb 1, 2025
ecea0e6
handle consolidated metadata with upath
berombau Feb 1, 2025
734eb45
split remote reading tests between http and http with consolidated me…
berombau Feb 1, 2025
c0ffb1c
remove f_store_path, support remote raster types fully and keep local…
berombau Feb 1, 2025
d60bd85
Fix metadata_key bug now that store is not always FSStore. Add extra …
berombau Feb 1, 2025
c3fa8cf
add mypy fixes
berombau Feb 1, 2025
d16a638
Merge branch 'main' into remote2
ap-- Mar 17, 2025
23f4a89
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Mar 17, 2025
a80588c
Fix linting errors
ap-- Mar 17, 2025
020810b
fixed majority of tests
ap-- Mar 17, 2025
ba25564
spatialdata._io._utils: _open_zarr_store has to set dimension_separat…
ap-- Mar 17, 2025
b2ff8f8
stay in sync with ome zarr format
ap-- Mar 17, 2025
70480ce
spatialdata._io.io_raster: support remote stores
ap-- Mar 17, 2025
10cef3f
prevent crashing tests on 3.10
ap-- Mar 17, 2025
d9e4eac
Merge branch 'spatial-data-crash310' into remote2
ap-- Mar 17, 2025
41825fb
spatialdata.io.io_shapes: fix support for remote shapes
ap-- Mar 18, 2025
b47838f
spatialdata.io.io_points: fix support for remote points
ap-- Mar 18, 2025
c2e2d0d
tests: update tests to use remote paths
ap-- Mar 18, 2025
28466fd
spatialdata._io._utils: support consolidated remote stores
ap-- Mar 18, 2025
1ae94c7
spatialdata._io.format: supprot remote embl datasets on s3
ap-- Mar 18, 2025
aed327d
spatialdata._io: test remote tables support and fix repr
ap-- Mar 18, 2025
437d0d3
Merge branch 'cloud-fix-remote-shapes' into cloud-future
ap-- Mar 18, 2025
2d7aa96
Merge branch 'cloud-fix-remote-points' into cloud-future
ap-- Mar 18, 2025
8335a99
Merge branch 'cloud-fix-remote-tables' into cloud-future
ap-- Mar 18, 2025
0fa1df9
Merge branch 'cloud-fix-remote-embl-datasets' into cloud-future
ap-- Mar 18, 2025
e9e2c7b
add support for remote write of non-raster elements
berombau Apr 4, 2025
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
7 changes: 5 additions & 2 deletions pyproject.toml
Original file line numberDiff line numberDiff line change
Expand Up@@ -26,20 +26,22 @@ dependencies = [
"click",
"dask-image",
"dask>=2024.4.1,<=2024.11.2",
"fsspec",
"fsspec[s3,http]",
"geopandas>=0.14",
"multiscale_spatial_image>=2.0.2",
"networkx",
"numba>=0.55.0",
"numpy",
"ome_zarr>=0.8.4",
"universal_pathlib>=0.2.6",
"pandas",
"pooch",
"pyarrow",
"rich",
"setuptools",
"shapely>=2.0.1",
"spatial_image>=1.1.0",
"spatial_image>=1.2.1",
"xarray-dataclasses>=1.9.1",
"scikit-image",
"scipy",
"typing_extensions>=4.8.0",
Expand All@@ -58,6 +60,7 @@ test = [
"pytest-cov",
"pytest-mock",
"torch",
"moto[s3,server]"
]
docs = [
"sphinx>=4.5",
Expand Down
76 changes: 43 additions & 33 deletions src/spatialdata/_core/spatialdata.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -30,11 +30,8 @@
validate_table_attr_keys,
)
from spatialdata._logging import logger
from spatialdata._types import ArrayLike, Raster_T
from spatialdata._utils import (
_deprecation_alias,
_error_message_add_element,
)
from spatialdata._types import ArrayLike, Raster_T, StoreLike
from spatialdata._utils import _deprecation_alias, _error_message_add_element
from spatialdata.models import (
Image2DModel,
Image3DModel,
Expand DownExpand Up@@ -601,7 +598,7 @@ def path(self, value: Path | None) -> None:
)

def _get_groups_for_element(
self, zarr_path: Path, element_type: str, element_name: str
self, zarr_path: StoreLike, element_type: str, element_name: str
) -> tuple[zarr.Group, zarr.Group, zarr.Group]:
"""
Get the Zarr groups for the root, element_type and element for a specific element.
Expand All@@ -621,9 +618,9 @@ def _get_groups_for_element(
-------
either the existing Zarr subgroup or a new one.
"""
if not isinstance(zarr_path, Path):
raise ValueError("zarr_path should be a Path object")
store = parse_url(zarr_path, mode="r+").store
from spatialdata._io._utils import _open_zarr_store

store = _open_zarr_store(zarr_path, mode="r+")
root = zarr.group(store=store)
if element_type not in ["images", "labels", "points", "polygons", "shapes", "tables"]:
raise ValueError(f"Unknown element type {element_type}")
Expand DownExpand Up@@ -1068,9 +1065,12 @@ def elements_paths_on_disk(self) -> list[str]:
-------
A list of paths of the elements saved in the Zarr store.
"""
from spatialdata._io._utils import _open_zarr_store

if self.path is None:
raise ValueError("The SpatialData object is not backed by a Zarr store.")
store = parse_url(self.path, mode="r").store

store = _open_zarr_store(self.path)
root = zarr.group(store=store)
elements_in_zarr = []

Expand DownExpand Up@@ -1175,7 +1175,7 @@ def _validate_all_elements(self) -> None:

def write(
self,
file_path: str | Path,
file_path: StoreLike,
overwrite: bool = False,
consolidate_metadata: bool = True,
format: SpatialDataFormat | list[SpatialDataFormat] | None = None,
Expand DownExpand Up@@ -1205,12 +1205,16 @@ def write(
:class:`~spatialdata._io.format.CurrentRasterFormat`, :class:`~spatialdata._io.format.CurrentShapesFormat`,
:class:`~spatialdata._io.format.CurrentPointsFormat`, :class:`~spatialdata._io.format.CurrentTablesFormat`.
"""
from spatialdata._io._utils import _open_zarr_store

if isinstance(file_path, str):
file_path = Path(file_path)
self._validate_can_safely_write_to_path(file_path, overwrite=overwrite)
self._validate_all_elements()
if isinstance(file_path, Path):
# TODO: also validate remote paths
self._validate_can_safely_write_to_path(file_path, overwrite=overwrite)
self._validate_all_elements()

store = parse_url(file_path, mode="w").store
store = _open_zarr_store(file_path, mode="w")
zarr_group = zarr.group(store=store, overwrite=overwrite)
self.write_attrs(zarr_group=zarr_group)
store.close()
Expand All@@ -1236,20 +1240,21 @@ def write(
def _write_element(
self,
element: SpatialElement | AnnData,
zarr_container_path: Path,
zarr_container_path: StoreLike,
element_type: str,
element_name: str,
overwrite: bool,
format: SpatialDataFormat | list[SpatialDataFormat] | None = None,
) -> None:
if not isinstance(zarr_container_path, Path):
if not isinstance(zarr_container_path, StoreLike):
raise ValueError(
f"zarr_container_path must be a Path object, type(zarr_container_path) = {type(zarr_container_path)}."
)
file_path_of_element = zarr_container_path / element_type / element_name
self._validate_can_safely_write_to_path(
file_path=file_path_of_element, overwrite=overwrite, saving_an_element=True
)
if isinstance(zarr_container_path, Path):
file_path_of_element = zarr_container_path / element_type / element_name
self._validate_can_safely_write_to_path(
file_path=file_path_of_element, overwrite=overwrite, saving_an_element=True
)

root_group, element_type_group, _ = self._get_groups_for_element(
zarr_path=zarr_container_path, element_type=element_type, element_name=element_name
Expand DownExpand Up@@ -1376,7 +1381,7 @@ def delete_element_from_disk(self, element_name: str | list[str]) -> None:
self.delete_element_from_disk(name)
return

from spatialdata._io._utils import _backed_elements_contained_in_path
from spatialdata._io._utils import _backed_elements_contained_in_path, _open_zarr_store

if self.path is None:
raise ValueError("The SpatialData object is not backed by a Zarr store.")
Expand DownExpand Up@@ -1417,7 +1422,7 @@ def delete_element_from_disk(self, element_name: str | list[str]) -> None:
)

# delete the element
store = parse_url(self.path, mode="r+").store
store = _open_zarr_store(self.path)
root = zarr.group(store=store)
root[element_type].pop(element_name)
store.close()
Expand All@@ -1438,15 +1443,24 @@ def _check_element_not_on_disk_with_different_type(self, element_type: str, elem
)

def write_consolidated_metadata(self) -> None:
store = parse_url(self.path, mode="r+").store
# consolidate metadata to more easily support remote reading bug in zarr. In reality, 'zmetadata' is written
# instead of '.zmetadata' see discussion https://github.com/zarr-developers/zarr-python/issues/1121
zarr.consolidate_metadata(store, metadata_key=".zmetadata")
from spatialdata._io._utils import _open_zarr_store

store = _open_zarr_store(self.path)
# Note that the store can be local (which does not have the zmetadata bug)
# or a remote FSStore (which has the bug).
# Consolidate metadata to more easily support remote reading bug in zarr.
# We write 'zmetadata' instead of the standard '.zmetadata' to avoid the FSStore bug.
# See discussion https://github.com/zarr-developers/zarr-python/issues/1121
zarr.consolidate_metadata(store, metadata_key="zmetadata")
store.close()

def has_consolidated_metadata(self) -> bool:
from spatialdata._io._utils import _open_zarr_store

return_value = False
store = parse_url(self.path, mode="r").store
store = _open_zarr_store(self.path)
# Note that the store can be local (which does not have the zmetadata bug)
# or a remote FSStore (which has the bug).
if "zmetadata" in store:
return_value = True
store.close()
Expand DownExpand Up@@ -1575,15 +1589,11 @@ def write_transformations(self, element_name: str | None = None) -> None:
)
axes = get_axes_names(element)
if isinstance(element, DataArray | DataTree):
from spatialdata._io._utils import (
overwrite_coordinate_transformations_raster,
)
from spatialdata._io._utils import overwrite_coordinate_transformations_raster

overwrite_coordinate_transformations_raster(group=element_group, axes=axes, transformations=transformations)
elif isinstance(element, DaskDataFrame | GeoDataFrame | AnnData):
from spatialdata._io._utils import (
overwrite_coordinate_transformations_non_raster,
)
from spatialdata._io._utils import overwrite_coordinate_transformations_non_raster

overwrite_coordinate_transformations_non_raster(
group=element_group, axes=axes, transformations=transformations
Expand Down
53 changes: 48 additions & 5 deletions src/spatialdata/_io/_utils.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,14 +15,18 @@
from pathlib import Path
from typing import Any, Literal

import zarr
import zarr.storage
from anndata import AnnData
from dask.array import Array as DaskArray
from dask.dataframe import DataFrame as DaskDataFrame
from geopandas import GeoDataFrame
from upath import UPath
from upath.implementations.local import PosixUPath, WindowsUPath
from xarray import DataArray, DataTree
from zarr.storage import FSStore

from spatialdata._core.spatialdata import SpatialData
from spatialdata._types import StoreLike
from spatialdata._utils import get_pyramid_levels
from spatialdata.models._utils import (
MappingToCoordinateSystem_t,
Expand All@@ -31,10 +35,7 @@
_validate_mapping_to_coordinate_system_type,
)
from spatialdata.transformations.ngff.ngff_transformations import NgffBaseTransformation
from spatialdata.transformations.transformations import (
BaseTransformation,
_get_current_output_axes,
)
from spatialdata.transformations.transformations import BaseTransformation, _get_current_output_axes


# suppress logger debug from ome_zarr with context manager
Expand DownExpand Up@@ -388,6 +389,48 @@ def save_transformations(sdata: SpatialData) -> None:
sdata.write_transformations()


def _open_zarr_store(path: StoreLike, **kwargs: Any) -> zarr.storage.BaseStore:
# TODO: ensure kwargs like mode are enforced everywhere and passed correctly to the store
if isinstance(path, str | Path):
# if the input is str or Path, map it to UPath
path = UPath(path)
if isinstance(path, PosixUPath | WindowsUPath):
# if the input is a local path, use DirectoryStore
return zarr.storage.DirectoryStore(path.path, dimension_separator="/")
if isinstance(path, zarr.Group):
# if the input is a zarr.Group, wrap it with a store
if isinstance(path.store, zarr.storage.DirectoryStore):
# create a simple FSStore if the store is a DirectoryStore with just the path
return FSStore(os.path.join(path.store.path, path.path), **kwargs)
if isinstance(path.store, FSStore):
# if the store within the zarr.Group is an FSStore, return it
# but extend the path of the store with that of the zarr.Group
return FSStore(path.store.path + "/" + path.path, fs=path.store.fs, **kwargs)
if isinstance(path.store, zarr.storage.ConsolidatedMetadataStore):
# if the store is a ConsolidatedMetadataStore, just return the underlying FSSpec store
return path.store.store
raise ValueError(f"Unsupported store type or zarr.Group: {type(path.store)}")
if isinstance(path, zarr.storage.StoreLike):
# if the input already a store, wrap it in an FSStore
return FSStore(path, **kwargs)
if isinstance(path, UPath):
# if input is a remote UPath, map it to an FSStore
return FSStore(path.path, fs=path.fs, **kwargs)
raise TypeError(f"Unsupported type: {type(path)}")


def _create_upath(path: StoreLike) -> UPath | None:
# try to create a UPath from the input
if isinstance(path, zarr.storage.ConsolidatedMetadataStore):
path = path.store # get the fsstore from the consolidated store
if isinstance(path, FSStore):
protocol = path.fs.protocol if isinstance(path.fs.protocol, str) else path.fs.protocol[0]
return UPath(path.path, protocol=protocol, **path.fs.storage_options)
if isinstance(path, zarr.storage.DirectoryStore):
return UPath(path.path)
return None


class BadFileHandleMethod(Enum):
ERROR = "error"
WARN = "warn"
Expand Down
2 changes: 1 addition & 1 deletion src/spatialdata/_io/format.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -257,7 +257,7 @@ def validate_table(
def format_implementations() -> Iterator[Format]:
"""Return an instance of each format implementation, newest to oldest."""
yield RasterFormatV02()
# yield RasterFormatV01() # same format string as FormatV04
yield RasterFormatV01() # same format string as FormatV04
yield FormatV04()
yield FormatV03()
yield FormatV02()
Expand Down
19 changes: 6 additions & 13 deletions src/spatialdata/_io/io_points.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,27 +14,19 @@
)
from spatialdata._io.format import CurrentPointsFormat, PointsFormats, _parse_version
from spatialdata.models import get_axes_names
from spatialdata.transformations._utils import (
_get_transformations,
_set_transformations,
)
from spatialdata.transformations._utils import _get_transformations, _set_transformations


def _read_points(
store: str | Path | MutableMapping | zarr.Group, # type: ignore[type-arg]
) -> DaskDataFrame:
"""Read points from a zarr store."""
assert isinstance(store, str | Path)
f = zarr.open(store, mode="r")

f = zarr.open(store, mode="r") if isinstance(store, str | Path | MutableMapping) else store
version = _parse_version(f, expect_attrs_key=True)
assert version is not None
format = PointsFormats[version]

path = os.path.join(f._store.path, f.path, "points.parquet")
# cache on remote file needed for parquet reader to work
# TODO: allow reading in the metadata without caching all the data
points = read_parquet("simplecache::" + path if path.startswith("http") else path)
points = read_parquet(f.store.path, filesystem=getattr(f.store, "fs", None))
assert isinstance(points, DaskDataFrame)

transformations = _get_transformations_from_ngff_dict(f.attrs.asdict()["coordinateTransformations"])
Expand All@@ -57,7 +49,8 @@ def write_points(
t = _get_transformations(points)

points_groups = group.require_group(name)
path = Path(points_groups._store.path) / points_groups.path / "points.parquet"
store = points_groups._store
new_path = os.path.join(store.path, points_groups.path, "points.parquet")

# The following code iterates through all columns in the 'points' DataFrame. If the column's datatype is
# 'category', it checks whether the categories of this column are known. If not, it explicitly converts the
Expand All@@ -70,7 +63,7 @@ def write_points(
c = c.cat.as_known()
points[column_name] = c

points.to_parquet(path)
points.to_parquet(new_path, filesystem=getattr(store, "fs", None))

attrs = format.attrs_to_dict(points.attrs)
attrs["version"] = format.spatialdata_format_version
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
5718cba
add basic support and remote SpatialData tests
berombau Jan 24, 2025
a8be620
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jan 24, 2025
ce686e4
fix pre-commit
LucaMarconato Jan 31, 2025
e7fa020
Revert "Update pyproject.toml"
LucaMarconato Jan 31, 2025
a9c0801
removed 3.13 from test ci
LucaMarconato Jan 31, 2025
5cfdaec
Merge branch 'no_python_313' into remote2
LucaMarconato Jan 31, 2025
4fe6a47
fix
LucaMarconato Jan 31, 2025
d97a1d2
uploading sdata to local s3 storage
LucaMarconato Jan 31, 2025
5e26b5e
add _open_zarr_store
berombau Jan 31, 2025
5794871
revert changing write function signature
berombau Jan 31, 2025
0207ff7
update _open_zarr_store with StoreLike
berombau Jan 31, 2025
7e497ff
read image element from base store
berombau Jan 31, 2025
c674281
clean up remote mock tests, focus only on reading raster elements
berombau Feb 1, 2025
fb953a0
improve remote http test, add alternative
berombau Feb 1, 2025
52bb5fc
add support for consolidated metadata store in util function, add _cr…
berombau Feb 1, 2025
ca82493
allow for groups as store input
berombau Feb 1, 2025
ecea0e6
handle consolidated metadata with upath
berombau Feb 1, 2025
734eb45
split remote reading tests between http and http with consolidated me…
berombau Feb 1, 2025
c0ffb1c
remove f_store_path, support remote raster types fully and keep local…
berombau Feb 1, 2025
d60bd85
Fix metadata_key bug now that store is not always FSStore. Add extra …
berombau Feb 1, 2025
c3fa8cf
add mypy fixes
berombau Feb 1, 2025
d16a638
Merge branch 'main' into remote2
ap-- Mar 17, 2025
23f4a89
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Mar 17, 2025
a80588c
Fix linting errors
ap-- Mar 17, 2025
020810b
fixed majority of tests
ap-- Mar 17, 2025
ba25564
spatialdata._io._utils: _open_zarr_store has to set dimension_separat…
ap-- Mar 17, 2025
b2ff8f8
stay in sync with ome zarr format
ap-- Mar 17, 2025
70480ce
spatialdata._io.io_raster: support remote stores
ap-- Mar 17, 2025
10cef3f
prevent crashing tests on 3.10
ap-- Mar 17, 2025
d9e4eac
Merge branch 'spatial-data-crash310' into remote2
ap-- Mar 17, 2025
41825fb
spatialdata.io.io_shapes: fix support for remote shapes
ap-- Mar 18, 2025
b47838f
spatialdata.io.io_points: fix support for remote points
ap-- Mar 18, 2025
c2e2d0d
tests: update tests to use remote paths
ap-- Mar 18, 2025
28466fd
spatialdata._io._utils: support consolidated remote stores
ap-- Mar 18, 2025
1ae94c7
spatialdata._io.format: supprot remote embl datasets on s3
ap-- Mar 18, 2025
aed327d
spatialdata._io: test remote tables support and fix repr
ap-- Mar 18, 2025
437d0d3
Merge branch 'cloud-fix-remote-shapes' into cloud-future
ap-- Mar 18, 2025
2d7aa96
Merge branch 'cloud-fix-remote-points' into cloud-future
ap-- Mar 18, 2025
8335a99
Merge branch 'cloud-fix-remote-tables' into cloud-future
ap-- Mar 18, 2025
0fa1df9
Merge branch 'cloud-fix-remote-embl-datasets' into cloud-future
ap-- Mar 18, 2025
e9e2c7b
add support for remote write of non-raster elements
berombau Apr 4, 2025
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
7 changes: 5 additions & 2 deletions pyproject.toml
Original file line numberDiff line numberDiff line change
Expand Up@@ -26,20 +26,22 @@ dependencies = [
"click",
"dask-image",
"dask>=2024.4.1,<=2024.11.2",
"fsspec",
"fsspec[s3,http]",
"geopandas>=0.14",
"multiscale_spatial_image>=2.0.2",
"networkx",
"numba>=0.55.0",
"numpy",
"ome_zarr>=0.8.4",
"universal_pathlib>=0.2.6",
"pandas",
"pooch",
"pyarrow",
"rich",
"setuptools",
"shapely>=2.0.1",
"spatial_image>=1.1.0",
"spatial_image>=1.2.1",
"xarray-dataclasses>=1.9.1",
"scikit-image",
"scipy",
"typing_extensions>=4.8.0",
Expand All@@ -58,6 +60,7 @@ test = [
"pytest-cov",
"pytest-mock",
"torch",
"moto[s3,server]"
]
docs = [
"sphinx>=4.5",
Expand Down
76 changes: 43 additions & 33 deletions src/spatialdata/_core/spatialdata.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -30,11 +30,8 @@
validate_table_attr_keys,
)
from spatialdata._logging import logger
from spatialdata._types import ArrayLike, Raster_T
from spatialdata._utils import (
_deprecation_alias,
_error_message_add_element,
)
from spatialdata._types import ArrayLike, Raster_T, StoreLike
from spatialdata._utils import _deprecation_alias, _error_message_add_element
from spatialdata.models import (
Image2DModel,
Image3DModel,
Expand DownExpand Up@@ -601,7 +598,7 @@ def path(self, value: Path | None) -> None:
)

def _get_groups_for_element(
self, zarr_path: Path, element_type: str, element_name: str
self, zarr_path: StoreLike, element_type: str, element_name: str
) -> tuple[zarr.Group, zarr.Group, zarr.Group]:
"""
Get the Zarr groups for the root, element_type and element for a specific element.
Expand All@@ -621,9 +618,9 @@ def _get_groups_for_element(
-------
either the existing Zarr subgroup or a new one.
"""
if not isinstance(zarr_path, Path):
raise ValueError("zarr_path should be a Path object")
store = parse_url(zarr_path, mode="r+").store
from spatialdata._io._utils import _open_zarr_store

store = _open_zarr_store(zarr_path, mode="r+")
root = zarr.group(store=store)
if element_type not in ["images", "labels", "points", "polygons", "shapes", "tables"]:
raise ValueError(f"Unknown element type {element_type}")
Expand DownExpand Up@@ -1068,9 +1065,12 @@ def elements_paths_on_disk(self) -> list[str]:
-------
A list of paths of the elements saved in the Zarr store.
"""
from spatialdata._io._utils import _open_zarr_store

if self.path is None:
raise ValueError("The SpatialData object is not backed by a Zarr store.")
store = parse_url(self.path, mode="r").store

store = _open_zarr_store(self.path)
root = zarr.group(store=store)
elements_in_zarr = []

Expand DownExpand Up@@ -1175,7 +1175,7 @@ def _validate_all_elements(self) -> None:

def write(
self,
file_path: str | Path,
file_path: StoreLike,
overwrite: bool = False,
consolidate_metadata: bool = True,
format: SpatialDataFormat | list[SpatialDataFormat] | None = None,
Expand DownExpand Up@@ -1205,12 +1205,16 @@ def write(
:class:`~spatialdata._io.format.CurrentRasterFormat`, :class:`~spatialdata._io.format.CurrentShapesFormat`,
:class:`~spatialdata._io.format.CurrentPointsFormat`, :class:`~spatialdata._io.format.CurrentTablesFormat`.
"""
from spatialdata._io._utils import _open_zarr_store

if isinstance(file_path, str):
file_path = Path(file_path)
self._validate_can_safely_write_to_path(file_path, overwrite=overwrite)
self._validate_all_elements()
if isinstance(file_path, Path):
# TODO: also validate remote paths
self._validate_can_safely_write_to_path(file_path, overwrite=overwrite)
self._validate_all_elements()

store = parse_url(file_path, mode="w").store
store = _open_zarr_store(file_path, mode="w")
zarr_group = zarr.group(store=store, overwrite=overwrite)
self.write_attrs(zarr_group=zarr_group)
store.close()
Expand All@@ -1236,20 +1240,21 @@ def write(
def _write_element(
self,
element: SpatialElement | AnnData,
zarr_container_path: Path,
zarr_container_path: StoreLike,
element_type: str,
element_name: str,
overwrite: bool,
format: SpatialDataFormat | list[SpatialDataFormat] | None = None,
) -> None:
if not isinstance(zarr_container_path, Path):
if not isinstance(zarr_container_path, StoreLike):
raise ValueError(
f"zarr_container_path must be a Path object, type(zarr_container_path) = {type(zarr_container_path)}."
)
file_path_of_element = zarr_container_path / element_type / element_name
self._validate_can_safely_write_to_path(
file_path=file_path_of_element, overwrite=overwrite, saving_an_element=True
)
if isinstance(zarr_container_path, Path):
file_path_of_element = zarr_container_path / element_type / element_name
self._validate_can_safely_write_to_path(
file_path=file_path_of_element, overwrite=overwrite, saving_an_element=True
)

root_group, element_type_group, _ = self._get_groups_for_element(
zarr_path=zarr_container_path, element_type=element_type, element_name=element_name
Expand DownExpand Up@@ -1376,7 +1381,7 @@ def delete_element_from_disk(self, element_name: str | list[str]) -> None:
self.delete_element_from_disk(name)
return

from spatialdata._io._utils import _backed_elements_contained_in_path
from spatialdata._io._utils import _backed_elements_contained_in_path, _open_zarr_store

if self.path is None:
raise ValueError("The SpatialData object is not backed by a Zarr store.")
Expand DownExpand Up@@ -1417,7 +1422,7 @@ def delete_element_from_disk(self, element_name: str | list[str]) -> None:
)

# delete the element
store = parse_url(self.path, mode="r+").store
store = _open_zarr_store(self.path)
root = zarr.group(store=store)
root[element_type].pop(element_name)
store.close()
Expand All@@ -1438,15 +1443,24 @@ def _check_element_not_on_disk_with_different_type(self, element_type: str, elem
)

def write_consolidated_metadata(self) -> None:
store = parse_url(self.path, mode="r+").store
# consolidate metadata to more easily support remote reading bug in zarr. In reality, 'zmetadata' is written
# instead of '.zmetadata' see discussion https://github.com/zarr-developers/zarr-python/issues/1121
zarr.consolidate_metadata(store, metadata_key=".zmetadata")
from spatialdata._io._utils import _open_zarr_store

store = _open_zarr_store(self.path)
# Note that the store can be local (which does not have the zmetadata bug)
# or a remote FSStore (which has the bug).
# Consolidate metadata to more easily support remote reading bug in zarr.
# We write 'zmetadata' instead of the standard '.zmetadata' to avoid the FSStore bug.
# See discussion https://github.com/zarr-developers/zarr-python/issues/1121
zarr.consolidate_metadata(store, metadata_key="zmetadata")
store.close()

def has_consolidated_metadata(self) -> bool:
from spatialdata._io._utils import _open_zarr_store

return_value = False
store = parse_url(self.path, mode="r").store
store = _open_zarr_store(self.path)
# Note that the store can be local (which does not have the zmetadata bug)
# or a remote FSStore (which has the bug).
if "zmetadata" in store:
return_value = True
store.close()
Expand DownExpand Up@@ -1575,15 +1589,11 @@ def write_transformations(self, element_name: str | None = None) -> None:
)
axes = get_axes_names(element)
if isinstance(element, DataArray | DataTree):
from spatialdata._io._utils import (
overwrite_coordinate_transformations_raster,
)
from spatialdata._io._utils import overwrite_coordinate_transformations_raster

overwrite_coordinate_transformations_raster(group=element_group, axes=axes, transformations=transformations)
elif isinstance(element, DaskDataFrame | GeoDataFrame | AnnData):
from spatialdata._io._utils import (
overwrite_coordinate_transformations_non_raster,
)
from spatialdata._io._utils import overwrite_coordinate_transformations_non_raster

overwrite_coordinate_transformations_non_raster(
group=element_group, axes=axes, transformations=transformations
Expand Down
53 changes: 48 additions & 5 deletions src/spatialdata/_io/_utils.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,14 +15,18 @@
from pathlib import Path
from typing import Any, Literal

import zarr
import zarr.storage
from anndata import AnnData
from dask.array import Array as DaskArray
from dask.dataframe import DataFrame as DaskDataFrame
from geopandas import GeoDataFrame
from upath import UPath
from upath.implementations.local import PosixUPath, WindowsUPath
from xarray import DataArray, DataTree
from zarr.storage import FSStore

from spatialdata._core.spatialdata import SpatialData
from spatialdata._types import StoreLike
from spatialdata._utils import get_pyramid_levels
from spatialdata.models._utils import (
MappingToCoordinateSystem_t,
Expand All@@ -31,10 +35,7 @@
_validate_mapping_to_coordinate_system_type,
)
from spatialdata.transformations.ngff.ngff_transformations import NgffBaseTransformation
from spatialdata.transformations.transformations import (
BaseTransformation,
_get_current_output_axes,
)
from spatialdata.transformations.transformations import BaseTransformation, _get_current_output_axes


# suppress logger debug from ome_zarr with context manager
Expand DownExpand Up@@ -388,6 +389,48 @@ def save_transformations(sdata: SpatialData) -> None:
sdata.write_transformations()


def _open_zarr_store(path: StoreLike, **kwargs: Any) -> zarr.storage.BaseStore:
# TODO: ensure kwargs like mode are enforced everywhere and passed correctly to the store
if isinstance(path, str | Path):
# if the input is str or Path, map it to UPath
path = UPath(path)
if isinstance(path, PosixUPath | WindowsUPath):
# if the input is a local path, use DirectoryStore
return zarr.storage.DirectoryStore(path.path, dimension_separator="/")
if isinstance(path, zarr.Group):
# if the input is a zarr.Group, wrap it with a store
if isinstance(path.store, zarr.storage.DirectoryStore):
# create a simple FSStore if the store is a DirectoryStore with just the path
return FSStore(os.path.join(path.store.path, path.path), **kwargs)
if isinstance(path.store, FSStore):
# if the store within the zarr.Group is an FSStore, return it
# but extend the path of the store with that of the zarr.Group
return FSStore(path.store.path + "/" + path.path, fs=path.store.fs, **kwargs)
if isinstance(path.store, zarr.storage.ConsolidatedMetadataStore):
# if the store is a ConsolidatedMetadataStore, just return the underlying FSSpec store
return path.store.store
raise ValueError(f"Unsupported store type or zarr.Group: {type(path.store)}")
if isinstance(path, zarr.storage.StoreLike):
# if the input already a store, wrap it in an FSStore
return FSStore(path, **kwargs)
if isinstance(path, UPath):
# if input is a remote UPath, map it to an FSStore
return FSStore(path.path, fs=path.fs, **kwargs)
raise TypeError(f"Unsupported type: {type(path)}")


def _create_upath(path: StoreLike) -> UPath | None:
# try to create a UPath from the input
if isinstance(path, zarr.storage.ConsolidatedMetadataStore):
path = path.store # get the fsstore from the consolidated store
if isinstance(path, FSStore):
protocol = path.fs.protocol if isinstance(path.fs.protocol, str) else path.fs.protocol[0]
return UPath(path.path, protocol=protocol, **path.fs.storage_options)
if isinstance(path, zarr.storage.DirectoryStore):
return UPath(path.path)
return None


class BadFileHandleMethod(Enum):
ERROR = "error"
WARN = "warn"
Expand Down
2 changes: 1 addition & 1 deletion src/spatialdata/_io/format.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -257,7 +257,7 @@ def validate_table(
def format_implementations() -> Iterator[Format]:
"""Return an instance of each format implementation, newest to oldest."""
yield RasterFormatV02()
# yield RasterFormatV01() # same format string as FormatV04
yield RasterFormatV01() # same format string as FormatV04
yield FormatV04()
yield FormatV03()
yield FormatV02()
Expand Down
19 changes: 6 additions & 13 deletions src/spatialdata/_io/io_points.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,27 +14,19 @@
)
from spatialdata._io.format import CurrentPointsFormat, PointsFormats, _parse_version
from spatialdata.models import get_axes_names
from spatialdata.transformations._utils import (
_get_transformations,
_set_transformations,
)
from spatialdata.transformations._utils import _get_transformations, _set_transformations


def _read_points(
store: str | Path | MutableMapping | zarr.Group, # type: ignore[type-arg]
) -> DaskDataFrame:
"""Read points from a zarr store."""
assert isinstance(store, str | Path)
f = zarr.open(store, mode="r")

f = zarr.open(store, mode="r") if isinstance(store, str | Path | MutableMapping) else store
version = _parse_version(f, expect_attrs_key=True)
assert version is not None
format = PointsFormats[version]

path = os.path.join(f._store.path, f.path, "points.parquet")
# cache on remote file needed for parquet reader to work
# TODO: allow reading in the metadata without caching all the data
points = read_parquet("simplecache::" + path if path.startswith("http") else path)
points = read_parquet(f.store.path, filesystem=getattr(f.store, "fs", None))
assert isinstance(points, DaskDataFrame)

transformations = _get_transformations_from_ngff_dict(f.attrs.asdict()["coordinateTransformations"])
Expand All@@ -57,7 +49,8 @@ def write_points(
t = _get_transformations(points)

points_groups = group.require_group(name)
path = Path(points_groups._store.path) / points_groups.path / "points.parquet"
store = points_groups._store
new_path = os.path.join(store.path, points_groups.path, "points.parquet")

# The following code iterates through all columns in the 'points' DataFrame. If the column's datatype is
# 'category', it checks whether the categories of this column are known. If not, it explicitly converts the
Expand All@@ -70,7 +63,7 @@ def write_points(
c = c.cat.as_known()
points[column_name] = c

points.to_parquet(path)
points.to_parquet(new_path, filesystem=getattr(store, "fs", None))

attrs = format.attrs_to_dict(points.attrs)
attrs["version"] = format.spatialdata_format_version
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
5718cba
add basic support and remote SpatialData tests
berombau Jan 24, 2025
a8be620
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jan 24, 2025
ce686e4
fix pre-commit
LucaMarconato Jan 31, 2025
e7fa020
Revert "Update pyproject.toml"
LucaMarconato Jan 31, 2025
a9c0801
removed 3.13 from test ci
LucaMarconato Jan 31, 2025
5cfdaec
Merge branch 'no_python_313' into remote2
LucaMarconato Jan 31, 2025
4fe6a47
fix
LucaMarconato Jan 31, 2025
d97a1d2
uploading sdata to local s3 storage
LucaMarconato Jan 31, 2025
5e26b5e
add _open_zarr_store
berombau Jan 31, 2025
5794871
revert changing write function signature
berombau Jan 31, 2025
0207ff7
update _open_zarr_store with StoreLike
berombau Jan 31, 2025
7e497ff
read image element from base store
berombau Jan 31, 2025
c674281
clean up remote mock tests, focus only on reading raster elements
berombau Feb 1, 2025
fb953a0
improve remote http test, add alternative
berombau Feb 1, 2025
52bb5fc
add support for consolidated metadata store in util function, add _cr…
berombau Feb 1, 2025
ca82493
allow for groups as store input
berombau Feb 1, 2025
ecea0e6
handle consolidated metadata with upath
berombau Feb 1, 2025
734eb45
split remote reading tests between http and http with consolidated me…
berombau Feb 1, 2025
c0ffb1c
remove f_store_path, support remote raster types fully and keep local…
berombau Feb 1, 2025
d60bd85
Fix metadata_key bug now that store is not always FSStore. Add extra …
berombau Feb 1, 2025
c3fa8cf
add mypy fixes
berombau Feb 1, 2025
d16a638
Merge branch 'main' into remote2
ap-- Mar 17, 2025
23f4a89
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Mar 17, 2025
a80588c
Fix linting errors
ap-- Mar 17, 2025
020810b
fixed majority of tests
ap-- Mar 17, 2025
ba25564
spatialdata._io._utils: _open_zarr_store has to set dimension_separat…
ap-- Mar 17, 2025
b2ff8f8
stay in sync with ome zarr format
ap-- Mar 17, 2025
70480ce
spatialdata._io.io_raster: support remote stores
ap-- Mar 17, 2025
10cef3f
prevent crashing tests on 3.10
ap-- Mar 17, 2025
d9e4eac
Merge branch 'spatial-data-crash310' into remote2
ap-- Mar 17, 2025
41825fb
spatialdata.io.io_shapes: fix support for remote shapes
ap-- Mar 18, 2025
b47838f
spatialdata.io.io_points: fix support for remote points
ap-- Mar 18, 2025
c2e2d0d
tests: update tests to use remote paths
ap-- Mar 18, 2025
28466fd
spatialdata._io._utils: support consolidated remote stores
ap-- Mar 18, 2025
1ae94c7
spatialdata._io.format: supprot remote embl datasets on s3
ap-- Mar 18, 2025
aed327d
spatialdata._io: test remote tables support and fix repr
ap-- Mar 18, 2025
437d0d3
Merge branch 'cloud-fix-remote-shapes' into cloud-future
ap-- Mar 18, 2025
2d7aa96
Merge branch 'cloud-fix-remote-points' into cloud-future
ap-- Mar 18, 2025
8335a99
Merge branch 'cloud-fix-remote-tables' into cloud-future
ap-- Mar 18, 2025
0fa1df9
Merge branch 'cloud-fix-remote-embl-datasets' into cloud-future
ap-- Mar 18, 2025
e9e2c7b
add support for remote write of non-raster elements
berombau Apr 4, 2025
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
7 changes: 5 additions & 2 deletions pyproject.toml
Original file line numberDiff line numberDiff line change
Expand Up@@ -26,20 +26,22 @@ dependencies = [
"click",
"dask-image",
"dask>=2024.4.1,<=2024.11.2",
"fsspec",
"fsspec[s3,http]",
"geopandas>=0.14",
"multiscale_spatial_image>=2.0.2",
"networkx",
"numba>=0.55.0",
"numpy",
"ome_zarr>=0.8.4",
"universal_pathlib>=0.2.6",
"pandas",
"pooch",
"pyarrow",
"rich",
"setuptools",
"shapely>=2.0.1",
"spatial_image>=1.1.0",
"spatial_image>=1.2.1",
"xarray-dataclasses>=1.9.1",
"scikit-image",
"scipy",
"typing_extensions>=4.8.0",
Expand All@@ -58,6 +60,7 @@ test = [
"pytest-cov",
"pytest-mock",
"torch",
"moto[s3,server]"
]
docs = [
"sphinx>=4.5",
Expand Down
76 changes: 43 additions & 33 deletions src/spatialdata/_core/spatialdata.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -30,11 +30,8 @@
validate_table_attr_keys,
)
from spatialdata._logging import logger
from spatialdata._types import ArrayLike, Raster_T
from spatialdata._utils import (
_deprecation_alias,
_error_message_add_element,
)
from spatialdata._types import ArrayLike, Raster_T, StoreLike
from spatialdata._utils import _deprecation_alias, _error_message_add_element
from spatialdata.models import (
Image2DModel,
Image3DModel,
Expand DownExpand Up@@ -601,7 +598,7 @@ def path(self, value: Path | None) -> None:
)

def _get_groups_for_element(
self, zarr_path: Path, element_type: str, element_name: str
self, zarr_path: StoreLike, element_type: str, element_name: str
) -> tuple[zarr.Group, zarr.Group, zarr.Group]:
"""
Get the Zarr groups for the root, element_type and element for a specific element.
Expand All@@ -621,9 +618,9 @@ def _get_groups_for_element(
-------
either the existing Zarr subgroup or a new one.
"""
if not isinstance(zarr_path, Path):
raise ValueError("zarr_path should be a Path object")
store = parse_url(zarr_path, mode="r+").store
from spatialdata._io._utils import _open_zarr_store

store = _open_zarr_store(zarr_path, mode="r+")
root = zarr.group(store=store)
if element_type not in ["images", "labels", "points", "polygons", "shapes", "tables"]:
raise ValueError(f"Unknown element type {element_type}")
Expand DownExpand Up@@ -1068,9 +1065,12 @@ def elements_paths_on_disk(self) -> list[str]:
-------
A list of paths of the elements saved in the Zarr store.
"""
from spatialdata._io._utils import _open_zarr_store

if self.path is None:
raise ValueError("The SpatialData object is not backed by a Zarr store.")
store = parse_url(self.path, mode="r").store

store = _open_zarr_store(self.path)
root = zarr.group(store=store)
elements_in_zarr = []

Expand DownExpand Up@@ -1175,7 +1175,7 @@ def _validate_all_elements(self) -> None:

def write(
self,
file_path: str | Path,
file_path: StoreLike,
overwrite: bool = False,
consolidate_metadata: bool = True,
format: SpatialDataFormat | list[SpatialDataFormat] | None = None,
Expand DownExpand Up@@ -1205,12 +1205,16 @@ def write(
:class:`~spatialdata._io.format.CurrentRasterFormat`, :class:`~spatialdata._io.format.CurrentShapesFormat`,
:class:`~spatialdata._io.format.CurrentPointsFormat`, :class:`~spatialdata._io.format.CurrentTablesFormat`.
"""
from spatialdata._io._utils import _open_zarr_store

if isinstance(file_path, str):
file_path = Path(file_path)
self._validate_can_safely_write_to_path(file_path, overwrite=overwrite)
self._validate_all_elements()
if isinstance(file_path, Path):
# TODO: also validate remote paths
self._validate_can_safely_write_to_path(file_path, overwrite=overwrite)
self._validate_all_elements()

store = parse_url(file_path, mode="w").store
store = _open_zarr_store(file_path, mode="w")
zarr_group = zarr.group(store=store, overwrite=overwrite)
self.write_attrs(zarr_group=zarr_group)
store.close()
Expand All@@ -1236,20 +1240,21 @@ def write(
def _write_element(
self,
element: SpatialElement | AnnData,
zarr_container_path: Path,
zarr_container_path: StoreLike,
element_type: str,
element_name: str,
overwrite: bool,
format: SpatialDataFormat | list[SpatialDataFormat] | None = None,
) -> None:
if not isinstance(zarr_container_path, Path):
if not isinstance(zarr_container_path, StoreLike):
raise ValueError(
f"zarr_container_path must be a Path object, type(zarr_container_path) = {type(zarr_container_path)}."
)
file_path_of_element = zarr_container_path / element_type / element_name
self._validate_can_safely_write_to_path(
file_path=file_path_of_element, overwrite=overwrite, saving_an_element=True
)
if isinstance(zarr_container_path, Path):
file_path_of_element = zarr_container_path / element_type / element_name
self._validate_can_safely_write_to_path(
file_path=file_path_of_element, overwrite=overwrite, saving_an_element=True
)

root_group, element_type_group, _ = self._get_groups_for_element(
zarr_path=zarr_container_path, element_type=element_type, element_name=element_name
Expand DownExpand Up@@ -1376,7 +1381,7 @@ def delete_element_from_disk(self, element_name: str | list[str]) -> None:
self.delete_element_from_disk(name)
return

from spatialdata._io._utils import _backed_elements_contained_in_path
from spatialdata._io._utils import _backed_elements_contained_in_path, _open_zarr_store

if self.path is None:
raise ValueError("The SpatialData object is not backed by a Zarr store.")
Expand DownExpand Up@@ -1417,7 +1422,7 @@ def delete_element_from_disk(self, element_name: str | list[str]) -> None:
)

# delete the element
store = parse_url(self.path, mode="r+").store
store = _open_zarr_store(self.path)
root = zarr.group(store=store)
root[element_type].pop(element_name)
store.close()
Expand All@@ -1438,15 +1443,24 @@ def _check_element_not_on_disk_with_different_type(self, element_type: str, elem
)

def write_consolidated_metadata(self) -> None:
store = parse_url(self.path, mode="r+").store
# consolidate metadata to more easily support remote reading bug in zarr. In reality, 'zmetadata' is written
# instead of '.zmetadata' see discussion https://github.com/zarr-developers/zarr-python/issues/1121
zarr.consolidate_metadata(store, metadata_key=".zmetadata")
from spatialdata._io._utils import _open_zarr_store

store = _open_zarr_store(self.path)
# Note that the store can be local (which does not have the zmetadata bug)
# or a remote FSStore (which has the bug).
# Consolidate metadata to more easily support remote reading bug in zarr.
# We write 'zmetadata' instead of the standard '.zmetadata' to avoid the FSStore bug.
# See discussion https://github.com/zarr-developers/zarr-python/issues/1121
zarr.consolidate_metadata(store, metadata_key="zmetadata")
store.close()

def has_consolidated_metadata(self) -> bool:
from spatialdata._io._utils import _open_zarr_store

return_value = False
store = parse_url(self.path, mode="r").store
store = _open_zarr_store(self.path)
# Note that the store can be local (which does not have the zmetadata bug)
# or a remote FSStore (which has the bug).
if "zmetadata" in store:
return_value = True
store.close()
Expand DownExpand Up@@ -1575,15 +1589,11 @@ def write_transformations(self, element_name: str | None = None) -> None:
)
axes = get_axes_names(element)
if isinstance(element, DataArray | DataTree):
from spatialdata._io._utils import (
overwrite_coordinate_transformations_raster,
)
from spatialdata._io._utils import overwrite_coordinate_transformations_raster

overwrite_coordinate_transformations_raster(group=element_group, axes=axes, transformations=transformations)
elif isinstance(element, DaskDataFrame | GeoDataFrame | AnnData):
from spatialdata._io._utils import (
overwrite_coordinate_transformations_non_raster,
)
from spatialdata._io._utils import overwrite_coordinate_transformations_non_raster

overwrite_coordinate_transformations_non_raster(
group=element_group, axes=axes, transformations=transformations
Expand Down
53 changes: 48 additions & 5 deletions src/spatialdata/_io/_utils.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,14 +15,18 @@
from pathlib import Path
from typing import Any, Literal

import zarr
import zarr.storage
from anndata import AnnData
from dask.array import Array as DaskArray
from dask.dataframe import DataFrame as DaskDataFrame
from geopandas import GeoDataFrame
from upath import UPath
from upath.implementations.local import PosixUPath, WindowsUPath
from xarray import DataArray, DataTree
from zarr.storage import FSStore

from spatialdata._core.spatialdata import SpatialData
from spatialdata._types import StoreLike
from spatialdata._utils import get_pyramid_levels
from spatialdata.models._utils import (
MappingToCoordinateSystem_t,
Expand All@@ -31,10 +35,7 @@
_validate_mapping_to_coordinate_system_type,
)
from spatialdata.transformations.ngff.ngff_transformations import NgffBaseTransformation
from spatialdata.transformations.transformations import (
BaseTransformation,
_get_current_output_axes,
)
from spatialdata.transformations.transformations import BaseTransformation, _get_current_output_axes


# suppress logger debug from ome_zarr with context manager
Expand DownExpand Up@@ -388,6 +389,48 @@ def save_transformations(sdata: SpatialData) -> None:
sdata.write_transformations()


def _open_zarr_store(path: StoreLike, **kwargs: Any) -> zarr.storage.BaseStore:
# TODO: ensure kwargs like mode are enforced everywhere and passed correctly to the store
if isinstance(path, str | Path):
# if the input is str or Path, map it to UPath
path = UPath(path)
if isinstance(path, PosixUPath | WindowsUPath):
# if the input is a local path, use DirectoryStore
return zarr.storage.DirectoryStore(path.path, dimension_separator="/")
if isinstance(path, zarr.Group):
# if the input is a zarr.Group, wrap it with a store
if isinstance(path.store, zarr.storage.DirectoryStore):
# create a simple FSStore if the store is a DirectoryStore with just the path
return FSStore(os.path.join(path.store.path, path.path), **kwargs)
if isinstance(path.store, FSStore):
# if the store within the zarr.Group is an FSStore, return it
# but extend the path of the store with that of the zarr.Group
return FSStore(path.store.path + "/" + path.path, fs=path.store.fs, **kwargs)
if isinstance(path.store, zarr.storage.ConsolidatedMetadataStore):
# if the store is a ConsolidatedMetadataStore, just return the underlying FSSpec store
return path.store.store
raise ValueError(f"Unsupported store type or zarr.Group: {type(path.store)}")
if isinstance(path, zarr.storage.StoreLike):
# if the input already a store, wrap it in an FSStore
return FSStore(path, **kwargs)
if isinstance(path, UPath):
# if input is a remote UPath, map it to an FSStore
return FSStore(path.path, fs=path.fs, **kwargs)
raise TypeError(f"Unsupported type: {type(path)}")


def _create_upath(path: StoreLike) -> UPath | None:
# try to create a UPath from the input
if isinstance(path, zarr.storage.ConsolidatedMetadataStore):
path = path.store # get the fsstore from the consolidated store
if isinstance(path, FSStore):
protocol = path.fs.protocol if isinstance(path.fs.protocol, str) else path.fs.protocol[0]
return UPath(path.path, protocol=protocol, **path.fs.storage_options)
if isinstance(path, zarr.storage.DirectoryStore):
return UPath(path.path)
return None


class BadFileHandleMethod(Enum):
ERROR = "error"
WARN = "warn"
Expand Down
2 changes: 1 addition & 1 deletion src/spatialdata/_io/format.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -257,7 +257,7 @@ def validate_table(
def format_implementations() -> Iterator[Format]:
"""Return an instance of each format implementation, newest to oldest."""
yield RasterFormatV02()
# yield RasterFormatV01() # same format string as FormatV04
yield RasterFormatV01() # same format string as FormatV04
yield FormatV04()
yield FormatV03()
yield FormatV02()
Expand Down
19 changes: 6 additions & 13 deletions src/spatialdata/_io/io_points.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,27 +14,19 @@
)
from spatialdata._io.format import CurrentPointsFormat, PointsFormats, _parse_version
from spatialdata.models import get_axes_names
from spatialdata.transformations._utils import (
_get_transformations,
_set_transformations,
)
from spatialdata.transformations._utils import _get_transformations, _set_transformations


def _read_points(
store: str | Path | MutableMapping | zarr.Group, # type: ignore[type-arg]
) -> DaskDataFrame:
"""Read points from a zarr store."""
assert isinstance(store, str | Path)
f = zarr.open(store, mode="r")

f = zarr.open(store, mode="r") if isinstance(store, str | Path | MutableMapping) else store
version = _parse_version(f, expect_attrs_key=True)
assert version is not None
format = PointsFormats[version]

path = os.path.join(f._store.path, f.path, "points.parquet")
# cache on remote file needed for parquet reader to work
# TODO: allow reading in the metadata without caching all the data
points = read_parquet("simplecache::" + path if path.startswith("http") else path)
points = read_parquet(f.store.path, filesystem=getattr(f.store, "fs", None))
assert isinstance(points, DaskDataFrame)

transformations = _get_transformations_from_ngff_dict(f.attrs.asdict()["coordinateTransformations"])
Expand All@@ -57,7 +49,8 @@ def write_points(
t = _get_transformations(points)

points_groups = group.require_group(name)
path = Path(points_groups._store.path) / points_groups.path / "points.parquet"
store = points_groups._store
new_path = os.path.join(store.path, points_groups.path, "points.parquet")

# The following code iterates through all columns in the 'points' DataFrame. If the column's datatype is
# 'category', it checks whether the categories of this column are known. If not, it explicitly converts the
Expand All@@ -70,7 +63,7 @@ def write_points(
c = c.cat.as_known()
points[column_name] = c

points.to_parquet(path)
points.to_parquet(new_path, filesystem=getattr(store, "fs", None))

attrs = format.attrs_to_dict(points.attrs)
attrs["version"] = format.spatialdata_format_version
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
5718cba
add basic support and remote SpatialData tests
berombau Jan 24, 2025
a8be620
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jan 24, 2025
ce686e4
fix pre-commit
LucaMarconato Jan 31, 2025
e7fa020
Revert "Update pyproject.toml"
LucaMarconato Jan 31, 2025
a9c0801
removed 3.13 from test ci
LucaMarconato Jan 31, 2025
5cfdaec
Merge branch 'no_python_313' into remote2
LucaMarconato Jan 31, 2025
4fe6a47
fix
LucaMarconato Jan 31, 2025
d97a1d2
uploading sdata to local s3 storage
LucaMarconato Jan 31, 2025
5e26b5e
add _open_zarr_store
berombau Jan 31, 2025
5794871
revert changing write function signature
berombau Jan 31, 2025
0207ff7
update _open_zarr_store with StoreLike
berombau Jan 31, 2025
7e497ff
read image element from base store
berombau Jan 31, 2025
c674281
clean up remote mock tests, focus only on reading raster elements
berombau Feb 1, 2025
fb953a0
improve remote http test, add alternative
berombau Feb 1, 2025
52bb5fc
add support for consolidated metadata store in util function, add _cr…
berombau Feb 1, 2025
ca82493
allow for groups as store input
berombau Feb 1, 2025
ecea0e6
handle consolidated metadata with upath
berombau Feb 1, 2025
734eb45
split remote reading tests between http and http with consolidated me…
berombau Feb 1, 2025
c0ffb1c
remove f_store_path, support remote raster types fully and keep local…
berombau Feb 1, 2025
d60bd85
Fix metadata_key bug now that store is not always FSStore. Add extra …
berombau Feb 1, 2025
c3fa8cf
add mypy fixes
berombau Feb 1, 2025
d16a638
Merge branch 'main' into remote2
ap-- Mar 17, 2025
23f4a89
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Mar 17, 2025
a80588c
Fix linting errors
ap-- Mar 17, 2025
020810b
fixed majority of tests
ap-- Mar 17, 2025
ba25564
spatialdata._io._utils: _open_zarr_store has to set dimension_separat…
ap-- Mar 17, 2025
b2ff8f8
stay in sync with ome zarr format
ap-- Mar 17, 2025
70480ce
spatialdata._io.io_raster: support remote stores
ap-- Mar 17, 2025
10cef3f
prevent crashing tests on 3.10
ap-- Mar 17, 2025
d9e4eac
Merge branch 'spatial-data-crash310' into remote2
ap-- Mar 17, 2025
41825fb
spatialdata.io.io_shapes: fix support for remote shapes
ap-- Mar 18, 2025
b47838f
spatialdata.io.io_points: fix support for remote points
ap-- Mar 18, 2025
c2e2d0d
tests: update tests to use remote paths
ap-- Mar 18, 2025
28466fd
spatialdata._io._utils: support consolidated remote stores
ap-- Mar 18, 2025
1ae94c7
spatialdata._io.format: supprot remote embl datasets on s3
ap-- Mar 18, 2025
aed327d
spatialdata._io: test remote tables support and fix repr
ap-- Mar 18, 2025
437d0d3
Merge branch 'cloud-fix-remote-shapes' into cloud-future
ap-- Mar 18, 2025
2d7aa96
Merge branch 'cloud-fix-remote-points' into cloud-future
ap-- Mar 18, 2025
8335a99
Merge branch 'cloud-fix-remote-tables' into cloud-future
ap-- Mar 18, 2025
0fa1df9
Merge branch 'cloud-fix-remote-embl-datasets' into cloud-future
ap-- Mar 18, 2025
e9e2c7b
add support for remote write of non-raster elements
berombau Apr 4, 2025
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
7 changes: 5 additions & 2 deletions pyproject.toml
Original file line numberDiff line numberDiff line change
Expand Up@@ -26,20 +26,22 @@ dependencies = [
"click",
"dask-image",
"dask>=2024.4.1,<=2024.11.2",
"fsspec",
"fsspec[s3,http]",
"geopandas>=0.14",
"multiscale_spatial_image>=2.0.2",
"networkx",
"numba>=0.55.0",
"numpy",
"ome_zarr>=0.8.4",
"universal_pathlib>=0.2.6",
"pandas",
"pooch",
"pyarrow",
"rich",
"setuptools",
"shapely>=2.0.1",
"spatial_image>=1.1.0",
"spatial_image>=1.2.1",
"xarray-dataclasses>=1.9.1",
"scikit-image",
"scipy",
"typing_extensions>=4.8.0",
Expand All@@ -58,6 +60,7 @@ test = [
"pytest-cov",
"pytest-mock",
"torch",
"moto[s3,server]"
]
docs = [
"sphinx>=4.5",
Expand Down
76 changes: 43 additions & 33 deletions src/spatialdata/_core/spatialdata.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -30,11 +30,8 @@
validate_table_attr_keys,
)
from spatialdata._logging import logger
from spatialdata._types import ArrayLike, Raster_T
from spatialdata._utils import (
_deprecation_alias,
_error_message_add_element,
)
from spatialdata._types import ArrayLike, Raster_T, StoreLike
from spatialdata._utils import _deprecation_alias, _error_message_add_element
from spatialdata.models import (
Image2DModel,
Image3DModel,
Expand DownExpand Up@@ -601,7 +598,7 @@ def path(self, value: Path | None) -> None:
)

def _get_groups_for_element(
self, zarr_path: Path, element_type: str, element_name: str
self, zarr_path: StoreLike, element_type: str, element_name: str
) -> tuple[zarr.Group, zarr.Group, zarr.Group]:
"""
Get the Zarr groups for the root, element_type and element for a specific element.
Expand All@@ -621,9 +618,9 @@ def _get_groups_for_element(
-------
either the existing Zarr subgroup or a new one.
"""
if not isinstance(zarr_path, Path):
raise ValueError("zarr_path should be a Path object")
store = parse_url(zarr_path, mode="r+").store
from spatialdata._io._utils import _open_zarr_store

store = _open_zarr_store(zarr_path, mode="r+")
root = zarr.group(store=store)
if element_type not in ["images", "labels", "points", "polygons", "shapes", "tables"]:
raise ValueError(f"Unknown element type {element_type}")
Expand DownExpand Up@@ -1068,9 +1065,12 @@ def elements_paths_on_disk(self) -> list[str]:
-------
A list of paths of the elements saved in the Zarr store.
"""
from spatialdata._io._utils import _open_zarr_store

if self.path is None:
raise ValueError("The SpatialData object is not backed by a Zarr store.")
store = parse_url(self.path, mode="r").store

store = _open_zarr_store(self.path)
root = zarr.group(store=store)
elements_in_zarr = []

Expand DownExpand Up@@ -1175,7 +1175,7 @@ def _validate_all_elements(self) -> None:

def write(
self,
file_path: str | Path,
file_path: StoreLike,
overwrite: bool = False,
consolidate_metadata: bool = True,
format: SpatialDataFormat | list[SpatialDataFormat] | None = None,
Expand DownExpand Up@@ -1205,12 +1205,16 @@ def write(
:class:`~spatialdata._io.format.CurrentRasterFormat`, :class:`~spatialdata._io.format.CurrentShapesFormat`,
:class:`~spatialdata._io.format.CurrentPointsFormat`, :class:`~spatialdata._io.format.CurrentTablesFormat`.
"""
from spatialdata._io._utils import _open_zarr_store

if isinstance(file_path, str):
file_path = Path(file_path)
self._validate_can_safely_write_to_path(file_path, overwrite=overwrite)
self._validate_all_elements()
if isinstance(file_path, Path):
# TODO: also validate remote paths
self._validate_can_safely_write_to_path(file_path, overwrite=overwrite)
self._validate_all_elements()

store = parse_url(file_path, mode="w").store
store = _open_zarr_store(file_path, mode="w")
zarr_group = zarr.group(store=store, overwrite=overwrite)
self.write_attrs(zarr_group=zarr_group)
store.close()
Expand All@@ -1236,20 +1240,21 @@ def write(
def _write_element(
self,
element: SpatialElement | AnnData,
zarr_container_path: Path,
zarr_container_path: StoreLike,
element_type: str,
element_name: str,
overwrite: bool,
format: SpatialDataFormat | list[SpatialDataFormat] | None = None,
) -> None:
if not isinstance(zarr_container_path, Path):
if not isinstance(zarr_container_path, StoreLike):
raise ValueError(
f"zarr_container_path must be a Path object, type(zarr_container_path) = {type(zarr_container_path)}."
)
file_path_of_element = zarr_container_path / element_type / element_name
self._validate_can_safely_write_to_path(
file_path=file_path_of_element, overwrite=overwrite, saving_an_element=True
)
if isinstance(zarr_container_path, Path):
file_path_of_element = zarr_container_path / element_type / element_name
self._validate_can_safely_write_to_path(
file_path=file_path_of_element, overwrite=overwrite, saving_an_element=True
)

root_group, element_type_group, _ = self._get_groups_for_element(
zarr_path=zarr_container_path, element_type=element_type, element_name=element_name
Expand DownExpand Up@@ -1376,7 +1381,7 @@ def delete_element_from_disk(self, element_name: str | list[str]) -> None:
self.delete_element_from_disk(name)
return

from spatialdata._io._utils import _backed_elements_contained_in_path
from spatialdata._io._utils import _backed_elements_contained_in_path, _open_zarr_store

if self.path is None:
raise ValueError("The SpatialData object is not backed by a Zarr store.")
Expand DownExpand Up@@ -1417,7 +1422,7 @@ def delete_element_from_disk(self, element_name: str | list[str]) -> None:
)

# delete the element
store = parse_url(self.path, mode="r+").store
store = _open_zarr_store(self.path)
root = zarr.group(store=store)
root[element_type].pop(element_name)
store.close()
Expand All@@ -1438,15 +1443,24 @@ def _check_element_not_on_disk_with_different_type(self, element_type: str, elem
)

def write_consolidated_metadata(self) -> None:
store = parse_url(self.path, mode="r+").store
# consolidate metadata to more easily support remote reading bug in zarr. In reality, 'zmetadata' is written
# instead of '.zmetadata' see discussion https://github.com/zarr-developers/zarr-python/issues/1121
zarr.consolidate_metadata(store, metadata_key=".zmetadata")
from spatialdata._io._utils import _open_zarr_store

store = _open_zarr_store(self.path)
# Note that the store can be local (which does not have the zmetadata bug)
# or a remote FSStore (which has the bug).
# Consolidate metadata to more easily support remote reading bug in zarr.
# We write 'zmetadata' instead of the standard '.zmetadata' to avoid the FSStore bug.
# See discussion https://github.com/zarr-developers/zarr-python/issues/1121
zarr.consolidate_metadata(store, metadata_key="zmetadata")
store.close()

def has_consolidated_metadata(self) -> bool:
from spatialdata._io._utils import _open_zarr_store

return_value = False
store = parse_url(self.path, mode="r").store
store = _open_zarr_store(self.path)
# Note that the store can be local (which does not have the zmetadata bug)
# or a remote FSStore (which has the bug).
if "zmetadata" in store:
return_value = True
store.close()
Expand DownExpand Up@@ -1575,15 +1589,11 @@ def write_transformations(self, element_name: str | None = None) -> None:
)
axes = get_axes_names(element)
if isinstance(element, DataArray | DataTree):
from spatialdata._io._utils import (
overwrite_coordinate_transformations_raster,
)
from spatialdata._io._utils import overwrite_coordinate_transformations_raster

overwrite_coordinate_transformations_raster(group=element_group, axes=axes, transformations=transformations)
elif isinstance(element, DaskDataFrame | GeoDataFrame | AnnData):
from spatialdata._io._utils import (
overwrite_coordinate_transformations_non_raster,
)
from spatialdata._io._utils import overwrite_coordinate_transformations_non_raster

overwrite_coordinate_transformations_non_raster(
group=element_group, axes=axes, transformations=transformations
Expand Down
53 changes: 48 additions & 5 deletions src/spatialdata/_io/_utils.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,14 +15,18 @@
from pathlib import Path
from typing import Any, Literal

import zarr
import zarr.storage
from anndata import AnnData
from dask.array import Array as DaskArray
from dask.dataframe import DataFrame as DaskDataFrame
from geopandas import GeoDataFrame
from upath import UPath
from upath.implementations.local import PosixUPath, WindowsUPath
from xarray import DataArray, DataTree
from zarr.storage import FSStore

from spatialdata._core.spatialdata import SpatialData
from spatialdata._types import StoreLike
from spatialdata._utils import get_pyramid_levels
from spatialdata.models._utils import (
MappingToCoordinateSystem_t,
Expand All@@ -31,10 +35,7 @@
_validate_mapping_to_coordinate_system_type,
)
from spatialdata.transformations.ngff.ngff_transformations import NgffBaseTransformation
from spatialdata.transformations.transformations import (
BaseTransformation,
_get_current_output_axes,
)
from spatialdata.transformations.transformations import BaseTransformation, _get_current_output_axes


# suppress logger debug from ome_zarr with context manager
Expand DownExpand Up@@ -388,6 +389,48 @@ def save_transformations(sdata: SpatialData) -> None:
sdata.write_transformations()


def _open_zarr_store(path: StoreLike, **kwargs: Any) -> zarr.storage.BaseStore:
# TODO: ensure kwargs like mode are enforced everywhere and passed correctly to the store
if isinstance(path, str | Path):
# if the input is str or Path, map it to UPath
path = UPath(path)
if isinstance(path, PosixUPath | WindowsUPath):
# if the input is a local path, use DirectoryStore
return zarr.storage.DirectoryStore(path.path, dimension_separator="/")
if isinstance(path, zarr.Group):
# if the input is a zarr.Group, wrap it with a store
if isinstance(path.store, zarr.storage.DirectoryStore):
# create a simple FSStore if the store is a DirectoryStore with just the path
return FSStore(os.path.join(path.store.path, path.path), **kwargs)
if isinstance(path.store, FSStore):
# if the store within the zarr.Group is an FSStore, return it
# but extend the path of the store with that of the zarr.Group
return FSStore(path.store.path + "/" + path.path, fs=path.store.fs, **kwargs)
if isinstance(path.store, zarr.storage.ConsolidatedMetadataStore):
# if the store is a ConsolidatedMetadataStore, just return the underlying FSSpec store
return path.store.store
raise ValueError(f"Unsupported store type or zarr.Group: {type(path.store)}")
if isinstance(path, zarr.storage.StoreLike):
# if the input already a store, wrap it in an FSStore
return FSStore(path, **kwargs)
if isinstance(path, UPath):
# if input is a remote UPath, map it to an FSStore
return FSStore(path.path, fs=path.fs, **kwargs)
raise TypeError(f"Unsupported type: {type(path)}")


def _create_upath(path: StoreLike) -> UPath | None:
# try to create a UPath from the input
if isinstance(path, zarr.storage.ConsolidatedMetadataStore):
path = path.store # get the fsstore from the consolidated store
if isinstance(path, FSStore):
protocol = path.fs.protocol if isinstance(path.fs.protocol, str) else path.fs.protocol[0]
return UPath(path.path, protocol=protocol, **path.fs.storage_options)
if isinstance(path, zarr.storage.DirectoryStore):
return UPath(path.path)
return None


class BadFileHandleMethod(Enum):
ERROR = "error"
WARN = "warn"
Expand Down
2 changes: 1 addition & 1 deletion src/spatialdata/_io/format.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -257,7 +257,7 @@ def validate_table(
def format_implementations() -> Iterator[Format]:
"""Return an instance of each format implementation, newest to oldest."""
yield RasterFormatV02()
# yield RasterFormatV01() # same format string as FormatV04
yield RasterFormatV01() # same format string as FormatV04
yield FormatV04()
yield FormatV03()
yield FormatV02()
Expand Down
19 changes: 6 additions & 13 deletions src/spatialdata/_io/io_points.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,27 +14,19 @@
)
from spatialdata._io.format import CurrentPointsFormat, PointsFormats, _parse_version
from spatialdata.models import get_axes_names
from spatialdata.transformations._utils import (
_get_transformations,
_set_transformations,
)
from spatialdata.transformations._utils import _get_transformations, _set_transformations


def _read_points(
store: str | Path | MutableMapping | zarr.Group, # type: ignore[type-arg]
) -> DaskDataFrame:
"""Read points from a zarr store."""
assert isinstance(store, str | Path)
f = zarr.open(store, mode="r")

f = zarr.open(store, mode="r") if isinstance(store, str | Path | MutableMapping) else store
version = _parse_version(f, expect_attrs_key=True)
assert version is not None
format = PointsFormats[version]

path = os.path.join(f._store.path, f.path, "points.parquet")
# cache on remote file needed for parquet reader to work
# TODO: allow reading in the metadata without caching all the data
points = read_parquet("simplecache::" + path if path.startswith("http") else path)
points = read_parquet(f.store.path, filesystem=getattr(f.store, "fs", None))
assert isinstance(points, DaskDataFrame)

transformations = _get_transformations_from_ngff_dict(f.attrs.asdict()["coordinateTransformations"])
Expand All@@ -57,7 +49,8 @@ def write_points(
t = _get_transformations(points)

points_groups = group.require_group(name)
path = Path(points_groups._store.path) / points_groups.path / "points.parquet"
store = points_groups._store
new_path = os.path.join(store.path, points_groups.path, "points.parquet")

# The following code iterates through all columns in the 'points' DataFrame. If the column's datatype is
# 'category', it checks whether the categories of this column are known. If not, it explicitly converts the
Expand All@@ -70,7 +63,7 @@ def write_points(
c = c.cat.as_known()
points[column_name] = c

points.to_parquet(path)
points.to_parquet(new_path, filesystem=getattr(store, "fs", None))

attrs = format.attrs_to_dict(points.attrs)
attrs["version"] = format.spatialdata_format_version
Expand Down
Loading