Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Enable metadata access for remote datasets. #1163

Merged
merged 17 commits into from
Sep 23, 2024
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ nav:
- Layer: api/webknossos/dataset/layer.md
- MagView: api/webknossos/dataset/mag_view.md
- View: api/webknossos/dataset/view.md
- RemoteFolder: api/webknossos/dataset/remote_folder.md
- Annotation: api/webknossos/annotation/annotation.md
- Skeleton:
- Skeleton: api/webknossos/skeleton/skeleton.md
Expand Down
1 change: 1 addition & 0 deletions webknossos/Changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ For upgrade instructions, please check the respective _Breaking Changes_ section
### Breaking Changes

### Added
- Enable metadata access for remote datasets. [#1163](https://github.com/scalableminds/webknossos-libs/pull/1163)

### Changed

Expand Down
21 changes: 21 additions & 0 deletions webknossos/examples/accessing_metadata.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import webknossos as wk


def main() -> None:
with wk.webknossos_context(url="https://webknossos.org/"):
l4_sample_dataset = wk.Dataset.open_remote("l4_sample")
# Access the metadata of the dataset
print(l4_sample_dataset.metadata)

# Edit the metadata of the dataset
l4_sample_dataset.metadata["new_key"] = "new_value"

# Access metadata of a folder
print(l4_sample_dataset.folder.metadata)

# Edit the metadata of the folder
l4_sample_dataset.folder.metadata["new_folder_key"] = "new_folder_value"


if __name__ == "__main__":
main()
25 changes: 25 additions & 0 deletions webknossos/webknossos/client/api_client/_abstract_api_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ def _get_json_paginated(
response, response_type
), self._extract_total_count_header(response)

def _put_json(self, route: str, body_structured: Any) -> None:
body_json = self._prepare_for_json(body_structured)
self._put(route, body_json)

def _patch_json(self, route: str, body_structured: Any) -> None:
body_json = self._prepare_for_json(body_structured)
self._patch(route, body_json)
Expand Down Expand Up @@ -125,6 +129,27 @@ def _patch(
timeout_seconds=timeout_seconds,
)

def _put(
self,
route: str,
body_json: Optional[Any] = None,
query: Optional[Query] = None,
multipart_data: Optional[httpx._types.RequestData] = None,
files: Optional[httpx._types.RequestFiles] = None,
retry_count: int = 0,
timeout_seconds: Optional[float] = None,
) -> httpx.Response:
return self._request(
"PUT",
route,
body_json=body_json,
multipart_data=multipart_data,
files=files,
query=query,
retry_count=retry_count,
timeout_seconds=timeout_seconds,
)

def _post(
self,
route: str,
Expand Down
26 changes: 26 additions & 0 deletions webknossos/webknossos/client/api_client/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,21 @@ class ApiBoundingBox:
depth: int


@attr.s(auto_attribs=True)
class ApiAdditionalAxis:
name: str
bounds: Tuple[int, int]
index: int


@attr.s(auto_attribs=True)
class ApiDataLayer:
name: str
category: str
element_class: str
bounding_box: ApiBoundingBox
resolutions: List[Tuple[int, int, int]]
additional_axes: Optional[List[ApiAdditionalAxis]] = None
largest_segment_id: Optional[int] = None
default_view_configuration: Optional[Dict[str, Any]] = None

Expand All @@ -73,6 +81,13 @@ class ApiDataSource:
scale: Optional[ApiVoxelSize] = None


@attr.s(auto_attribs=True)
class ApiMetadata:
key: str
type: str
value: Any


@attr.s(auto_attribs=True)
class ApiDataset:
name: str
Expand All @@ -82,6 +97,7 @@ class ApiDataset:
tags: List[str]
data_store: ApiDataStore
data_source: ApiDataSource
metadata: Optional[List[ApiMetadata]] = None
display_name: Optional[str] = None
description: Optional[str] = None

Expand Down Expand Up @@ -291,3 +307,13 @@ class ApiFolderWithParent:
id: str
name: str
parent: Optional[str] = None


@attr.s(auto_attribs=True)
class ApiFolder:
id: str
name: str
allowed_teams: List[ApiTeam]
allowed_teams_cumulative: List[ApiTeam]
is_editable: bool
metadata: Optional[List[ApiMetadata]] = None
119 changes: 119 additions & 0 deletions webknossos/webknossos/dataset/_metadata.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
from contextlib import contextmanager
from typing import (
Any,
Dict,
Generator,
Iterator,
List,
MutableMapping,
Sequence,
TypeVar,
Union,
)

from webknossos.client.api_client.models import ApiDataset, ApiFolder, ApiMetadata
from webknossos.utils import infer_metadata_type, parse_metadata_value

_T = TypeVar("_T", bound="Metadata")


class Metadata(MutableMapping):
__slots__ = ()
_api_path: str
_api_type: Any

def __init__(self, _id: str, *args: Any, **kwargs: Dict[str, Any]) -> None:
if not self._api_path or not self._api_type:
raise NotImplementedError(
"This class is not meant to be used directly. Please use FolderMetadata or DatasetMetadata."
)
super().__init__(*args, **kwargs)
self._id: str = _id
self._has_changed: bool = False
self._mapping: Dict = {}

@contextmanager
def _recent_metadata(self: _T) -> Generator[_T, None, None]:
from ..client.context import _get_api_client

try:
client = _get_api_client()
full_object = client._get_json(
f"{self._api_path}{self._id}",
self._api_type, # type: ignore
)
metadata: List[ApiMetadata] = full_object.metadata
if metadata is not None:
self._mapping = {
element.key: parse_metadata_value(element.value, element.type)
for element in metadata
}
else:
self._mapping = {}
yield self
finally:
if self._has_changed:
api_metadata = [
ApiMetadata(key=k, type=infer_metadata_type(v), value=v)
for k, v in self._mapping.items()
]

full_object.metadata = api_metadata
if self._api_type == ApiDataset:
client._patch_json(f"{self._api_path}{self._id}", full_object)
else:
client._put_json(f"{self._api_path}{self._id}", full_object)
self._has_changed = False

def __setitem__(
self, key: str, value: Union[str, int, float, Sequence[str]]
) -> None:
with self._recent_metadata() as metadata:
metadata._has_changed = True
metadata._mapping[key] = value

def __getitem__(self, key: str) -> Union[str, int, float, Sequence[str]]:
with self._recent_metadata() as metadata:
return metadata._mapping[key]

def __delitem__(self, key: str) -> None:
with self._recent_metadata() as metadata:
metadata._has_changed = True
del metadata._mapping[key]

def __contains__(self, key: object) -> bool:
with self._recent_metadata() as metadata:
return key in metadata._mapping

def __eq__(self, other: object) -> bool:
if not isinstance(other, Metadata):
raise NotImplementedError(
f"Cannot compare {self.__class__.__name__} with {other.__class__.__name__}"
)
with self._recent_metadata() as metadata:
return metadata._mapping == other._mapping

def __ne__(self, other: object) -> bool:
return not self == other

def __iter__(self) -> Iterator[Any]:
with self._recent_metadata() as metadata:
return iter(metadata._mapping)

def __len__(self) -> int:
with self._recent_metadata() as metadata:
return len(metadata._mapping)

def __repr__(self) -> str:
with self._recent_metadata() as metadata:
return f"{self.__class__.__name__}({repr(metadata._mapping)})"


class FolderMetadata(Metadata):
_api_path = "/folders/"
_api_type = ApiFolder


class DatasetMetadata(Metadata):
_api_path = "/datasets/"
_api_type = ApiDataset
27 changes: 24 additions & 3 deletions webknossos/webknossos/dataset/dataset.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,10 @@
from numpy.typing import DTypeLike
from upath import UPath

from webknossos.dataset._metadata import DatasetMetadata
from webknossos.geometry.vec_int import VecIntLike

from ..client.api_client.models import ApiDataset
from ..client.api_client.models import ApiDataset, ApiMetadata
from ..geometry.vec3_int import Vec3Int, Vec3IntLike
from ._array import ArrayException, ArrayInfo, BaseArray
from ._utils import pims_images
Expand Down Expand Up @@ -69,6 +70,7 @@
copytree,
count_defined_values,
get_executor_for_args,
infer_metadata_type,
is_fs_path,
named_partial,
rmtree,
Expand Down Expand Up @@ -2082,6 +2084,7 @@ def _update_dataset_info(
is_public: bool = _UNSET,
folder_id: str = _UNSET,
tags: List[str] = _UNSET,
metadata: Optional[List[ApiMetadata]] = _UNSET,
) -> None:
from ..client.context import _get_api_client

Expand All @@ -2099,14 +2102,32 @@ def _update_dataset_info(
info.is_public = is_public
if folder_id is not _UNSET:
info.folder_id = folder_id
if display_name is not _UNSET:
info.display_name = display_name
if metadata is not _UNSET:
info.metadata = metadata

with self._context:
_get_api_client().dataset_update(
self._organization_id, self._dataset_name, info
)

@property
def metadata(self) -> DatasetMetadata:
return DatasetMetadata(f"{self._organization_id}/{self._dataset_name}")

@metadata.setter
def metadata(
self,
metadata: Optional[
Union[Dict[str, Union[str, int, float, Sequence[str]]], DatasetMetadata]
],
) -> None:
if metadata is not None:
api_metadata = [
ApiMetadata(key=k, type=infer_metadata_type(v), value=v)
for k, v in metadata.items()
]
self._update_dataset_info(metadata=api_metadata)

@property
def display_name(self) -> Optional[str]:
return self._get_dataset_info().display_name
Expand Down
31 changes: 28 additions & 3 deletions webknossos/webknossos/dataset/remote_folder.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
from typing import Iterable, List
from typing import Dict, Iterable, List, Optional, Sequence, Union

import attr

from ..client.api_client.models import ApiFolderWithParent
from webknossos.dataset._metadata import FolderMetadata
from webknossos.utils import infer_metadata_type

from ..client.api_client.models import ApiFolder, ApiFolderWithParent, ApiMetadata


def _get_folder_path(
Expand All @@ -15,8 +18,10 @@ def _get_folder_path(
return f"{_get_folder_path(next(f for f in all_folders if f.id == folder.parent), all_folders)}/{folder.name}"


@attr.frozen
@attr.define
class RemoteFolder:
"""This class is used to access and edit metadata of a folder on the webknossos server."""

id: str
name: str

Expand Down Expand Up @@ -47,3 +52,23 @@ def get_by_path(cls, path: str) -> "RemoteFolder":
return cls(name=folder_info.name, id=folder_info.id)

raise KeyError(f"Could not find folder {path}.")

@property
def metadata(self) -> FolderMetadata:
return FolderMetadata(self.id)

@metadata.setter
def metadata(
self, metadata: Optional[Dict[str, Union[str, int, float, Sequence[str]]]]
) -> None:
from ..client.context import _get_api_client

client = _get_api_client(enforce_auth=True)
folder = client._get_json(f"/folders/{self.id}", ApiFolder)
if metadata is not None:
api_metadata = [
ApiMetadata(key=k, type=infer_metadata_type(v), value=v)
for k, v in metadata.items()
]
folder.metadata = api_metadata
client._put_json(f"/folders/{self.id}", folder)
2 changes: 1 addition & 1 deletion webknossos/webknossos/skeleton/skeleton.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
Vector3 = Tuple[float, float, float]


@attr.define()
@attr.define
class Skeleton(Group):
"""
Representation of the [skeleton](/webknossos/skeleton_annotation.html) of an `Annotation`.
Expand Down
Loading
Loading