Skip to content

Commit

Permalink
conformance of process description with OGC-API v1.0-draft6 + better …
Browse files Browse the repository at this point in the history
…support of datatype field/attr/property auto-resolution
  • Loading branch information
fmigneault committed Jul 16, 2021
1 parent 9f5252d commit 0a8c333
Show file tree
Hide file tree
Showing 7 changed files with 160 additions and 73 deletions.
2 changes: 2 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ Changes:
- Remove automatic conversion of falsy/truthy ``string`` and ``integer`` type definitions to ``boolean`` type
to align with OpenAPI ``boolean`` type definitions. Non explicit ``boolean`` values will not be automatically
converted to ``bool`` anymore. They will require explicit ``false|true`` values.
- Apply conformance updates to align with expected process description schema from
`OGC-API - Processes v1.0-draft6 <https://github.com/opengeospatial/ogcapi-processes/tree/1.0-draft.6>`_.

Fixes:
------
Expand Down
163 changes: 112 additions & 51 deletions weaver/datatype.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
Definitions of types used by tokens.
"""
import copy
import inspect
import traceback
import uuid
import warnings
Expand All @@ -15,8 +16,13 @@
from pywps import Process as ProcessWPS

from weaver.exceptions import ProcessInstanceError
from weaver.execute import EXECUTE_CONTROL_OPTION_ASYNC, EXECUTE_CONTROL_OPTIONS
from weaver.formats import ACCEPT_LANGUAGE_EN_US, CONTENT_TYPE_APP_JSON
from weaver.execute import (
EXECUTE_CONTROL_OPTION_ASYNC,
EXECUTE_CONTROL_OPTIONS,
EXECUTE_TRANSMISSION_MODE_OPTIONS,
EXECUTE_TRANSMISSION_MODE_REFERENCE
)
from weaver.formats import ACCEPT_LANGUAGE_EN_CA, CONTENT_TYPE_APP_JSON, CONTENT_TYPE_APP_XML
from weaver.processes.convert import ows2json, wps2json_io
from weaver.processes.types import (
PROCESS_APPLICATION,
Expand Down Expand Up @@ -63,18 +69,16 @@ def __setattr__(self, item, value):
prop = getattr(type(self), item)
if isinstance(prop, property) and prop.fset is not None:
prop.fset(self, value) # noqa
elif item in self:
self[item] = value
else:
raise AttributeError("Can't set attribute '{}'.".format(item))
super(Base, self).__setitem__(item, value)

def __getattr__(self, item):
def __getitem__(self, item):
# use existing property getter if defined
prop = getattr(type(self), item)
if isinstance(prop, property) and prop.fget is not None:
return prop.fget(self, item) # noqa
return prop.fget(self) # noqa
elif item in self:
return self[item]
return getattr(self, item, None)
else:
raise AttributeError("Can't get attribute '{}'.".format(item))

Expand Down Expand Up @@ -118,6 +122,20 @@ def params(self):
"""
raise NotImplementedError("Method 'params' must be defined for storage item representation.")

def dict(self):
"""
Generate a dictionary representation of the object, but with inplace resolution of attributes as applicable.
"""
# update any entries by key with their attribute
_dict = {key: getattr(self, key, dict.__getitem__(self, key)) for key, val in self.items()}
# then, ensure any missing key gets added if a getter property exists for it
props = {prop[0] for prop in inspect.getmembers(self) if not prop[0].startswith("_") and prop[0] not in _dict}
for key in props:
prop = getattr(type(self), key)
if isinstance(prop, property) and prop.fget is not None:
_dict[key] = prop.fget(self) # noqa
return _dict


class Service(Base):
"""
Expand All @@ -138,12 +156,12 @@ def id(self):
@property
def url(self):
"""Service URL."""
return self["url"]
return dict.__getitem__(self, "url")

@property
def name(self):
"""Service name."""
return self["name"]
return dict.__getitem__(self, "name")

@property
def type(self):
Expand Down Expand Up @@ -364,7 +382,7 @@ def _get_inputs(self):
# type: () -> List[Optional[Dict[str, Any]]]
if self.get("inputs") is None:
self["inputs"] = list()
return self["inputs"]
return dict.__getitem__(self, "inputs")

def _set_inputs(self, inputs):
# type: (List[Optional[Dict[str, Any]]]) -> None
Expand Down Expand Up @@ -560,7 +578,7 @@ def _get_results(self):
# type: () -> List[Optional[Dict[str, Any]]]
if self.get("results") is None:
self["results"] = list()
return self["results"]
return dict.__getitem__(self, "results")

def _set_results(self, results):
# type: (List[Optional[Dict[str, Any]]]) -> None
Expand All @@ -575,7 +593,7 @@ def _get_exceptions(self):
# type: () -> List[Optional[Dict[str, str]]]
if self.get("exceptions") is None:
self["exceptions"] = list()
return self["exceptions"]
return dict.__getitem__(self, "exceptions")

def _set_exceptions(self, exceptions):
# type: (List[Optional[Dict[str, str]]]) -> None
Expand All @@ -590,7 +608,7 @@ def _get_logs(self):
# type: () -> List[Dict[str, str]]
if self.get("logs") is None:
self["logs"] = list()
return self["logs"]
return dict.__getitem__(self, "logs")

def _set_logs(self, logs):
# type: (List[Dict[str, str]]) -> None
Expand All @@ -605,7 +623,7 @@ def _get_tags(self):
# type: () -> List[Optional[str]]
if self.get("tags") is None:
self["tags"] = list()
return self["tags"]
return dict.__getitem__(self, "tags")

def _set_tags(self, tags):
# type: (List[Optional[str]]) -> None
Expand Down Expand Up @@ -660,12 +678,11 @@ def response(self, response):
response = lxml.etree.tostring(response)
self["response"] = response

def _job_url(self, settings):
base_job_url = get_wps_restapi_base_url(settings)
def _job_url(self, base_url=None):
if self.service is not None:
base_job_url += sd.provider_service.path.format(provider_id=self.service)
base_url += sd.provider_service.path.format(provider_id=self.service)
job_path = sd.process_job_service.path.format(process_id=self.process, job_id=self.id)
return "{base_job_url}{job_path}".format(base_job_url=base_job_url, job_path=job_path)
return "{base_job_url}{job_path}".format(base_job_url=base_url, job_path=job_path)

def links(self, container=None, self_link=None):
# type: (Optional[AnySettingsContainer], Optional[str]) -> JSON
Expand All @@ -677,10 +694,14 @@ def links(self, container=None, self_link=None):
:param container: object that helps retrieve instance details, namely the host URL.
:param self_link: name of a section that represents the current link that will be returned.
"""
settings = get_settings(container) if container else {}
job_url = self._job_url(settings)
settings = get_settings(container)
base_url = get_wps_restapi_base_url(settings)
job_url = self._job_url(base_url)
job_list = "{}/{}".format(base_url, sd.jobs_service.path)
job_links_body = {"links": [
{"href": job_url, "rel": "status", "title": "Job status."},
{"href": job_url, "rel": "monitor", "title": "Job monitoring location."},
{"href": job_list, "rel": "collection", "title": "List of submitted jobs."}
]}
job_links = ["logs", "inputs"]
if self.status in JOB_STATUS_CATEGORIES[STATUS_CATEGORY_FINISHED]:
Expand All @@ -692,16 +713,16 @@ def links(self, container=None, self_link=None):
for link_type in job_links:
link_href = "{job_url}/{res}".format(job_url=job_url, res=link_type)
job_links_body["links"].append({"href": link_href, "rel": link_type, "title": "Job {}.".format(link_type)})
link_meta = {"type": CONTENT_TYPE_APP_JSON, "hreflang": ACCEPT_LANGUAGE_EN_US}
for link in job_links_body["links"]:
link.update(link_meta)
if self_link in ["status", "inputs", "outputs", "results", "logs", "exceptions"]:
self_link_body = list(filter(lambda _link: _link["rel"] == self_link, job_links_body["links"]))[-1]
self_link_body = copy.deepcopy(self_link_body)
else:
self_link_body = {"href": job_url, "title": "Job status."}
self_link_body["rel"] = "self"
job_links_body["links"].append(self_link_body)
link_meta = {"type": CONTENT_TYPE_APP_JSON, "hreflang": ACCEPT_LANGUAGE_EN_CA}
for link in job_links_body["links"]:
link.update(link_meta)
return job_links_body

def json(self, container=None, self_link=None): # pylint: disable=W0221,arguments-differ
Expand Down Expand Up @@ -782,7 +803,7 @@ def __init__(self, *args, **kwargs):
@property
def id(self):
# type: () -> str
return self["id"]
return dict.__getitem__(self, "id")

@property
def identifier(self):
Expand All @@ -804,10 +825,20 @@ def abstract(self):
# type: () -> str
return self.get("abstract", "")

@property
def description(self):
# OGC-API-Processes v1 field representation
# bw-compat with existing processes that defined it as abstract
return self.abstract or self.get("description", "")

@property
def keywords(self):
# type: () -> List[str]
return self.get("keywords", [])
keywords = self.setdefault("keywords", [])
if self.type not in keywords:
keywords.append(self.type)
self["keywords"] = keywords
return dict.__getitem__(self, "keywords")

@property
def metadata(self):
Expand Down Expand Up @@ -848,18 +879,26 @@ def outputs(self):
@property
def jobControlOptions(self): # noqa: N802
# type: () -> List[str]
self.setdefault("jobControlOptions", [EXECUTE_CONTROL_OPTION_ASYNC])
if not isinstance(self["jobControlOptions"], list): # eg: None, bw-compat
self["jobControlOptions"] = [EXECUTE_CONTROL_OPTION_ASYNC]
self["jobControlOptions"] = [mode for mode in self["jobControlOptions"] if mode in EXECUTE_CONTROL_OPTIONS]
if len(self["jobControlOptions"]) == 0:
self["jobControlOptions"].append(EXECUTE_CONTROL_OPTION_ASYNC)
return self.get("jobControlOptions")
jco = self.setdefault("jobControlOptions", [EXECUTE_CONTROL_OPTION_ASYNC])
if not isinstance(jco, list): # eg: None, bw-compat
jco = [EXECUTE_CONTROL_OPTION_ASYNC]
jco = [mode for mode in jco if mode in EXECUTE_CONTROL_OPTIONS]
if len(jco) == 0:
jco.append(EXECUTE_CONTROL_OPTION_ASYNC)
self["jobControlOptions"] = jco
return dict.__getitem__(self, "jobControlOptions")

@property
def outputTransmission(self): # noqa: N802
# type: () -> List[str]
return self.get("outputTransmission", [])
out = self.setdefault("outputTransmission", [EXECUTE_TRANSMISSION_MODE_REFERENCE])
if not isinstance(out, list): # eg: None, bw-compat
out = [EXECUTE_TRANSMISSION_MODE_REFERENCE]
out = [mode for mode in out if mode in EXECUTE_TRANSMISSION_MODE_OPTIONS]
if len(out) == 0:
out.append(EXECUTE_TRANSMISSION_MODE_REFERENCE)
self["outputTransmission"] = out
return dict.__getitem__(self, "outputTransmission")

@property
def processDescriptionURL(self): # noqa: N802
Expand Down Expand Up @@ -1018,28 +1057,50 @@ def json(self):
"""
Obtains the JSON serializable complete representation of the process.
"""
return sd.Process().deserialize(self)
return sd.Process().deserialize(self.dict())

def links(self, container=None):
# type: (Optional[AnySettingsContainer]) -> JSON
"""Obtains the JSON links section of many response body for the process.
:param container: object that helps retrieve instance details, namely the host URL.
"""
settings = get_settings(container)
base_url = get_wps_restapi_base_url(settings)
proc_desc = sd.process_service.path.format(process_id=self.id)
proc_list = sd.processes_service.path
proc_exec = sd.process_execution_service.path.format(process_id=self.id)
links = [
{"href": base_url + proc_desc, "rel": "self", "title": "Process description."},
{"href": base_url + proc_desc, "rel": "process-desc", "title": "Process description."},
{"href": base_url + proc_exec, "rel": "execute", "title": "Process execution endpoint for job submission."},
{"href": base_url + proc_list, "rel": "collection", "title": "List of registered processes."}
]
if self.processEndpointWPS1:
wps_url = "{}?service=WPS&request=GetCapabilities".format(self.processEndpointWPS1)
links.append({"href": wps_url, "rel": "service-desc",
"type": CONTENT_TYPE_APP_XML, "title": "Service definition."})
for link in links:
link.setdefault("type", CONTENT_TYPE_APP_JSON)
link.setdefault("hreflang", ACCEPT_LANGUAGE_EN_CA)
return {"links": links}

def offering(self):
# type: () -> JSON
"""
Obtains the JSON serializable offering representation of the process.
"""
process_offering = {"process": self}
if self.version:
process_offering.update({"processVersion": self.version})
if self.jobControlOptions:
process_offering.update({"jobControlOptions": self.jobControlOptions})
if self.outputTransmission:
process_offering.update({"outputTransmission": self.outputTransmission})
return sd.ProcessOffering().deserialize(process_offering)
process = self.dict()
process.update(self.links())
return sd.ProcessOffering().deserialize(process)

def summary(self):
# type: () -> JSON
"""
Obtains the JSON serializable summary representation of the process.
"""
return sd.ProcessSummary().deserialize(self)
process = self.dict()
return sd.ProcessSummary().deserialize(process)

@staticmethod
def from_wps(wps_process, **extra_params):
Expand Down Expand Up @@ -1171,7 +1232,7 @@ def __init__(self, *args, **kwargs):
@property
def id(self):
"""Quote ID."""
return self["id"]
return dict.__getitem__(self, "id")

@property
def title(self):
Expand All @@ -1191,12 +1252,12 @@ def details(self):
@property
def user(self):
"""User ID requesting the quote"""
return self["user"]
return dict.__getitem__(self, "user")

@property
def process(self):
"""WPS Process ID."""
return self["process"]
return dict.__getitem__(self, "process")

@property
def estimatedTime(self): # noqa: N802
Expand Down Expand Up @@ -1302,22 +1363,22 @@ def __init__(self, *args, **kwargs):
@property
def id(self):
"""Bill ID."""
return self["id"]
return dict.__getitem__(self, "id")

@property
def user(self):
"""User ID"""
return self["user"]
return dict.__getitem__(self, "user")

@property
def quote(self):
"""Quote ID."""
return self["quote"]
return dict.__getitem__(self, "quote")

@property
def job(self):
"""Job ID."""
return self["job"]
return dict.__getitem__(self, "job")

@property
def price(self):
Expand Down
3 changes: 3 additions & 0 deletions weaver/processes/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,9 @@ def deploy_process_from_payload(payload, container, overwrite=False):
process_info["owsContext"] = {"offering": {"content": {"href": str(reference)}}}
elif isinstance(ows_context, dict):
process_info["owsContext"] = ows_context
# bw-compat abstract/description (see: ProcessDeployment schema)
if "description" not in process_info or not process_info["description"]:
process_info["description"] = process_info.get("abstract", "")

# FIXME: handle colander invalid directly in tween (https://github.com/crim-ca/weaver/issues/112)
try:
Expand Down
4 changes: 2 additions & 2 deletions weaver/typedefs.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
if TYPE_CHECKING:
import os
import typing
from datetime import datetime
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Type, Union
if hasattr(typing, "TypedDict"):
from typing import TypedDict # pylint: disable=E0611,no-name-in-module
Expand Down Expand Up @@ -117,5 +118,4 @@

# others
DatetimeIntervalType = TypedDict("DatetimeIntervalType",
{"before": str, "after": str,
"match": str, }, total=False)
{"before": datetime, "after": datetime, "match": datetime}, total=False)
Loading

0 comments on commit 0a8c333

Please sign in to comment.