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.
@@ -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