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

New endpoints for managing viewer state #1091

Merged
merged 6 commits into from
Nov 20, 2024
Merged
Show file tree
Hide file tree
Changes from 5 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
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.
b-yogesh marked this conversation as resolved.
Show resolved Hide resolved

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
38 changes: 37 additions & 1 deletion docs/source/cli/xcube_serve.rst
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,9 @@ respect to ``base_dir`` and to normalize it. For example
.. _viewer configuration:

Viewer Configuration [optional]
------------------------------
-------------------------------

### Viewer Branding

The xcube server endpoint ``/viewer/config/{*path}`` allows
for configuring the viewer accessible via endpoint ``/viewer``.
Expand All @@ -260,6 +262,40 @@ are used instead.

.. _datasets:


### Viewer State Persistence

The xcube server endpoint ``/viewer/state`` allows for persisting viewer state
if configured via the ``Viewer/Persistence`` setting. It requires a ``Path``
which may be either an absolute local path or a URL or it may be relative to
the configuration's ``base_dìr`` setting.
xcube Server will use this location to persist xcube Viewer states.
therefore it should refer to a writable and have sufficient space.
forman marked this conversation as resolved.
Show resolved Hide resolved
A single state takes at least 2000 bytes but can also have several megabytes
depending on the user's activities (user shapes, layers, time-series, statistics).

.. code-block:: yaml

Viewer:
Persistence:
Path: user-states
b-yogesh marked this conversation as resolved.
Show resolved Hide resolved

You can add filesystem-specific storage options using the ``StorageOptions``
setting. For example:

.. code-block:: yaml

Viewer:
Persistence:
Path: s3://my-project-bucket/viewer/states
StorageOptions:
key: my-key
secret: my-secret
region: my-region

Note that it is not possible yet to limit state information stored.
If desired, this should be implemented outside of xcube server.

Datasets [mandatory]
--------------------

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)
b-yogesh marked this conversation as resolved.
Show resolved Hide resolved

@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
Loading