diff --git a/CHANGES.md b/CHANGES.md index 664c1bb28..01d23d7bb 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,19 @@ ## Changes in 1.8.3 (in development) +### Enhancements + +* xcube Server now can be configured to provide abstracts/descriptions for datasets + so they can be rendered as markdown in xcube Viewer + (https://github.com/xcube-dev/xcube-viewer/issues/454). (#1122) + + 1. New `description` properties have been added to responses from xcube Server for + datasets and variables. + 2. User can now provide abstracts or descriptions using markdown format for dataset + configurations in xcube Server. A new configuration setting `Description` + now accompanies settings such as `Title`. + 3. Default values for the `Description` setting are derived from metadata of + datasets and variable CF attributes. + ### Other changes * Reformatted code base according to the default settings used by diff --git a/examples/serve/panels-demo/config.yaml b/examples/serve/panels-demo/config.yaml index c07858b67..492b45686 100644 --- a/examples/serve/panels-demo/config.yaml +++ b/examples/serve/panels-demo/config.yaml @@ -19,6 +19,22 @@ DataStores: - Path: openSR_nordfriesland_S2L2A_selected_dates_10m-v4.levels Identifier: waddensea Title: Wadden Sea Nordfriesland + Description: >- + The [**Wadden Sea**](https://en.wikipedia.org/wiki/Wadden_Sea) + (Dutch: _Waddenzee_ [ˈʋɑdə(n)zeː]; + German: _Wattenmeer_ [ˈvatn̩ˌmeːɐ̯]; + Low German: _Wattensee_ or _Waddenzee_; + Danish: _Vadehavet_; + West Frisian: _Waadsee_; + North Frisian: _di Heef_) + is an [intertidal zone](https://en.wikipedia.org/wiki/Intertidal_zone) + in the southeastern part of the + [North Sea](https://en.wikipedia.org/wiki/North_Sea). + It lies between the coast of northwestern continental Europe and the + range of low-lying Frisian Islands, forming a shallow body of water + with tidal flats and wetlands. It has a high biological diversity + and is an important area for both breeding and migrating birds. + ![Wadden Sea](https://upload.wikimedia.org/wikipedia/commons/e/e7/13-09-29-nordfriesisches-wattenmeer-RalfR-19.jpg) Style: waddensea Augmentation: Path: compute_indexes.py diff --git a/test/webapi/datasets/test_context.py b/test/webapi/datasets/test_context.py index b3c89ba68..5e11b0e4f 100644 --- a/test/webapi/datasets/test_context.py +++ b/test/webapi/datasets/test_context.py @@ -107,7 +107,7 @@ def test_get_dataset_configs_from_stores(self): def test_get_dataset_configs_with_duplicate_ids_from_stores(self): with self.assertRaises(ApiError.InvalidServerConfig) as sce: - ctx = get_datasets_ctx("config-datastores-double-ids.yml") + get_datasets_ctx("config-datastores-double-ids.yml") self.assertEqual( "HTTP status 580:" " User-defined identifiers can only be assigned to " diff --git a/test/webapi/datasets/test_controllers.py b/test/webapi/datasets/test_controllers.py index 31dc2cc62..0b984f133 100644 --- a/test/webapi/datasets/test_controllers.py +++ b/test/webapi/datasets/test_controllers.py @@ -2,12 +2,13 @@ # Permissions are hereby granted under the terms of the MIT License: # https://opensource.org/licenses/MIT. - import os.path import unittest from test.webapi.helpers import get_api_ctx from typing import Any, Optional +import xarray as xr + from xcube.core.new import new_cube from xcube.server.api import ApiError from xcube.webapi.datasets.context import DatasetsContext @@ -16,9 +17,11 @@ find_dataset_places, get_color_bars, get_dataset, + get_dataset_title_and_description, get_datasets, get_legend, get_time_chunk_size, + get_variable_title_and_description, ) @@ -193,6 +196,91 @@ def assertPlaceGroupOk(self, feature_collection, expected_count, expected_ids): actual_ids = {f["id"] for f in features if "id" in f} self.assertEqual(expected_ids, actual_ids) + def test_dataset_title_and_description(self): + dataset = xr.Dataset( + attrs={ + "title": "From title Attr", + "name": "From name Attr", + "description": "From description Attr", + "abstract": "From abstract Attr", + "comment": "From comment Attr", + } + ) + + self.assertEqual( + ("From Title Conf", "From Description Conf"), + get_dataset_title_and_description( + dataset, + {"Title": "From Title Conf", "Description": "From Description Conf"}, + ), + ) + + self.assertEqual( + ("From title Attr", "From description Attr"), + get_dataset_title_and_description(dataset), + ) + + del dataset.attrs["title"] + del dataset.attrs["description"] + self.assertEqual( + ("From name Attr", "From abstract Attr"), + get_dataset_title_and_description(dataset), + ) + + del dataset.attrs["name"] + del dataset.attrs["abstract"] + self.assertEqual( + ("", "From comment Attr"), + get_dataset_title_and_description(dataset), + ) + + del dataset.attrs["comment"] + self.assertEqual( + ("", None), + get_dataset_title_and_description(dataset), + ) + + self.assertEqual( + ("From Identifier Conf", None), + get_dataset_title_and_description( + xr.Dataset(), {"Identifier": "From Identifier Conf"} + ), + ) + + def test_variable_title_and_description(self): + variable = xr.DataArray( + attrs={ + "title": "From title Attr", + "name": "From name Attr", + "long_name": "From long_name Attr", + "description": "From description Attr", + "abstract": "From abstract Attr", + "comment": "From comment Attr", + } + ) + self.assertEqual( + ("From title Attr", "From description Attr"), + get_variable_title_and_description("x", variable), + ) + + del variable.attrs["title"] + del variable.attrs["description"] + self.assertEqual( + ("From name Attr", "From abstract Attr"), + get_variable_title_and_description("x", variable), + ) + + del variable.attrs["name"] + del variable.attrs["abstract"] + self.assertEqual( + ("From long_name Attr", "From comment Attr"), + get_variable_title_and_description("x", variable), + ) + + del variable.attrs["long_name"] + del variable.attrs["comment"] + self.assertEqual(("x", None), get_variable_title_and_description("x", variable)) + class DatasetsAuthControllerTest(DatasetsControllerTestBase): @staticmethod @@ -346,7 +434,6 @@ def test_authorized_access_with_joker_scopes(self): granted_scopes = {"read:dataset:*", "read:variable:*"} response = get_datasets(ctx, granted_scopes=granted_scopes) datasets = self.assertDatasetsOk(response) - dataset_ids_dict = {ds["id"]: ds for ds in datasets} self.assertEqual( { "local_base_1w", @@ -354,17 +441,7 @@ def test_authorized_access_with_joker_scopes(self): "remote_base_1w", "remote~OLCI-SNS-RAW-CUBE-2.zarr", }, - set(dataset_ids_dict), - ) - dataset_titles_dict = {ds["title"]: ds for ds in datasets} - self.assertEqual( - { - "local_base_1w", - "A local base dataset", - "remote_base_1w", - "A remote base dataset", - }, - set(dataset_titles_dict), + {ds["id"] for ds in datasets}, ) def test_authorized_access_with_specific_scopes(self): @@ -372,7 +449,6 @@ def test_authorized_access_with_specific_scopes(self): granted_scopes = {"read:dataset:remote*", "read:variable:*"} response = get_datasets(ctx, granted_scopes=granted_scopes) datasets = self.assertDatasetsOk(response) - dataset_ids_dict = {ds["id"]: ds for ds in datasets} self.assertEqual( { # Not selected, because they are substitutes @@ -381,18 +457,7 @@ def test_authorized_access_with_specific_scopes(self): "remote_base_1w", "remote~OLCI-SNS-RAW-CUBE-2.zarr", }, - set(dataset_ids_dict), - ) - dataset_titles_dict = {ds["title"]: ds for ds in datasets} - self.assertEqual( - { - # Not selected, because they are substitutes - # 'local_base_1w', - # 'A local base dataset', - "remote_base_1w", - "A remote base dataset", - }, - set(dataset_titles_dict), + {ds["id"] for ds in datasets}, ) diff --git a/test/webapi/ows/stac/test_controllers.py b/test/webapi/ows/stac/test_controllers.py index 283ea9445..d5062d382 100644 --- a/test/webapi/ows/stac/test_controllers.py +++ b/test/webapi/ows/stac/test_controllers.py @@ -180,6 +180,9 @@ def read_json(self, filename): content = json.load(fp) return content + def setUp(self): + self.maxDiff = None + # Commented out to keep coverage checkers happy. # @staticmethod # def write_json(filename, content): diff --git a/test/webapi/places/test_routes.py b/test/webapi/places/test_routes.py index eabcf3454..dcef99056 100644 --- a/test/webapi/places/test_routes.py +++ b/test/webapi/places/test_routes.py @@ -24,6 +24,7 @@ def test_places(self): "sourceEncoding": "utf-8", "sourcePaths": [], "title": "Points inside the cube", + "description": None, "type": "FeatureCollection", }, { @@ -34,6 +35,7 @@ def test_places(self): "sourceEncoding": "utf-8", "sourcePaths": [], "title": "Points outside the cube", + "description": None, "type": "FeatureCollection", }, ] diff --git a/test/webapi/viewer/test_viewer.py b/test/webapi/viewer/test_viewer.py index b96b89c78..ea8defd76 100644 --- a/test/webapi/viewer/test_viewer.py +++ b/test/webapi/viewer/test_viewer.py @@ -193,14 +193,21 @@ def test_add_and_remove_dataset(self): new_cube(variables={"analysed_sst": 282.0}), ds_id="my_sst_2", title="My SST 2", + description="It is test 2", ) self.assertEqual("my_sst_2", ds_id_2) ds_config_1 = self.viewer.datasets_ctx.get_dataset_config(ds_id_1) - self.assertEqual({"Identifier": ds_id_1, "Title": "My SST 1"}, ds_config_1) + self.assertEqual( + {"Identifier": ds_id_1}, + ds_config_1, + ) ds_config_2 = self.viewer.datasets_ctx.get_dataset_config(ds_id_2) - self.assertEqual({"Identifier": ds_id_2, "Title": "My SST 2"}, ds_config_2) + self.assertEqual( + {"Identifier": ds_id_2, "Title": "My SST 2", "Description": "It is test 2"}, + ds_config_2, + ) self.viewer.remove_dataset(ds_id_1) with pytest.raises(ApiError.NotFound): @@ -218,23 +225,34 @@ def test_add_dataset_with_slash_path(self): new_cube(variables={"analysed_sst": 280.0}), ds_id="mybucket/mysst.levels", ) - ds_id = viewer.add_dataset(ml_ds, title="My SST") + ds_id = viewer.add_dataset(ml_ds, title="My SST", description="Test!") self.assertEqual("mybucket-mysst.levels", ds_id) ds_config = self.viewer.datasets_ctx.get_dataset_config(ds_id) - self.assertEqual({"Identifier": ds_id, "Title": "My SST"}, ds_config) + self.assertEqual( + {"Identifier": ds_id, "Title": "My SST", "Description": "Test!"}, ds_config + ) def test_add_dataset_with_style(self): viewer = self.get_viewer(STYLES_CONFIG) ds_id = viewer.add_dataset( - new_cube(variables={"analysed_sst": 280.0}), title="My SST", style="SST" + new_cube(variables={"analysed_sst": 280.0}), + title="My SST", + description="Better use SST", + style="SST", ) ds_config = self.viewer.datasets_ctx.get_dataset_config(ds_id) self.assertEqual( - {"Identifier": ds_id, "Title": "My SST", "Style": "SST"}, ds_config + { + "Identifier": ds_id, + "Title": "My SST", + "Description": "Better use SST", + "Style": "SST", + }, + ds_config, ) def test_add_dataset_with_color_mapping(self): @@ -250,5 +268,10 @@ def test_add_dataset_with_color_mapping(self): ds_config = self.viewer.datasets_ctx.get_dataset_config(ds_id) self.assertEqual( - {"Identifier": ds_id, "Title": "My SST", "Style": ds_id}, ds_config + { + "Identifier": ds_id, + "Title": "My SST", + "Style": ds_id, + }, + ds_config, ) diff --git a/xcube/webapi/datasets/config.py b/xcube/webapi/datasets/config.py index c16b6a124..31892067f 100644 --- a/xcube/webapi/datasets/config.py +++ b/xcube/webapi/datasets/config.py @@ -64,6 +64,7 @@ COMMON_DATASET_PROPERTIES = dict( Title=STRING_SCHEMA, + Description=STRING_SCHEMA, GroupTitle=STRING_SCHEMA, Tags=JsonArraySchema(items=STRING_SCHEMA), Variables=VARIABLES_SCHEMA, diff --git a/xcube/webapi/datasets/context.py b/xcube/webapi/datasets/context.py index ccec4d786..e3fb7d87f 100644 --- a/xcube/webapi/datasets/context.py +++ b/xcube/webapi/datasets/context.py @@ -170,7 +170,7 @@ def get_ml_dataset(self, ds_id: str) -> MultiLevelDataset: def set_ml_dataset(self, ml_dataset: MultiLevelDataset): self._set_dataset_entry( - (ml_dataset, dict(Identifier=ml_dataset.ds_id, Hidden=True)) + (ml_dataset, _new_dataset_config(Identifier=ml_dataset.ds_id, Hidden=True)) ) def add_dataset( @@ -178,6 +178,7 @@ def add_dataset( dataset: Union[xr.Dataset, MultiLevelDataset], ds_id: Optional[str] = None, title: Optional[str] = None, + description: Optional[str] = None, style: Optional[str] = None, color_mappings: dict[str, dict[str, Any]] = None, ) -> str: @@ -203,8 +204,8 @@ def add_dataset( if dataset_config["Identifier"] == ds_id: del dataset_configs[index] break - dataset_config = dict( - Identifier=ds_id, Title=title or dataset.attrs.get("title", ds_id) + dataset_config = _new_dataset_config( + Identifier=ds_id, Title=title, Description=description ) if style is not None: dataset_config.update(dict(Style=style)) @@ -230,12 +231,24 @@ def add_ml_dataset( ml_dataset: MultiLevelDataset, ds_id: Optional[str] = None, title: Optional[str] = None, + description: Optional[str] = None, ): if ds_id: ml_dataset.ds_id = ds_id else: ds_id = ml_dataset.ds_id - self._set_dataset_entry((ml_dataset, dict(Identifier=ds_id, Title=title))) + self._set_dataset_entry( + ( + ml_dataset, + ( + dict( + Identifier=ds_id, + Title=title, + Description=description, + ) + ), + ) + ) def get_dataset( self, ds_id: str, expected_var_names: Collection[str] = None @@ -386,7 +399,7 @@ def get_dataset_configs_from_stores( if store_dataset_configs: for store_dataset_config in store_dataset_configs: dataset_id_pattern = store_dataset_config.get("Path", "*") - if _is_wildard(dataset_id_pattern): + if _is_wildcard(dataset_id_pattern): if store_instance_id in DATA_STORE_IDS_WARNING: warnings.warn( f"The data store with ID '{store_instance_id}' has " @@ -905,7 +918,7 @@ def _get_common_prefixes(p): prefix = os.path.commonprefix(p) # ensure prefix does not end with full or partial directory # or file name - prefix = prefix[: max(_lastindex(prefix, "/"), _lastindex(prefix, "\\"), 0)] + prefix = prefix[: max(_last_index(prefix, "/"), _last_index(prefix, "\\"), 0)] if _is_not_empty(prefix) or len(p) == 1: return [prefix] else: @@ -932,7 +945,7 @@ def _get_selected_dataset_config( return dataset_config -def _lastindex(prefix, symbol): +def _last_index(prefix, symbol): try: return prefix.rindex(symbol) except ValueError: @@ -944,5 +957,9 @@ def _lastindex(prefix, symbol): } -def _is_wildard(string: str) -> bool: +def _is_wildcard(string: str) -> bool: return "?" in string or "*" in string + + +def _new_dataset_config(**kwargs) -> dict[str, Any]: + return {k: v for k, v in kwargs.items() if v is not None} diff --git a/xcube/webapi/datasets/controllers.py b/xcube/webapi/datasets/controllers.py index eb4a5d206..bf447dd55 100644 --- a/xcube/webapi/datasets/controllers.py +++ b/xcube/webapi/datasets/controllers.py @@ -1,12 +1,13 @@ # Copyright (c) 2018-2025 by xcube team and contributors # Permissions are hereby granted under the terms of the MIT License: # https://opensource.org/licenses/MIT. + import fnmatch import functools import io import json -from collections.abc import Mapping -from typing import Any, Callable, Dict, List, Optional, Set, Tuple +from collections.abc import Mapping, Sequence +from typing import Any, Callable, Optional import matplotlib.colorbar import matplotlib.colors @@ -32,6 +33,12 @@ ) from .context import DatasetConfig, DatasetsContext +DS_TITLE_ATTR_NAMES = ("title", "name") +DS_DESCRIPTION_ATTR_NAMES = ("description", "abstract", "comment") + +VAR_TITLE_ATTR_NAMES = ("title", "name", "long_name") +VAR_DESCRIPTION_ATTR_NAMES = ("description", "abstract", "comment") + def find_dataset_places( ctx: DatasetsContext, @@ -86,12 +93,9 @@ def get_datasets( LOG.info(f"Rejected dataset {ds_id!r} due to missing permission") continue + ds = ctx.get_dataset(ds_id) dataset_dict = dict(id=ds_id) - - _update_dataset_title_properties(dataset_config, dataset_dict) - if not details and "title" not in dataset_dict: - # "title" property should always be set - dataset_dict["title"] = ds_id + _update_dataset_desc_properties(ds, dataset_config, dataset_dict) ds_bbox = dataset_config.get("BoundingBox") if ds_bbox is not None: @@ -111,7 +115,6 @@ def get_datasets( ds_id = dataset_dict["id"] try: if point: - ds = ctx.get_dataset(ds_id) if "bbox" not in dataset_dict: dataset_dict["bbox"] = list(get_dataset_bounds(ds)) if details: @@ -162,6 +165,8 @@ def get_dataset( raise DatasetIsNotACubeError(f"could not open dataset: {e}") from e ds = ml_ds.get_dataset(0) + dataset_dict = dict(id=ds_id) + _update_dataset_desc_properties(ds, dataset_config, dataset_dict) try: ts_ds = ctx.get_time_series_dataset(ds_id) @@ -170,15 +175,6 @@ def get_dataset( x_name, y_name = ml_ds.grid_mapping.xy_dim_names - dataset_dict = dict(id=ds_id) - - _update_dataset_title_properties(dataset_config, dataset_dict) - if "title" not in dataset_dict: - title = ds.attrs.get("title", ds.attrs.get("name")) - if not isinstance(title, str) or not title: - title = ds_id - dataset_dict["title"] = title - crs = ml_ds.grid_mapping.crs transformer = pyproj.Transformer.from_crs(crs, CRS_CRS84, always_xy=True) dataset_bounds = get_dataset_bounds(ds) @@ -220,9 +216,9 @@ def get_dataset( spatial_var_names = filter_variable_names(spatial_var_names, var_name_patterns) if not spatial_var_names: LOG.warning( - f"No variable matched any of the patterns given" - f' in the "Variables" filter.' - f' You may specify a wildcard "*" as last item.' + "No variable matched any of the patterns given" + ' in the "Variables" filter.' + ' You may specify a wildcard "*" as last item.' ) for var_name in spatial_var_names: @@ -235,6 +231,7 @@ def get_dataset( ): continue + var_title, var_description = get_variable_title_and_description(var_name, var) variable_dict = dict( id=f"{ds_id}.{var_name}", name=var_name, @@ -242,9 +239,11 @@ def get_dataset( shape=list(var.shape), dtype=str(var.dtype), units=var.attrs.get("units", ""), - title=var.attrs.get("title", var.attrs.get("long_name", var_name)), + title=var_title, timeChunkSize=get_time_chunk_size(ts_ds, var_name, ds_id), ) + if var_description: + variable_dict["description"] = var_description tile_url = _get_dataset_tile_url2(ctx, ds_id, var_name, base_url) # Note that tileUrl is no longer used since xcube viewer v0.13 @@ -323,14 +322,49 @@ def get_dataset( return dataset_dict -def _update_dataset_title_properties( - dataset_config: Mapping[str, Any], dataset_dict: dict[str, Any] +def get_dataset_title_and_description( + dataset: xr.Dataset, + dataset_config: Mapping[str, Any] | None = None, +) -> tuple[str, str | None]: + dataset_config = dataset_config or {} + ds_title = dataset_config.get( + "Title", + _get_str_attr( + dataset.attrs, + DS_TITLE_ATTR_NAMES, + dataset_config.get("Identifier"), + ), + ) + ds_description = dataset_config.get( + "Description", + _get_str_attr(dataset.attrs, DS_DESCRIPTION_ATTR_NAMES), + ) + return ds_title or "", ds_description or None + + +def get_variable_title_and_description( + var_name: str, + var: xr.DataArray, +) -> tuple[str, str | None]: + var_title = _get_str_attr(var.attrs, VAR_TITLE_ATTR_NAMES, var_name) + var_description = _get_str_attr(var.attrs, VAR_DESCRIPTION_ATTR_NAMES) + return var_title or "", var_description or None + + +def _update_dataset_desc_properties( + ds: xr.Dataset, dataset_config: Mapping[str, Any], dataset_dict: dict[str, Any] ): - for dc_key in ("Title", "GroupTitle", "Tags"): - dd_key = dc_key[0].lower() + dc_key[1:] - if dc_key in dataset_config: - # Note, dataset_config is validated - dataset_dict[dd_key] = dataset_config[dc_key] + ds_title, ds_description = get_dataset_title_and_description(ds, dataset_config) + group_title = dataset_config.get("GroupTitle") + tags = dataset_config.get("Tags") + + dataset_dict["title"] = ds_title + if ds_description: + dataset_dict["description"] = ds_description + if group_title: + dataset_dict["groupTitle"] = group_title + if tags: + dataset_dict["tags"] = tags def filter_variable_names( @@ -635,3 +669,13 @@ def get_legend( fig.savefig(buffer, format="png") return buffer.getvalue() + + +def _get_str_attr( + attrs: Mapping[str, Any], keys: Sequence[str], default: Optional[str] = None +) -> Optional[str]: + for k in keys: + v = attrs.get(k) + if isinstance(v, str) and v.strip(): + return v + return default if isinstance(default, str) else None diff --git a/xcube/webapi/ows/wmts/controllers.py b/xcube/webapi/ows/wmts/controllers.py index ce1635132..b56e20f4f 100644 --- a/xcube/webapi/ows/wmts/controllers.py +++ b/xcube/webapi/ows/wmts/controllers.py @@ -5,7 +5,7 @@ import urllib.parse import warnings from collections.abc import Mapping -from typing import Any, Dict, List, Tuple +from typing import Any import numpy as np import pyproj @@ -21,6 +21,10 @@ TilingScheme, ) from xcube.webapi.common.xml import Document, Element +from xcube.webapi.datasets.controllers import ( + get_dataset_title_and_description, + get_variable_title_and_description, +) from .context import WmtsContext @@ -154,9 +158,11 @@ def get_capabilities_element(ctx: WmtsContext, base_url: str, tms_id: str) -> El "xmlns:ows": "http://www.opengis.net/ows/1.1", "xmlns:xlink": "http://www.w3.org/1999/xlink", "xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", - "xsi:schemaLocation": "http://www.opengis.net/wmts/1.0" - " http://schemas.opengis.net/wmts/1.0.0/" - "wmtsGetCapabilities_response.xsd", + "xsi:schemaLocation": ( + "http://www.opengis.net/wmts/1.0" + " http://schemas.opengis.net/wmts/1.0.0/" + "wmtsGetCapabilities_response.xsd" + ), "version": WMTS_VERSION, }, elements=[ @@ -217,8 +223,8 @@ def get_dim_elements( dim_element = Element( "Dimension", elements=[ - Element("ows:Identifier", text=f"{dim_name}"), - Element("ows:Title", text=dim_title), + Element("ows:Identifier", text=str(dim_name)), + Element("ows:Title", text=str(dim_title)), Element("ows:UOM", text=units), Element("Default", text=default), Element("Current", text=current), @@ -246,16 +252,13 @@ def get_dim_elements( def get_ds_theme_element( ds_name: str, ds: xr.Dataset, dataset_config: Mapping[str, Any] ) -> Element: - ds_title = dataset_config.get("Title", ds.attrs.get("title", ds_name)) - ds_abstract = dataset_config.get( - "Abstract", ds.attrs.get("abstract", ds.attrs.get("comment", "")) - ) + ds_title, ds_abstract = get_dataset_title_and_description(ds, dataset_config) return Element( "Theme", elements=[ Element("ows:Identifier", text=ds_name), Element("ows:Title", text=ds_title), - Element("ows:Abstract", text=ds_abstract), + Element("ows:Abstract", text=ds_abstract or ""), ], ) @@ -268,17 +271,15 @@ def get_var_layer_and_theme_element( var_tile_url_templ_pattern: str, tms_id: str, ) -> tuple[Element, Element]: + var_title, var_abstract = get_variable_title_and_description(var_name, var) var_id = f"{ds_name}.{var_name}" - var_title = ( - ds_name + "/" + var.attrs.get("title", var.attrs.get("long_name", var_name)) - ) - var_abstract = var.attrs.get("comment", var.attrs.get("abstract", "")) + var_title = f"{ds_name}/{var_title}" var_theme_element = Element( "Theme", elements=[ Element("ows:Identifier", text=var_id), Element("ows:Title", text=var_title), - Element("ows:Abstract", text=var_abstract), + Element("ows:Abstract", text=var_abstract or ""), Element("LayerRef", text=var_id), ], ) @@ -290,9 +291,9 @@ def get_var_layer_and_theme_element( layer_element = Element( "Layer", elements=[ - Element("ows:Identifier", text=f"{ds_name}.{var_name}"), - Element("ows:Title", text=f"{var_title}"), - Element("ows:Abstract", text=f"{var_abstract}"), + Element("ows:Identifier", text=var_id), + Element("ows:Title", text=var_title), + Element("ows:Abstract", text=var_abstract), Element( "ows:WGS84BoundingBox", elements=[ diff --git a/xcube/webapi/places/context.py b/xcube/webapi/places/context.py index ff731f015..41e9c61d5 100644 --- a/xcube/webapi/places/context.py +++ b/xcube/webapi/places/context.py @@ -3,13 +3,14 @@ # https://opensource.org/licenses/MIT. from collections.abc import Iterator, Sequence -from typing import Any, Callable, Dict, List, Optional +from typing import Any, Callable, Optional import fiona +import fiona.crs import fsspec import pyproj import shapely -from fiona.collection import Collection +import shapely.ops from xcube.server.api import ApiError, Context from xcube.webapi.common.context import ResourcesContext @@ -84,7 +85,7 @@ def load_place_groups( base_url: str, is_global: bool = False, load_features: bool = False, - qualifiers: list[str] = list(), + qualifiers: Optional[list[str]] = None, ) -> list[PlaceGroup]: place_groups = [] for place_group_config in place_group_configs: @@ -97,15 +98,17 @@ def load_place_groups( place_groups.append(place_group) for q in [ qualifier - for qualifier in qualifiers + for qualifier in (qualifiers or ()) if qualifier in self._additional_place_groups ]: for place_group in self._additional_place_groups[q]: place_groups.append(place_group) return place_groups - def add_place_group(self, place_group: PlaceGroup, qualifiers: list[str] = list()): - for qualifier in qualifiers: + def add_place_group( + self, place_group: PlaceGroup, qualifiers: Optional[list[str]] = None + ): + for qualifier in qualifiers or (): if qualifier not in self._additional_place_groups: self._additional_place_groups[qualifier] = [] self._additional_place_groups[qualifier].append(place_group) @@ -137,8 +140,9 @@ def _load_place_group( place_group = self.get_cached_place_group(place_group_id) if place_group is None: place_group_title = place_group_config.get("Title", place_group_id) + place_group_description = place_group_config.get("Description") place_path_wc = self.get_config_path( - place_group_config, f"'PlaceGroups' item" + place_group_config, "'PlaceGroups' item" ) fs, place_path = fsspec.core.url_to_fs(place_path_wc) source_paths = [fs.unstrip_protocol(p) for p in fs.glob(place_path)] @@ -167,6 +171,7 @@ def _load_place_group( features=None, id=place_group_id, title=place_group_title, + description=place_group_description, propertyMapping=property_mapping, sourcePaths=source_paths, sourceEncoding=source_encoding, @@ -252,10 +257,11 @@ def load_place_group_features( @classmethod def _to_geo_interface( - cls, feature_collection: Collection + cls, feature_collection: fiona.Collection ) -> Iterator[dict[str, Any]]: source_crs = feature_collection.crs target_crs = fiona.crs.CRS.from_epsg(4326) + project: Callable | None = None if not source_crs == target_crs: project = pyproj.Transformer.from_crs( source_crs, target_crs, always_xy=True @@ -275,7 +281,7 @@ def _to_geo_interface( and geometry.get("type") != "GeometryCollection" ): del geometry["geometries"] - if not source_crs == target_crs: + if project is not None: geometry = feature.get("geometry") shapely_geom = shapely.geometry.shape(geometry) feature["geometry"] = shapely.ops.transform( diff --git a/xcube/webapi/viewer/viewer.py b/xcube/webapi/viewer/viewer.py index 1ccfab352..460c39ca3 100644 --- a/xcube/webapi/viewer/viewer.py +++ b/xcube/webapi/viewer/viewer.py @@ -140,6 +140,7 @@ def add_dataset( dataset: Union[xr.Dataset, MultiLevelDataset], ds_id: Optional[str] = None, title: Optional[str] = None, + description: Optional[str] = None, style: Optional[str] = None, color_mappings: dict[str, dict[str, Any]] = None, ): @@ -153,6 +154,8 @@ def add_dataset( identifier will be generated and returned. title: Optional dataset title. Overrides a title given by dataset metadata. + description: Optional dataset description. Overrides a description given by + dataset metadata. style: Optional name of a style that must exist in the server configuration. color_mappings: Maps a variable name to a specific color @@ -169,6 +172,7 @@ def add_dataset( dataset, ds_id=ds_id, title=title, + description=description, style=style, color_mappings=color_mappings, )