Skip to content

Commit

Permalink
Merge branch 'refs/heads/main' into forman-x-ease_impl_of_server_side…
Browse files Browse the repository at this point in the history
…_viewer_panels
  • Loading branch information
forman committed Nov 20, 2024
2 parents 0664343 + 133cbb1 commit 800127e
Show file tree
Hide file tree
Showing 10 changed files with 550 additions and 338 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
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.
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
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)

@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

0 comments on commit 800127e

Please sign in to comment.