Skip to content

Commit

Permalink
added parameter schema to operations response
Browse files Browse the repository at this point in the history
  • Loading branch information
forman committed Jul 12, 2023
1 parent a0c7e0a commit ba3bc3a
Show file tree
Hide file tree
Showing 6 changed files with 248 additions and 33 deletions.
59 changes: 51 additions & 8 deletions test/webapi/compute/test_controllers.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,19 +21,62 @@


import unittest
from typing import List

from xcube.webapi.compute.controllers import get_compute_operations
from xcube.webapi.compute.op import get_operations
from .test_context import get_compute_ctx


class ComputeControllersTest(unittest.TestCase):

def test_operations_registered(self):
ops = get_operations()
self.assertIn("spatial_subset", ops)
self.assertTrue(callable(ops["spatial_subset"]))

def test_get_compute_operations(self):
result = get_compute_operations(get_compute_ctx())
self.assertEqual({"operations": []}, result)
self.assertIsInstance(result, dict)
self.assertIn("operations", result)
self.assertIsInstance(result["operations"], list)
self.assertTrue(len(result["operations"]) > 0)

def test_spatial_subset_operation_description(self):
result = get_compute_operations(get_compute_ctx())
operations: List = result["operations"]

operations_map = {op.get('operationId'): op for op in operations}
self.assertIn('spatial_subset', operations_map)

op = operations_map['spatial_subset']
self.assertIsInstance(op, dict)

self.assertEqual('spatial_subset', op.get('operationId'))
self.assertEqual('Create a spatial subset'
' from given dataset.',
op.get('description'))
self.assertIn("parametersSchema", op)

schema = op.get("parametersSchema")
self.assertIsInstance(schema, dict)

self.assertEqual('object',
schema.get('type'))
self.assertEqual(False,
schema.get('additionalProperties'))
self.assertEqual({'dataset', 'bbox'},
set(schema.get('required', [])))
self.assertEqual(
{
'dataset': {
'type': 'string',
'title': 'Dataset identifier',
},
'bbox': {
'type': 'array',
'items': [{'type': 'number'},
{'type': 'number'},
{'type': 'number'},
{'type': 'number'}],
'title': 'Bounding box',
'description': 'Bounding box using the '
'dataset\'s CRS '
'coordinates',
},
},
schema.get('properties')
)
5 changes: 5 additions & 0 deletions test/webapi/compute/test_operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,15 @@
import xarray as xr

from xcube.core.new import new_cube
from xcube.webapi.compute.op import get_operations
from xcube.webapi.compute.operations import spatial_subset


class ComputeOperationsTest(TestCase):
def test_operations_registered(self):
ops = get_operations()
self.assertIn("spatial_subset", ops)
self.assertTrue(callable(ops["spatial_subset"]))

def test_spatial_subset(self):
dataset = new_cube()
Expand Down
4 changes: 3 additions & 1 deletion test/webapi/compute/test_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,5 +26,7 @@ class ComputeRoutesTest(RoutesTestCase):

def test_fetch_compute_operations(self):
result, status = self.fetch_json('/compute/operations')
self.assertEqual({"operations": []}, result)
self.assertIsInstance(result, dict)
self.assertIsInstance(result.get("operations"), list)
self.assertTrue(len(result.get("operations")) > 0)
self.assertEqual(200, status)
27 changes: 12 additions & 15 deletions xcube/webapi/compute/controllers.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,30 +18,27 @@
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
# DEALINGS IN THE SOFTWARE.

import inspect
from typing import Callable, Any, Dict, List

from .context import ComputeContext
from .op import get_operations
from .op import get_operations, get_op_params_schema


def get_compute_operations(ctx: ComputeContext):
ops = get_operations()
return {"operations": [encode_op(k, v) for k, v in ops.items()]}


def encode_op(op_id: str, op: Callable) -> Dict[str, Any]:
members = dict(inspect.getmembers(op))
print(members)
return {
"operationId": op_id,
"parameters": []
"operations": [encode_op(op_id, f) for op_id, f in ops.items()]
}


def encode_op_params(op_id: str, op: Callable) -> List[Dict[str, Any]]:
return []


def encode_op_param(param_name: str, op: Callable) -> List[Dict[str, Any]]:
return {}
def encode_op(op_id: str, f: Callable) -> Dict[str, Any]:
op_json = {
"operationId": op_id,
"parametersSchema": get_op_params_schema(f)
}
doc = inspect.getdoc(f)
if doc:
op_json.update(description=doc)
return op_json
171 changes: 164 additions & 7 deletions xcube/webapi/compute/op.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,174 @@
from typing import Callable, Dict
import inspect
import warnings
from typing import Callable, Dict, Optional, Union, Any

import xarray as xr

from xcube.util.assertions import assert_instance
from xcube.util.jsonschema import JsonSchema

from xcube.util.jsonschema import JsonObjectSchema

_OP_REGISTRY: Dict[str, Callable] = {}
_OP_PARAMS_SCHEMA_ATTR_NAME = '_params_schema'

_PRIMITIVE_PY_TO_JSON_TYPES = {
type(None): "null",
bool: "boolean",
int: "integer",
float: "number",
str: "string",
}


def register_op(f: Callable):
f_name = f.__name__
prev_f = _OP_REGISTRY.get(f_name)
if prev_f is None:
_OP_REGISTRY[f.__name__] = f
set_op_params_schema(f, compute_params_schema(f))
elif prev_f is not f:
warnings.warn(f'redefining already registered operation {f_name!r}')


def compute_params_schema(f: Callable):
members = dict(inspect.getmembers(f))
annotations = members.get("__annotations__")
code = members.get("__code__")
params_schema = {}
if code:
args = inspect.getargs(code)
required_param_names = set(args.args or [])\
.union(set(args.varargs or []))
optional_param_names = set(args.varkw or [])
all_param_names = required_param_names.union(optional_param_names)
if all_param_names:
properties = {}
for param_name in all_param_names:
py_type = annotations.get(param_name)
# print(param_name, "-------------->",
# py_type, type(py_type), flush=True)
if py_type is xr.Dataset:
param_schema = {
"type": "string",
"title": "Dataset identifier"
}
elif py_type is not None:
json_type = _PRIMITIVE_PY_TO_JSON_TYPES.get(py_type)
if json_type is None:
# TODO: decode json_type
json_type = repr(py_type)
param_schema = {
"type": json_type,
}
else:
param_schema = {}
properties[param_name] = param_schema
params_schema = {
"type": "object",
"properties": properties,
"required": list(required_param_names),
"additionalProperties": False
}
else:
params_schema.update({
"type": ["null", "object"],
"additionalProperties": False
})

return params_schema


def get_op_params_schema(f: Callable) -> Dict[str, any]:
return getattr(f, _OP_PARAMS_SCHEMA_ATTR_NAME, {}).copy()


def set_op_params_schema(
f: Callable,
params_schema: Union[JsonObjectSchema, Dict[str, any]]
):
assert_instance(params_schema, (JsonObjectSchema, dict),
name='params_schema')
if isinstance(params_schema, JsonObjectSchema):
params_schema = params_schema.to_dict()
setattr(f, _OP_PARAMS_SCHEMA_ATTR_NAME, params_schema)


def get_operations() -> Dict[str, Callable]:
return _OP_REGISTRY.copy()


def op(op_id: str = None):
def decorator(func):
reg_op_id = op_id or func.__name__
print(f"registered {reg_op_id}: {func}")
_OP_REGISTRY[reg_op_id] = func
return func
def assert_decorator_target_ok(decorator_name: str, target: Any):
if not callable(target):
raise TypeError(f"decorator {decorator_name!r}"
f" can be used with callables only")


def op(_func: Optional[Callable] = None,
title: Optional[str] = None,
description: Optional[str] = None,
params_schema: Optional[JsonObjectSchema] = None):
def decorator(f: Callable):
assert_decorator_target_ok("op", f)
register_op(f)
if params_schema is not None:
set_op_params_schema(f, params_schema)
return f

if _func is None:
return decorator
else:
return decorator(_func)


# See also
# https://json-schema.org/draft/2020-12/json-schema-validation.html#name-a-vocabulary-for-basic-meta

def op_param(name: str,
title: Optional[str] = None,
description: Optional[str] = None,
default: Optional[Any] = None,
required: Optional[bool] = None,
schema: Optional[JsonSchema] = None):
"""Decorator that adds schema information for the parameter
given by *name*.
See also
https://json-schema.org/draft/2020-12/json-schema-validation.html#name-a-vocabulary-for-basic-meta
"""
def decorator(f: Callable):
assert_decorator_target_ok("op", f)
register_op(f)
if schema is not None:
_update_param_schema(f, name, schema.to_dict())
if title is not None:
_update_param_schema(f, name, {"title": title})
if description is not None:
_update_param_schema(f, name, {"description": description})
if default is not None:
_update_param_schema(f, name, {"default": default})
if required is not None:
params_schema = get_op_params_schema(f)
required_set = set(params_schema.get("required", []))
if required and name not in required_set:
required_set.add(name)
elif not required and name in required_set:
required_set.remove(name)
params_schema["required"] = list(required_set)
set_op_params_schema(f, params_schema)

return f

return decorator


def _update_param_schema(f: Callable, name: str, value: Dict[str, Any]):
params_schema = get_op_params_schema(f)
properties = params_schema.get("properties", {}).copy()
param_schema = properties.get(name, {}).copy()
param_schema.update(value)
properties[name] = param_schema
params_schema["properties"] = properties
set_op_params_schema(f, params_schema)


15 changes: 13 additions & 2 deletions xcube/webapi/compute/operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,23 @@
import xarray as xr

from xcube.core.gridmapping import GridMapping
from .op import op
from xcube.util.jsonschema import JsonArraySchema
from xcube.util.jsonschema import JsonNumberSchema

from .op import op, op_param

@op()

@op
@op_param("bbox",
title="Bounding box",
description="Bounding box using the dataset's CRS coordinates",
schema=JsonArraySchema(items=[JsonNumberSchema(),
JsonNumberSchema(),
JsonNumberSchema(),
JsonNumberSchema()]))
def spatial_subset(dataset: xr.Dataset,
bbox: Tuple[float, float, float, float]) -> xr.Dataset:
"""Create a spatial subset from given dataset."""
x1, y1, x2, y2 = bbox
gm = GridMapping.from_dataset(dataset)
x_name, y_name = gm.xy_dim_names
Expand Down

0 comments on commit ba3bc3a

Please sign in to comment.