Skip to content

Commit 7afde6f

Browse files
authored
Merge branch 'master' into fix-lint-ignore-comments
2 parents 3f4aef4 + 94f0f3f commit 7afde6f

File tree

8 files changed

+301
-11
lines changed

8 files changed

+301
-11
lines changed

CHANGES.rst

+6-1
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,13 @@ Fixes:
2121

2222
Changes:
2323
--------
24-
- No change.
24+
- Add support for array type as job inputs
25+
(relates to `#233 <https://github.com/crim-ca/weaver/issues/233>`_).
2526

2627
Fixes:
2728
------
29+
- Fixed the format of the output file URL. When the prefix ``/`` was not present,
30+
URL was incorrectly handled by not prepending the required base URL location.
2831
- Fix backward compatibility of pre-deployed processes that did not define ``jobControlOptions`` that is now required.
2932
Missing definition are substituted in-place by default ``["execute-async"]`` mode.
3033

@@ -36,6 +39,8 @@ Changes:
3639
- Add reference link to ReadTheDocs URL of `Weaver` in API landing page.
3740
- Add references to `OGC-API Processes` requirements and recommendations for eventual conformance listing
3841
(relates to `#231 <https://github.com/crim-ca/weaver/issues/231>`_).
42+
- In order to align with OpenAPI ``boolean`` type definitions, non explicit ``boolean`` values will not be automatically
43+
converted to ``bool`` anymore. They will require explicit ``false|true``.
3944
- Add ``datetime`` query parameter for job searches queries
4045
(relates to `#236 <https://github.com/crim-ca/weaver/issues/236>`_).
4146
- Add ``limit`` query parameter validation and integration for jobs in retrieve queries

tests/functional/test_wps_package.py

+182-1
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@
88
- :mod:`tests.processes.wps_package`.
99
"""
1010
import contextlib
11+
import json
1112
import logging
1213
import os
14+
from inspect import cleandoc
1315

1416
import colander
1517
import pytest
@@ -23,6 +25,8 @@
2325
mocked_aws_s3,
2426
mocked_aws_s3_bucket_test_file,
2527
mocked_execute_process,
28+
mocked_http_file,
29+
mocked_reference_test_file,
2630
mocked_sub_requests
2731
)
2832
from weaver.execute import EXECUTE_MODE_ASYNC, EXECUTE_RESPONSE_DOCUMENT, EXECUTE_TRANSMISSION_MODE_REFERENCE
@@ -37,7 +41,11 @@
3741
IANA_NAMESPACE,
3842
get_cwl_file_format
3943
)
40-
from weaver.processes.constants import CWL_REQUIREMENT_APP_BUILTIN, CWL_REQUIREMENT_INIT_WORKDIR
44+
from weaver.processes.constants import (
45+
CWL_REQUIREMENT_APP_BUILTIN,
46+
CWL_REQUIREMENT_APP_DOCKER,
47+
CWL_REQUIREMENT_INIT_WORKDIR
48+
)
4149
from weaver.utils import get_any_value
4250

4351
EDAM_PLAIN = EDAM_NAMESPACE + ":" + EDAM_MAPPING[CONTENT_TYPE_TEXT_PLAIN]
@@ -69,6 +77,7 @@ def setUpClass(cls):
6977
"weaver.wps": True,
7078
"weaver.wps_path": "/ows/wps",
7179
"weaver.wps_restapi_path": "/",
80+
"weaver.wps_output_dir": "/tmp", # nosec: B108 # don't care hardcoded for test
7281
}
7382
super(WpsPackageAppTest, cls).setUpClass()
7483

@@ -925,6 +934,178 @@ def test_valid_io_min_max_occurs_as_str_or_int(self):
925934
"Field '{}' of input '{}'({}) is expected to be '{}' but was '{}'" \
926935
.format(field, process_input, i, proc_in_exp, proc_in_res)
927936

937+
@mocked_aws_credentials
938+
@mocked_aws_s3
939+
@mocked_http_file
940+
def test_execute_job_with_array_input(self):
941+
"""
942+
The test validates job can receive an array as input and process it as expected.
943+
"""
944+
cwl = {
945+
"cwlVersion": "v1.0",
946+
"class": "CommandLineTool",
947+
"baseCommand": ["python3", "script.py"],
948+
"inputs":
949+
{
950+
"test_int_array": {"type": {"type": "array", "items": "int"}, "inputBinding": {"position": 1}},
951+
"test_float_array": {"type": {"type": "array", "items": "float"}},
952+
"test_string_array": {"type": {"type": "array", "items": "string"}},
953+
"test_reference_array": {"type": {"type": "array", "items": "File"}},
954+
"test_int_value": "int",
955+
"test_float_value": "float",
956+
"test_string_value": "string",
957+
"test_reference_http_value": "File",
958+
"test_reference_file_value": "File",
959+
"test_reference_s3_value": "File"
960+
},
961+
"requirements": {
962+
CWL_REQUIREMENT_APP_DOCKER: {
963+
"dockerPull": "python:3.7-alpine"
964+
},
965+
CWL_REQUIREMENT_INIT_WORKDIR: {
966+
"listing": [
967+
{
968+
"entryname": "script.py",
969+
"entry": cleandoc("""
970+
import json
971+
import os
972+
input = $(inputs)
973+
for key, value in input.items():
974+
if isinstance(value, list):
975+
if all(isinstance(val, int) for val in value):
976+
value = map(lambda v: v+1, value)
977+
elif all(isinstance(val, float) for val in value):
978+
value = map(lambda v: v+0.5, value)
979+
elif all(isinstance(val, bool) for val in value):
980+
value = map(lambda v: not v, value)
981+
elif all(isinstance(val, str) for val in value):
982+
value = map(lambda v: v.upper(), value)
983+
elif all(isinstance(val, dict) for val in value):
984+
def tmp(value):
985+
path_ = value.get('path')
986+
if path_ and os.path.exists(path_):
987+
with open (path_, 'r') as file_:
988+
filedata = file_.read()
989+
return filedata.upper()
990+
value = map(tmp, value)
991+
input[key] = ";".join(map(str, value))
992+
elif isinstance(value, dict):
993+
path_ = value.get('path')
994+
if path_ and os.path.exists(path_):
995+
with open (path_, 'r') as file_:
996+
filedata = file_.read()
997+
input[key] = filedata.upper()
998+
elif isinstance(value, str):
999+
input[key] = value.upper()
1000+
elif isinstance(value, bool):
1001+
input[key] = not value
1002+
elif isinstance(value, int):
1003+
input[key] = value+1
1004+
elif isinstance(value, float):
1005+
input[key] = value+0.5
1006+
json.dump(input, open("./tmp.txt","w"))
1007+
""")
1008+
}
1009+
]
1010+
}
1011+
},
1012+
"outputs": [{"id": "output_test", "type": "File", "outputBinding": {"glob": "tmp.txt"}}],
1013+
}
1014+
body = {
1015+
"processDescription": {
1016+
"process": {
1017+
"id": self._testMethodName,
1018+
"title": "some title",
1019+
"abstract": "this is a test",
1020+
},
1021+
},
1022+
"deploymentProfileName": "http://www.opengis.net/profiles/eoc/wpsApplication",
1023+
"executionUnit": [{"unit": cwl}],
1024+
}
1025+
try:
1026+
desc, _ = self.deploy_process(body)
1027+
except colander.Invalid:
1028+
self.fail("Test")
1029+
1030+
assert desc["process"] is not None
1031+
1032+
test_bucket_ref = mocked_aws_s3_bucket_test_file(
1033+
"wps-process-test-bucket",
1034+
"input_file_s3.txt",
1035+
"This is a generated file for s3 test"
1036+
)
1037+
1038+
test_http_ref = mocked_reference_test_file(
1039+
"input_file_http.txt",
1040+
"http",
1041+
"This is a generated file for http test"
1042+
)
1043+
1044+
test_file_ref = mocked_reference_test_file(
1045+
"input_file_ref.txt",
1046+
"file",
1047+
"This is a generated file for file test"
1048+
)
1049+
1050+
exec_body = {
1051+
"mode": EXECUTE_MODE_ASYNC,
1052+
"response": EXECUTE_RESPONSE_DOCUMENT,
1053+
"inputs":
1054+
[
1055+
{"id": "test_int_array", "value": [10, 20, 30, 40, 50]},
1056+
{"id": "test_float_array", "value": [10.03, 20.03, 30.03, 40.03, 50.03]},
1057+
{"id": "test_string_array", "value": ["this", "is", "a", "test"]},
1058+
{"id": "test_reference_array",
1059+
"value": [{"href": test_file_ref},
1060+
{"href": test_http_ref},
1061+
{"href": test_bucket_ref}
1062+
]
1063+
},
1064+
{"id": "test_int_value", "value": 2923},
1065+
{"id": "test_float_value", "value": 389.73},
1066+
{"id": "test_string_value", "value": "stringtest"},
1067+
{"id": "test_reference_http_value", "href": test_http_ref},
1068+
{"id": "test_reference_file_value", "href": test_file_ref},
1069+
{"id": "test_reference_s3_value", "href": test_bucket_ref}
1070+
],
1071+
"outputs": [
1072+
{"id": "output_test", "type": "File"},
1073+
]
1074+
}
1075+
1076+
with contextlib.ExitStack() as stack_exec:
1077+
for mock_exec in mocked_execute_process():
1078+
stack_exec.enter_context(mock_exec)
1079+
proc_url = "/processes/{}/jobs".format(self._testMethodName)
1080+
resp = mocked_sub_requests(self.app, "post_json", proc_url, timeout=5,
1081+
data=exec_body, headers=self.json_headers, only_local=True)
1082+
assert resp.status_code in [200, 201], "Failed with: [{}]\nReason:\n{}".format(resp.status_code, resp.json)
1083+
status_url = resp.json.get("location")
1084+
1085+
results = self.monitor_job(status_url)
1086+
1087+
job_output_file = results.get("output_test")["href"].split("/", 3)[-1]
1088+
tmpfile = "{}/{}".format(self.settings["weaver.wps_output_dir"], job_output_file)
1089+
1090+
try:
1091+
processed_values = json.load(open(tmpfile, "r"))
1092+
except FileNotFoundError:
1093+
self.fail("Output file [{}] was not found where it was expected to resume test".format(tmpfile))
1094+
except Exception as exception:
1095+
self.fail("An error occured during the reading of the file: {}".format(exception))
1096+
assert processed_values["test_int_array"] == "11;21;31;41;51"
1097+
assert processed_values["test_float_array"] == "10.53;20.53;30.53;40.53;50.53"
1098+
assert processed_values["test_string_array"] == "THIS;IS;A;TEST"
1099+
assert processed_values["test_reference_array"] == ("THIS IS A GENERATED FILE FOR FILE TEST;"
1100+
"THIS IS A GENERATED FILE FOR HTTP TEST;"
1101+
"THIS IS A GENERATED FILE FOR S3 TEST")
1102+
assert processed_values["test_int_value"] == 2924
1103+
assert processed_values["test_float_value"] == 390.23
1104+
assert processed_values["test_string_value"] == "STRINGTEST"
1105+
assert processed_values["test_reference_s3_value"] == "THIS IS A GENERATED FILE FOR S3 TEST"
1106+
assert processed_values["test_reference_http_value"] == "THIS IS A GENERATED FILE FOR HTTP TEST"
1107+
assert processed_values["test_reference_file_value"] == "THIS IS A GENERATED FILE FOR FILE TEST"
1108+
9281109
# FIXME: test not working
9291110
# same payloads sent directly to running weaver properly raise invalid schema -> bad request error
9301111
# somehow they don't work within this test (not raised)...

tests/utils.py

+43-5
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
from weaver.datatype import Service
3131
from weaver.formats import CONTENT_TYPE_APP_JSON, CONTENT_TYPE_TEXT_XML
3232
from weaver.store.mongodb import MongodbJobStore, MongodbProcessStore, MongodbServiceStore
33-
from weaver.utils import get_path_kvp, get_url_without_query, get_weaver_url, null
33+
from weaver.utils import fetch_file, get_path_kvp, get_url_without_query, get_weaver_url, null
3434
from weaver.warning import MissingParameterWarning, UnsupportedOperationWarning
3535

3636
if TYPE_CHECKING:
@@ -46,6 +46,7 @@
4646
MockPatch = mock._patch # noqa
4747

4848
MOCK_AWS_REGION = "us-central-1"
49+
MOCK_HTTP_REF = "http://mock.localhost"
4950

5051

5152
def ignore_warning_regex(func, warning_message_regex, warning_categories=DeprecationWarning):
@@ -371,11 +372,11 @@ def mocked_app_request(method, url=None, **req_kwargs):
371372

372373
url, func, req_kwargs = _parse_for_app_req(method, url, **req_kwargs)
373374
redirects = req_kwargs.pop("allow_redirects", True)
374-
if not url.startswith("mock://"):
375-
_resp = func(url, expect_errors=True, **req_kwargs)
376-
else:
375+
if url.startswith("mock://"):
377376
path = get_url_without_query(url.replace("mock://", ""))
378377
_resp = mocked_file_response(path, url)
378+
else:
379+
_resp = func(url, expect_errors=True, **req_kwargs)
379380
if redirects:
380381
# must handle redirects manually with TestApp
381382
while 300 <= _resp.status_code < 400:
@@ -384,7 +385,7 @@ def mocked_app_request(method, url=None, **req_kwargs):
384385
return _resp
385386

386387
# permit schema validation against 'mock' scheme during test only
387-
mock_file_regex = mock.PropertyMock(return_value=colander.Regex(r"^(file|mock://)?(?:/|[/?]\S+)$"))
388+
mock_file_regex = mock.PropertyMock(return_value=colander.Regex(r"^((file|mock)://)?(?:/|[/?]\S+)$"))
388389
with contextlib.ExitStack() as stack:
389390
stack.enter_context(mock.patch("requests.request", side_effect=mocked_app_request))
390391
stack.enter_context(mock.patch("requests.Session.request", side_effect=mocked_app_request))
@@ -549,3 +550,40 @@ def mocked_aws_s3_bucket_test_file(bucket_name, file_name, file_content="Test fi
549550
tmp_file.flush()
550551
s3.upload_file(Bucket=bucket_name, Filename=tmp_file.name, Key=file_name)
551552
return "s3://{}/{}".format(bucket_name, file_name)
553+
554+
555+
def mocked_http_file(test_func):
556+
# type: (Callable[[...], Any]) -> Callable
557+
"""
558+
Creates a mock of the function :func:`fetch_file`, to fetch a generated file locally, for test purposes only.
559+
For instance, calling this function with :func:`mocked_http_file` decorator
560+
will effectively employ the mocked :func:`fetch_file` and return a generated local file.
561+
562+
.. seealso::
563+
- :func:`mocked_reference_test_file`
564+
"""
565+
def mocked_file_request(file_reference, file_outdir, **kwargs):
566+
if file_reference and file_reference.startswith(MOCK_HTTP_REF):
567+
file_reference = file_reference.replace(MOCK_HTTP_REF, "")
568+
file_path = fetch_file(file_reference, file_outdir, **kwargs)
569+
return file_path
570+
571+
def wrapped(*args, **kwargs):
572+
with mock.patch("weaver.processes.wps_package.fetch_file", side_effect=mocked_file_request):
573+
return test_func(*args, **kwargs)
574+
return wrapped
575+
576+
577+
def mocked_reference_test_file(file_name, href_type, file_content="This is a generated file for href test"):
578+
# type: (str,str,str) -> str
579+
"""
580+
Generates a test file reference from dummy data for http and file href types.
581+
582+
.. seealso::
583+
- :func:`mocked_http_file`
584+
"""
585+
tmpdir = tempfile.mkdtemp()
586+
path = os.path.join(tmpdir, file_name)
587+
with open(path, "w") as tmp_file:
588+
tmp_file.write(file_content)
589+
return "file://{}".format(path) if href_type == "file" else os.path.join(MOCK_HTTP_REF, path)

weaver/processes/constants.py

+1
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
CWL_REQUIREMENT_APP_WPS1,
3434
])
3535
CWL_REQUIREMENT_INIT_WORKDIR = "InitialWorkDirRequirement"
36+
CWL_REQUIREMENT_APP_DOCKER = "DockerRequirement"
3637

3738
# CWL package types and extensions
3839
PACKAGE_SIMPLE_TYPES = frozenset(["string", "boolean", "float", "int", "integer", "long", "double"])

weaver/processes/execution.py

+8-1
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,14 @@ def execute_process(self, job_id, url, headers=None):
118118
input_values = process_value if isinstance(process_value, list) else [process_value]
119119

120120
# we need to support file:// scheme but PyWPS doesn't like them so remove the scheme file://
121-
input_values = [val[7:] if str(val).startswith("file://") else val for val in input_values]
121+
input_values = [
122+
# when value is an array of dict that each contain a file reference
123+
(get_any_value(val)[7:] if str(get_any_value(val)).startswith("file://") else get_any_value(val))
124+
if isinstance(val, dict) else
125+
# when value is directly a single dict with file reference
126+
(val[7:] if str(val).startswith("file://") else val)
127+
for val in input_values
128+
]
122129

123130
# need to use ComplexDataInput structure for complex input
124131
# need to use literal String for anything else than complex

weaver/wps_restapi/colander_extras.py

+14
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,20 @@ def __init__(self, *args, v_prefix=False, rc_suffix=True, **kwargs):
179179

180180

181181
class ExtendedBoolean(colander.Boolean):
182+
183+
def __init__(self, *args, true_choices=None, fase_choices=None, **kwargs):
184+
"""
185+
The arguments :paramref:`true_choices` and :paramref:`false_choices`
186+
are defined as ``"true"`` and ``"false"`` since :mod:`colander` converts the value to string lowercase
187+
to compare with other thruty/falsy values it should accept. Do NOT add other values like ``"1"``
188+
to avoid conflict with ``Integer`` type for schemas that support both variants.
189+
"""
190+
if true_choices is None:
191+
true_choices = ("true")
192+
if fase_choices is None:
193+
false_choices = ("false")
194+
super(ExtendedBoolean, self).__init__(true_choices=true_choices, false_choices=false_choices, *args, **kwargs)
195+
182196
def serialize(self, node, cstruct): # pylint: disable=W0221
183197
result = super(ExtendedBoolean, self).serialize(node, cstruct)
184198
if result is not colander.null:

weaver/wps_restapi/jobs/jobs.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,9 @@ def get_results(job, container, value_key=None, ogc_api=False):
105105
if rtype == "href":
106106
# fix paths relative to instance endpoint, but leave explicit links as is (eg: S3 bucket, remote HTTP, etc.)
107107
if value.startswith("/"):
108-
value = wps_url + str(value).lstrip("/")
108+
value = str(value).lstrip("/")
109+
if "://" not in value:
110+
value = wps_url + value
109111
elif ogc_api:
110112
out_key = "value"
111113
elif value_key:

0 commit comments

Comments
 (0)