Skip to content

Commit

Permalink
Added new endpoint /viewer/state
Browse files Browse the repository at this point in the history
  • Loading branch information
forman committed Nov 17, 2024
1 parent 90e1348 commit d03c4b6
Show file tree
Hide file tree
Showing 7 changed files with 163 additions and 38 deletions.
21 changes: 20 additions & 1 deletion CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,34 @@
`xcube.core.gridmapping.GridMapping`, enabling users to set the grid-mapping
resolution directly, which speeds up the method by avoiding time-consuming
spatial resolution estimation. (#1082)
* The behaviour of the function `xcube.core.resample.resample_in_space()` has

* The behaviour of the function `xcube.core.resample.resample_in_space()` has
been changed if no `tile_size` is specified for the target grid mapping. It now
defaults to the `tile_size` of the source grid mapping, improving the
user-friendliness of resampling and reprojection. (#1082)

* The `"https"` data store (`store = new_data_store("https", ...)`) now allows
for lazily accessing NetCDF files.
Implementation note: For this to work, the `DatasetNetcdfFsDataAccessor`
class has been adjusted. (#1083)

* Added new endpoint `/viewer/state` to xcube Server that allows for xcube Viewer
state persistence. (#1088)

The new viewer API operations are:
- `GET /viewer/state` to get a keys of stored states or restore a specific state;
- `PUT /viewer/state` to store a state and receive a key for it.

Persistence is configured using new optional `Viewer/Persistence` setting:
```yaml
Viewer:
Persistence:
# Any filesystem. Can also be relative to base_dir.
Path: memory://states
# Filesystem-specific storage options
# StorageOptions: ...
```

### Fixes

* The function `xcube.core.resample.resample_in_space()` now always operates
Expand Down
16 changes: 16 additions & 0 deletions test/webapi/res/config-persistence.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
Viewer:
Persistence:
Path: "memory://states"

DataStores:
- Identifier: test
StoreId: file
StoreParams:
root: examples/serve/demo
Datasets:
- Path: "cube-1-250-250.zarr"
TimeSeriesDataset: "cube-5-100-200.zarr"

ServiceProvider:
ProviderName: "Brockmann Consult GmbH"
ProviderSite: "https://www.brockmann-consult.de"
10 changes: 9 additions & 1 deletion test/webapi/viewer/test_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import collections.abc
import unittest
from typing import Optional, Union, Any
from collections.abc import Mapping
from collections.abc import Mapping, MutableMapping

import fsspec
from chartlets import ExtensionContext
Expand Down Expand Up @@ -51,6 +51,14 @@ def test_config_path_ok(self):
ctx2 = get_viewer_ctx(server_config=config)
self.assertEqual(config_path, ctx2.config_path)

def test_without_persistence(self):
ctx = get_viewer_ctx()
self.assertIsNone(ctx.persistence)

def test_with_persistence(self):
ctx = get_viewer_ctx("config-persistence.yml")
self.assertIsInstance(ctx.persistence, MutableMapping)

def test_panels_local(self):
ctx = get_viewer_ctx("config-panels.yml")
self.assert_extensions_ok(ctx.ext_ctx)
Expand Down
58 changes: 55 additions & 3 deletions test/webapi/viewer/test_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,60 @@ def test_viewer_config(self):
self.assertResponseOK(response)


class ViewerStateRoutesNoConfigTest(RoutesTestCase):

def test_get(self):
response = self.fetch("/viewer/state")
self.assertEqual(504, response.status)
self.assertEqual("Persistence not supported", response.reason)

def test_put(self):
response = self.fetch("/viewer/state", method="PUT", body={"state": 123})
self.assertEqual(504, response.status)
self.assertEqual("Persistence not supported", response.reason)


class ViewerStateRoutesTest(RoutesTestCase):

def get_config_filename(self) -> str:
return "config-persistence.yml"

def test_get_and_put_states(self):
response = self.fetch("/viewer/state")
self.assertResponseOK(response)
result = response.json()
self.assertEqual({"keys": []}, result)

response = self.fetch("/viewer/state", method="PUT", body={"state": "Hallo"})
self.assertResponseOK(response)
result = response.json()
self.assertIsInstance(result, dict)
self.assertIn("key", result)
key1 = result["key"]

response = self.fetch("/viewer/state", method="PUT", body={"state": "Hello"})
self.assertResponseOK(response)
result = response.json()
self.assertIsInstance(result, dict)
self.assertIn("key", result)
key2 = result["key"]

response = self.fetch("/viewer/state")
self.assertResponseOK(response)
result = response.json()
self.assertEqual({key1, key2}, set(result["keys"]))

response = self.fetch(f"/viewer/state?key={key1}")
self.assertResponseOK(response)
result = response.json()
self.assertEqual({"state": "Hallo"}, result)

response = self.fetch(f"/viewer/state?key={key2}")
self.assertResponseOK(response)
result = response.json()
self.assertEqual({"state": "Hello"}, result)


class ViewerExtRoutesTest(RoutesTestCase):

def setUp(self) -> None:
Expand Down Expand Up @@ -276,7 +330,5 @@ def test_viewer_ext_callback(self):
},
]
},
"extensions": [
{"contributes": ["panels"], "name": "my_ext", "version": "0.0.0"}
],
"extensions": [{"contributes": ["panels"], "name": "my_ext", "version": "0.0.0"}],
}
9 changes: 9 additions & 0 deletions xcube/webapi/viewer/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@
additional_properties=False,
)

PERSISTENCE_SCHEMA = JsonObjectSchema(
properties=dict(
Path=STRING_SCHEMA, StorageOptions=JsonObjectSchema(additional_properties=True)
),
required=["Path"],
additional_properties=False,
)

EXTENSIONS_SCHEMA = JsonArraySchema(
items=STRING_SCHEMA,
min_items=1,
Expand All @@ -31,6 +39,7 @@
properties=dict(
Configuration=CONFIGURATION_SCHEMA,
Augmentation=AUGMENTATION_SCHEMA,
Persistence=PERSISTENCE_SCHEMA,
),
additional_properties=False,
)
Expand Down
35 changes: 25 additions & 10 deletions xcube/webapi/viewer/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
from contextlib import contextmanager
from functools import cached_property
from pathlib import Path
from typing import Optional
from collections.abc import Mapping
from typing import Optional, Any
from collections.abc import Mapping, MutableMapping
import sys

from chartlets import Extension
Expand All @@ -29,16 +29,23 @@ class ViewerContext(ResourcesContext):
def __init__(self, server_ctx: Context):
super().__init__(server_ctx)
self.ext_ctx: ExtensionContext | None = None
self.persistence: MutableMapping | None = None

def on_update(self, prev_context: Optional[Context]):
super().on_update(prev_context)
viewer_config: dict = self.config.get("Viewer")
if viewer_config:
augmentation: dict | None = viewer_config.get("Augmentation")
if augmentation:
path: Path | None = augmentation.get("Path")
extension_refs: list[str] = augmentation["Extensions"]
self.set_extension_context(path, extension_refs)
if not viewer_config:
return
persistence: dict | None = viewer_config.get("Persistence")
if persistence:
path = self.get_config_path(persistence, "Persistence")
storage_options = persistence.get("StorageOptions")
self.set_persistence(path, storage_options)
augmentation: dict | None = viewer_config.get("Augmentation")
if augmentation:
path = augmentation.get("Path")
extension_refs = augmentation["Extensions"]
self.set_extension_context(path, extension_refs)

@cached_property
def config_items(self) -> Optional[Mapping[str, bytes]]:
Expand All @@ -55,7 +62,15 @@ def config_path(self) -> Optional[str]:
"'Configuration' item of 'Viewer'",
)

def set_extension_context(self, path: Path | None, extension_refs: list[str]):
def set_persistence(self, path: str, storage_options: dict[str, Any] | None):
fs_root: tuple[fsspec.AbstractFileSystem, str] = fsspec.core.url_to_fs(
path, **(storage_options or {})
)
fs, root = fs_root
self.persistence = fs.get_mapper(root, create=True, check=True)
LOG.info(f"Viewer persistence established for path {path!r}")

def set_extension_context(self, path: str | None, extension_refs: list[str]):
module_path = self.base_dir
if path:
module_path = f"{module_path}/{path}"
Expand All @@ -67,7 +82,7 @@ def set_extension_context(self, path: Path | None, extension_refs: list[str]):
local_module_path = Path(module_path)
else:
temp_module_path = new_temp_dir("xcube-viewer-aux-")
LOG.warning(f"Downloading {module_path} to {temp_module_path}")
LOG.warning(f"Downloading {module_path!r} to {temp_module_path!r}")
fs.get(fs_path + "/**/*", temp_module_path + "/", recursive=True)
local_module_path = Path(temp_module_path)
with prepend_sys_path(local_module_path):
Expand Down
52 changes: 29 additions & 23 deletions xcube/webapi/viewer/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,51 +106,57 @@ def get_content_type(path: str) -> Optional[str]:
@api.route("/viewer/state")
class ViewerStateHandler(ApiHandler[ViewerContext]):

_stored_states: dict[str, bytes] = {}
# noinspection SpellCheckingInspection
_id_chars = "abcdefghijklmnopqrstuvwxyz0123456789"

@api.operation(
operationId="getViewerState",
summary="Get a previously stored viewer state.",
summary="Get previously stored viewer state keys or a specific state.",
parameters=[
{
"name": "stateId",
"name": "key",
"in": "query",
"description": "The state identifier.",
"required": True,
"description": "The key of a previously stored state.",
"required": False,
"schema": {"type": "string"},
}
],
)
def get(self):
state_id = self.request.get_query_arg("stateId", str)
if state_id in self._stored_states:
state = self._stored_states[state_id]
LOG.info(f"Restored state of size {len(state)} for key {state_id!r}")
if self.ctx.persistence is None:
self.response.set_status(504, "Persistence not supported")
return
key = self.request.get_query_arg("key", type=str, default="")
if key:
if key not in self.ctx.persistence:
self.response.set_status(404, "State not found")
return
state = self.ctx.persistence[key]
LOG.info(f"Restored state ({len(state)} bytes) for key {key!r}")
self.response.write(state)
else:
self.response.set_status(404)
keys = list(self.ctx.persistence.keys())
self.response.write({"keys": keys})

@api.operation(
operationId="putViewerState",
summary="Store a viewer state and return a new state identifier.",
summary="Store a viewer state and return a state key.",
)
def put(self):
if self.ctx.persistence is None:
self.response.set_status(504, "Persistence not supported")
return
state = self.request.body
state_id = self.new_id()
self._stored_states[state_id] = state
LOG.info(f"Stored state of size {len(state)} using key {state_id!r}")
self.response.write({"stateId": state_id})

@classmethod
def new_id(cls, length: int = 8) -> str:
states = cls._stored_states
chars = cls._id_chars
key = self.new_key()
self.ctx.persistence[key] = state
LOG.info(f"Stored state ({len(state)} bytes) using key {key!r}")
self.response.write({"key": key})

def new_key(self, length: int = 8) -> str:
while True:
state_id = "".join(random.choice(chars) for _ in range(length))
if state_id not in states:
return state_id
key = "".join(random.choice(self._id_chars) for _ in range(length))
if key not in self.ctx.persistence:
return key


class ViewerExtHandler(ApiHandler[ViewerContext]):
Expand Down

0 comments on commit d03c4b6

Please sign in to comment.