Skip to content

Commit

Permalink
[wip] handle recursive deserialize for nested process
Browse files Browse the repository at this point in the history
  • Loading branch information
fmigneault-crim committed Nov 5, 2024
1 parent db63cf3 commit 1c0148f
Show file tree
Hide file tree
Showing 3 changed files with 118 additions and 32 deletions.
37 changes: 37 additions & 0 deletions tests/wps_restapi/test_swagger_definitions.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import mock
import pytest

from weaver.execute import ExecuteMode
from weaver.formats import EDAM_NAMESPACE, EDAM_NAMESPACE_URL, IANA_NAMESPACE, IANA_NAMESPACE_URL, ContentType
from weaver.processes.constants import (
CWL_NAMESPACE_CWL_SPEC_ID,
Expand Down Expand Up @@ -444,3 +445,39 @@ def test_collection_input_filter_unresolved_error():

def test_collection_input_filter_missing():
assert sd.FilterSchema().deserialize({}) in [colander.drop, {}]


@pytest.mark.parametrize(
["test_value", "expect_result"],
[
(
{
"process": "https://example.com/processes/parent",
"inputs": {
"process": "https://example.com/processes/nested",
"inputs": {
"process": "https://example.com/processes/child",
"inputs": {"value": 123}
}
}
},
{
"$schema": sd.Execute._schema,
"process": "https://example.com/processes/parent",
"inputs": {
"process": "https://example.com/processes/nested",
"inputs": {
"process": "https://example.com/processes/child",
"inputs": {"value": 123},
}
},
"outputs": None,
"mode": ExecuteMode.AUTO,
},
)
]
)
def test_nested_process_input(test_value, expect_result):
schema = sd.Execute()
result = schema.deserialize(test_value)
assert result == expect_result
42 changes: 42 additions & 0 deletions weaver/wps_restapi/colander_extras.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
from typing import TYPE_CHECKING

import colander
from beaker.util import deserialize
from cornice_swagger.converters.exceptions import ConversionError, NoSuchConverter
from cornice_swagger.converters.parameters import (
BodyParameterConverter,
Expand Down Expand Up @@ -1917,6 +1918,36 @@ def _validate_keyword_schemas(self):
for node in children:
ExtendedSchemaBase._validate(node)

def _bind(self, kw):
"""
Applies the bindings to the children nodes.
Based on :meth:`colander._SchemaNode._bind` except that `children` are obtained from the keyword.
"""
self.bindings = kw
children = self.get_keyword_items()
for child in children:
child._bind(kw)
names = dir(self)
for k in names:
v = getattr(self, k)
if isinstance(v, colander.deferred):
v = v(self, kw)
if isinstance(v, colander.SchemaNode):
if not v.name:
v.name = k
if v.raw_title is colander._marker:
v.title = k.replace("_", " ").title()
for idx, node in enumerate(list(children)):
if node.name == v.name:
children[idx] = v
else:
children.append(v)
else:
setattr(self, k, v)
if getattr(self, "after_bind", None):
self.after_bind(self, kw)

@abstractmethod
def _deserialize_keyword(self, cstruct):
"""
Expand Down Expand Up @@ -1956,6 +1987,17 @@ def _deserialize_subnode(self, node, cstruct, index):
node.name = _get_node_name(node, schema_name=True) or str(index)
if isinstance(node, KeywordMapper):
return KeywordMapper.deserialize(node, cstruct)

# call the specific method defined by the schema if overridden
# this is to allow the nested schema under the keyword to apply additional logic
# it is up to that schema to do the 'super().deserialize()' call to run the usual logic
deserialize_override = getattr(type(node), "deserialize", None)
if deserialize_override not in [
ExtendedMappingSchema.deserialize,
ExtendedSequenceSchema.deserialize,
ExtendedSchemaNode.deserialize,
]:
return deserialize_override(node, cstruct)
return ExtendedSchemaNode.deserialize(node, cstruct)

def deserialize(self, cstruct): # pylint: disable=W0222,signature-differs
Expand Down
71 changes: 39 additions & 32 deletions weaver/wps_restapi/swagger_definitions.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,6 @@
ExtendedFloat as Float,
ExtendedInteger as Integer,
ExtendedMappingSchema,
ExtendedObjectTypeConverter,
ExtendedSchemaNode,
ExtendedSequenceSchema,
ExtendedString as String,
Expand All @@ -143,7 +142,7 @@
from weaver.wps_restapi.patches import WeaverService as Service # warning: don't use 'cornice.Service'

if TYPE_CHECKING:
from typing import Any, Dict, Type, Union
from typing import Any, Dict, List, Type, Union
from typing_extensions import TypedDict

from pygeofilter.ast import AstType as FilterAstType
Expand Down Expand Up @@ -3936,52 +3935,60 @@ class ExecuteCollectionInput(FilterSchema, SortBySchema, PermissiveMappingSchema
)


class ExecuteNestedProcessInput(ExtendedMappingSchema):
class ExecuteNestedProcessReference(ExtendedMappingSchema):
# 'process' is required for a nested definition, otherwise it will not even be detected as one!
process = ProcessURL(description="Process reference to be executed.")


class ExecuteNestedProcessParameters(ExtendedMappingSchema):
"""
Dynamically defines the nested process input.
Dynamically defines the nested process parameters with recursive schema handling.
This class must create the nested properties dynamically because the required classes are not yet defined, and
those required definitions also depend on this class to define the nested process as a possible input value.
.. note::
This class acts as a :class:`colander.SchemaNode` and a `cornice.TypeConverter` simultaneously through
a dual interface invoked through :class:`weaver.wps_restapi.colander_extras.SchemaRefConverter`.
.. seealso::
- https://docs.pylonsproject.org/projects/colander/en/latest/binding.html
"""
_schema = f"{OGC_API_PROC_PART1_SCHEMAS}/execute.yaml"
description = "Nested process to execute, for which the selected output will become the input of the parent call."

# 'process' is required for a nested definition, otherwise it will not even be detected as one!
process = ProcessURL(description="Process reference to be executed.")
_sort_first = ["process", "inputs", "outputs", "properties", "mode", "response"]

@colander.deferred
@staticmethod
def get_field(field):
return getattr(ExecuteInputValues(), field).clone()
def _children(self, __bindings):
# type: (Dict[str, Any]) -> List[colander.SchemaNode]
self.children = [node.clone() for node in ExecuteParameters().children]
for child in self.children:
# avoid inserting nested default properties that were omitted (ie: mode/response)
# they should be included explicitly only on the top-most process by 'Execute(ExecuteParameters)' schema
child.default = null
return self.children

inputs = get_field
# calling 'bind' method will initialize this
# schema node attribute from the deferred method
children = _children
children_bound = False # only for optimization

def deserialize(self, cstruct):
"""
Defer deserialization validation to the class that contains the set of expected properties.
Additional properties that are added dynamically should "align" to reflect the :term:`OpenAPI` definition,
although correspondance is not explicitly ensured.
although correspondence is not explicitly ensured.
"""
self.bind()
return ExtendedMappingSchema.deserialize(self, cstruct)
# local_result = super().deserialize(cstruct)
# defer_result = ExecuteParameters().deserialize(cstruct)
# local_result.update(defer_result or {})
# return local_result
# def convert_type(self, cstruct, dispatcher):
# defer_schema = ExtendedObjectTypeConverter(dispatcher).convert_type(ExecuteParameters())
# local_schema = ExtendedObjectTypeConverter(dispatcher).convert_type(self)
# # local definitions take precedence to reflect alternate requirements
# # defer the missing properties from the other schema (but only properties, to not override requirements)
# defer_schema = {field: schema for field, schema in defer_schema.items() if "properties" in field.lower()}
# local_schema.update(defer_schema)
# return local_schema
node = self
if not self.children_bound:
node = self.bind() # ensure bindings are applied to generate children recursive references
node.children_bound = True # avoid doing the binding to resolve children on each recursive resolution
return ExtendedMappingSchema.deserialize(node, cstruct)


class ExecuteNestedProcessInput(AllOfKeywordSchema):
_schema = f"{OGC_API_PROC_PART1_SCHEMAS}/execute.yaml"
description = "Nested process to execute, for which the selected output will become the input of the parent call."

_all_of = [
ExecuteNestedProcessReference(),
ExecuteNestedProcessParameters(),
]


# Backward compatible data-input that allows values to be nested under 'data' or 'value' fields,
Expand Down

0 comments on commit 1c0148f

Please sign in to comment.