assertions-mate 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- assertions_mate/__about__.py +15 -0
- assertions_mate/__init__.py +170 -0
- assertions_mate/cli.py +128 -0
- assertions_mate/cql2_validator.py +116 -0
- assertions_mate/error_models.py +569 -0
- assertions_mate/jsonschema_validator.py +55 -0
- assertions_mate/rego_validator.py +65 -0
- assertions_mate-0.1.0.dist-info/METADATA +38 -0
- assertions_mate-0.1.0.dist-info/RECORD +13 -0
- assertions_mate-0.1.0.dist-info/WHEEL +4 -0
- assertions_mate-0.1.0.dist-info/entry_points.txt +2 -0
- assertions_mate-0.1.0.dist-info/licenses/LICENSE +202 -0
- assertions_mate-0.1.0.dist-info/licenses/NOTICE +5 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Copyright 2025 Terradue
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# Copyright 2025 Terradue
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
from .error_models import ProblemDetails
|
|
16
|
+
from abc import (
|
|
17
|
+
ABC,
|
|
18
|
+
abstractmethod
|
|
19
|
+
)
|
|
20
|
+
from cwl2ogc import BaseCWLtypes2OGCConverter
|
|
21
|
+
from loguru import logger
|
|
22
|
+
from pydantic import (
|
|
23
|
+
BaseModel,
|
|
24
|
+
computed_field,
|
|
25
|
+
model_serializer
|
|
26
|
+
)
|
|
27
|
+
from typing import (
|
|
28
|
+
Any,
|
|
29
|
+
List,
|
|
30
|
+
Mapping,
|
|
31
|
+
Optional
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
class BaseValidator(ABC):
|
|
35
|
+
|
|
36
|
+
@abstractmethod
|
|
37
|
+
def validate_inputs(
|
|
38
|
+
self,
|
|
39
|
+
data: Mapping[str, Any]
|
|
40
|
+
) -> ProblemDetails | None:
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
class AssertionHint(BaseModel):
|
|
44
|
+
parent_workflow: Optional[Any] = None
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
@computed_field
|
|
48
|
+
@abstractmethod
|
|
49
|
+
def annotation(self) -> str:
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
@abstractmethod
|
|
53
|
+
def validator(self) -> BaseValidator:
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
class JSONSchemaHint(AssertionHint):
|
|
57
|
+
json_schema: Mapping[str, Any] = {}
|
|
58
|
+
|
|
59
|
+
@staticmethod
|
|
60
|
+
def get_annotation_name() -> str:
|
|
61
|
+
return 'eoap.ogc.org/inputs-json-schema'
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def annotation(self) -> str:
|
|
65
|
+
return JSONSchemaHint.get_annotation_name()
|
|
66
|
+
|
|
67
|
+
@model_serializer
|
|
68
|
+
def ser_model(self) -> Mapping[str, Any]:
|
|
69
|
+
return BaseCWLtypes2OGCConverter(self.parent_workflow).get_inputs_json_schema() if self.parent_workflow is not None else self.json_schema
|
|
70
|
+
|
|
71
|
+
def validator(self) -> BaseValidator:
|
|
72
|
+
from .jsonschema_validator import JSONSchemaValidator
|
|
73
|
+
schema = BaseCWLtypes2OGCConverter(self.parent_workflow).get_inputs_json_schema() if self.parent_workflow is not None else self.json_schema
|
|
74
|
+
return JSONSchemaValidator(schema=schema)
|
|
75
|
+
|
|
76
|
+
class RegoPolicyHint(AssertionHint):
|
|
77
|
+
|
|
78
|
+
@staticmethod
|
|
79
|
+
def get_annotation_name() -> str:
|
|
80
|
+
return 'eoap.ogc.org/inputs-rego-policy'
|
|
81
|
+
|
|
82
|
+
module: str
|
|
83
|
+
queries: List[str]
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def annotation(self) -> str:
|
|
87
|
+
return RegoPolicyHint.get_annotation_name()
|
|
88
|
+
|
|
89
|
+
@model_serializer
|
|
90
|
+
def ser_model(self) -> Mapping[str, Any]:
|
|
91
|
+
return {
|
|
92
|
+
'queries': self.queries,
|
|
93
|
+
'module': self.module
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
def validator(self) -> BaseValidator:
|
|
97
|
+
from .rego_validator import RegoValidator
|
|
98
|
+
return RegoValidator(
|
|
99
|
+
queries=self.queries,
|
|
100
|
+
module=self.module
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
class Cql2Query(BaseModel):
|
|
104
|
+
id: str
|
|
105
|
+
cql2: str | Mapping[str, Any]
|
|
106
|
+
message: str
|
|
107
|
+
|
|
108
|
+
class Cql2FilterHint(AssertionHint):
|
|
109
|
+
|
|
110
|
+
@staticmethod
|
|
111
|
+
def get_annotation_name() -> str:
|
|
112
|
+
return 'eoap.ogc.org/inputs-cql2-filter'
|
|
113
|
+
|
|
114
|
+
queries: List[Cql2Query]
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def annotation(self) -> str:
|
|
118
|
+
return Cql2FilterHint.get_annotation_name()
|
|
119
|
+
|
|
120
|
+
@model_serializer
|
|
121
|
+
def ser_model(self) -> Mapping[str, Any]:
|
|
122
|
+
return {
|
|
123
|
+
'queries': self.queries
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
def validator(self) -> BaseValidator:
|
|
127
|
+
from .cql2_validator import Cql2Validator
|
|
128
|
+
return Cql2Validator(
|
|
129
|
+
queries=self.queries
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
def _get_assertion_hint_by_name(
|
|
133
|
+
parent_workflow: Any,
|
|
134
|
+
hint: Mapping[str, Any]
|
|
135
|
+
) -> AssertionHint | None:
|
|
136
|
+
fqn_hint_kind = hint['class']
|
|
137
|
+
|
|
138
|
+
logger.debug(f"Analysing hint: {fqn_hint_kind}")
|
|
139
|
+
|
|
140
|
+
if 'eoap:' in fqn_hint_kind:
|
|
141
|
+
hint_kind_name = fqn_hint_kind.split(':')[-1]
|
|
142
|
+
|
|
143
|
+
logger.debug(f"Mapping {fqn_hint_kind} to {AssertionHint.__name__}:")
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
hint_kind = globals()[hint_kind_name]
|
|
147
|
+
return hint_kind(parent_workflow=parent_workflow, **hint)
|
|
148
|
+
except Exception as e:
|
|
149
|
+
logger.error(f"An error occurred while mapping {fqn_hint_kind} to {AssertionHint.__name__}: {e}")
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
def extract_assertion_hints(
|
|
155
|
+
workflow: Any
|
|
156
|
+
) -> List[AssertionHint]:
|
|
157
|
+
assertion_hints = []
|
|
158
|
+
|
|
159
|
+
if workflow.hints:
|
|
160
|
+
for hint in workflow.hints:
|
|
161
|
+
if isinstance(hint, dict):
|
|
162
|
+
hint_instance = _get_assertion_hint_by_name(parent_workflow=workflow, hint=hint)
|
|
163
|
+
|
|
164
|
+
if hint_instance:
|
|
165
|
+
assertion_hints.append(hint_instance)
|
|
166
|
+
else:
|
|
167
|
+
logger.debug(f"No hints defined in current #{workflow.id.split('#')[-1]} {workflow.class_} ({workflow.cwlVersion})")
|
|
168
|
+
|
|
169
|
+
return assertion_hints
|
|
170
|
+
|
assertions_mate/cli.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# Copyright 2025 Terradue
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
from . import (
|
|
16
|
+
JSONSchemaHint,
|
|
17
|
+
RegoPolicyHint,
|
|
18
|
+
Cql2FilterHint,
|
|
19
|
+
extract_assertion_hints
|
|
20
|
+
)
|
|
21
|
+
from cwl2ogc import BaseCWLtypes2OGCConverter
|
|
22
|
+
from cwl_utils.parser import load_document_by_uri
|
|
23
|
+
from cwl_utils.parser.cwl_v1_2 import Workflow
|
|
24
|
+
from datetime import datetime
|
|
25
|
+
from loguru import logger
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import (
|
|
28
|
+
Any,
|
|
29
|
+
Mapping
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
import click
|
|
33
|
+
import yaml
|
|
34
|
+
import time
|
|
35
|
+
|
|
36
|
+
def _scan_workflow(
|
|
37
|
+
wf: Workflow,
|
|
38
|
+
inputs: Mapping[str, Any]
|
|
39
|
+
):
|
|
40
|
+
logger.info('------------------------------------------------------------------------')
|
|
41
|
+
workflow_id = wf.id.split('#')[-1]
|
|
42
|
+
logger.info(f"Validating #{workflow_id} {wf.class_} ({wf.cwlVersion}):")
|
|
43
|
+
|
|
44
|
+
validators = []
|
|
45
|
+
|
|
46
|
+
# prepare
|
|
47
|
+
for hint_instance in extract_assertion_hints(wf):
|
|
48
|
+
logger.info(f"Setting up validator for {type(hint_instance).__name__}...")
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
validators.append(hint_instance.validator())
|
|
52
|
+
except Exception as e:
|
|
53
|
+
logger.error(f"An error occurred when setting up {type(hint_instance).__name__}: {e}")
|
|
54
|
+
|
|
55
|
+
if validators:
|
|
56
|
+
logger.info("Setup is over, validating...")
|
|
57
|
+
|
|
58
|
+
for validator in validators:
|
|
59
|
+
logger.info(f" - Executing {type(validator).__name__}...")
|
|
60
|
+
|
|
61
|
+
problem_details = validator.validate_inputs(inputs)
|
|
62
|
+
if problem_details:
|
|
63
|
+
logger.error(f" {type(validator).__name__} detected violations below:")
|
|
64
|
+
|
|
65
|
+
for error_detail in problem_details.errors:
|
|
66
|
+
logger.error(f" [{error_detail.pointer}] {error_detail.detail}")
|
|
67
|
+
else:
|
|
68
|
+
logger.info(f" {type(validator).__name__} execution terminated with no violations")
|
|
69
|
+
else:
|
|
70
|
+
logger.info(f"No Validators configured in '#{workflow_id}.hints'")
|
|
71
|
+
|
|
72
|
+
@click.command()
|
|
73
|
+
@click.argument(
|
|
74
|
+
'workflow',
|
|
75
|
+
type=click.Path(
|
|
76
|
+
path_type=Path,
|
|
77
|
+
exists=True,
|
|
78
|
+
readable=True,
|
|
79
|
+
resolve_path=True
|
|
80
|
+
),
|
|
81
|
+
required=True
|
|
82
|
+
)
|
|
83
|
+
@click.option(
|
|
84
|
+
'--inputs',
|
|
85
|
+
type=click.Path(
|
|
86
|
+
path_type=Path,
|
|
87
|
+
exists=True,
|
|
88
|
+
readable=True,
|
|
89
|
+
resolve_path=True
|
|
90
|
+
),
|
|
91
|
+
required=True,
|
|
92
|
+
help="The Workflow inputs to check against the input Workflow"
|
|
93
|
+
)
|
|
94
|
+
def main(
|
|
95
|
+
workflow: Path,
|
|
96
|
+
inputs: Path
|
|
97
|
+
):
|
|
98
|
+
start_time = time.time()
|
|
99
|
+
|
|
100
|
+
logger.info(f"Loading CWL document from {workflow.absolute()}")
|
|
101
|
+
|
|
102
|
+
cwl_document = load_document_by_uri(
|
|
103
|
+
path=workflow,
|
|
104
|
+
load_all=True
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
end_time = time.time()
|
|
108
|
+
logger.info(f"{workflow.absolute()} load in {end_time - start_time:.4f} seconds")
|
|
109
|
+
|
|
110
|
+
logger.info(f"Loading inputs from {inputs.absolute()}")
|
|
111
|
+
|
|
112
|
+
with inputs.open() as input_stream:
|
|
113
|
+
inputs_mapping = yaml.safe_load(input_stream)
|
|
114
|
+
|
|
115
|
+
if isinstance(cwl_document, list):
|
|
116
|
+
for wf in cwl_document:
|
|
117
|
+
_scan_workflow(wf, inputs_mapping)
|
|
118
|
+
else:
|
|
119
|
+
_scan_workflow(cwl_document, inputs_mapping)
|
|
120
|
+
|
|
121
|
+
end_time = time.time()
|
|
122
|
+
|
|
123
|
+
logger.info('------------------------------------------------------------------------')
|
|
124
|
+
logger.info('VALIDATION COMPLETE')
|
|
125
|
+
logger.info('------------------------------------------------------------------------')
|
|
126
|
+
|
|
127
|
+
logger.info(f"Total time: {end_time - start_time:.4f} seconds")
|
|
128
|
+
logger.info(f"Finished at: {datetime.fromtimestamp(end_time).isoformat(timespec='milliseconds')}")
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# Copyright 2025 Terradue
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
from . import (
|
|
16
|
+
BaseValidator,
|
|
17
|
+
Cql2Query
|
|
18
|
+
)
|
|
19
|
+
from .error_models import (
|
|
20
|
+
BusinessRuleViolation,
|
|
21
|
+
ErrorDetail,
|
|
22
|
+
ProblemDetails,
|
|
23
|
+
ServerError
|
|
24
|
+
)
|
|
25
|
+
from pygeofilter.backends.native.evaluate import NativeEvaluator
|
|
26
|
+
from pygeofilter.parsers.cql2_text import parse as parse_cql2_text
|
|
27
|
+
from pygeofilter.parsers.cql2_json import parse as parse_cql2_json
|
|
28
|
+
from shapely import geometry
|
|
29
|
+
from typing import (
|
|
30
|
+
Any,
|
|
31
|
+
List,
|
|
32
|
+
Mapping,
|
|
33
|
+
Union
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
def ensure_bbox(input: Union[Mapping[str, Any], List[float], str]):
|
|
37
|
+
value = []
|
|
38
|
+
|
|
39
|
+
if isinstance(input, dict):
|
|
40
|
+
value = input['bbox']
|
|
41
|
+
if not value:
|
|
42
|
+
raise ValueError(f"Input {input} doesn't have a 'bbox' property")
|
|
43
|
+
elif isinstance(input, str):
|
|
44
|
+
value = [float(x) for x in str(input).split(',')]
|
|
45
|
+
else:
|
|
46
|
+
value = input
|
|
47
|
+
|
|
48
|
+
return geometry.box(*value)
|
|
49
|
+
|
|
50
|
+
class Cql2Validator(BaseValidator):
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
queries: List[Cql2Query]
|
|
55
|
+
):
|
|
56
|
+
self.evaluator = NativeEvaluator(
|
|
57
|
+
function_map={
|
|
58
|
+
'ensure_bbox': ensure_bbox
|
|
59
|
+
},
|
|
60
|
+
use_getattr=False
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
self.queries = queries
|
|
64
|
+
|
|
65
|
+
def validate_inputs(
|
|
66
|
+
self,
|
|
67
|
+
data: Mapping[str, Any]
|
|
68
|
+
) -> ProblemDetails | None:
|
|
69
|
+
errors_list = []
|
|
70
|
+
|
|
71
|
+
for filter in self.queries:
|
|
72
|
+
ast = None
|
|
73
|
+
|
|
74
|
+
if isinstance(filter.cql2, str):
|
|
75
|
+
try:
|
|
76
|
+
ast = parse_cql2_text(filter.cql2)
|
|
77
|
+
except Exception as e:
|
|
78
|
+
errors_list.append(
|
|
79
|
+
ErrorDetail(
|
|
80
|
+
pointer=filter.id,
|
|
81
|
+
detail=f"Filter does not look like a valid CQL2 Text encoded sentece: {e}"
|
|
82
|
+
)
|
|
83
|
+
)
|
|
84
|
+
elif isinstance(filter.cql2, dict):
|
|
85
|
+
try:
|
|
86
|
+
ast = parse_cql2_json(filter.cql2)
|
|
87
|
+
except Exception as e:
|
|
88
|
+
errors_list.append(
|
|
89
|
+
ErrorDetail(
|
|
90
|
+
pointer=filter.id,
|
|
91
|
+
detail=f"Filter does not look like a valid CQL2 JSON encoded structure: {e}"
|
|
92
|
+
)
|
|
93
|
+
)
|
|
94
|
+
else:
|
|
95
|
+
errors_list.append(
|
|
96
|
+
ErrorDetail(
|
|
97
|
+
pointer=filter.id,
|
|
98
|
+
detail=f"Filter is expressed in an unrecognizible format: {type(filter.cql2)}"
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
if ast:
|
|
103
|
+
predicate = self.evaluator.evaluate(ast)
|
|
104
|
+
|
|
105
|
+
if not predicate(data):
|
|
106
|
+
errors_list.append(
|
|
107
|
+
ErrorDetail(
|
|
108
|
+
pointer=filter.id,
|
|
109
|
+
detail=filter.message
|
|
110
|
+
)
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
if errors_list:
|
|
114
|
+
return BusinessRuleViolation(errors=errors_list)
|
|
115
|
+
|
|
116
|
+
return None
|