nadap 1.0.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.
nadap/__init__.py ADDED
@@ -0,0 +1,14 @@
1
+ """
2
+ Central import for types
3
+ """
4
+
5
+ from nadap.nadap import Nadap
6
+ from nadap.errors import SchemaDefinitionError, DataValidationError
7
+ from nadap.schema import Schema
8
+ from nadap.references import ReferenceFinding
9
+ from nadap.results import ValidationFinding
10
+ from nadap.base import SET_DEFAULTS, CONVERT_DATA, NOFLAG
11
+
12
+
13
+ __version__ = "1.0.0"
14
+ version_info = (1, 0, 0)
nadap/base.py ADDED
@@ -0,0 +1,222 @@
1
+ """
2
+ Basic constants and functions
3
+ """
4
+
5
+ # pylint: disable=too-few-public-methods
6
+
7
+ import enum
8
+ import copy
9
+ import re
10
+
11
+ if hasattr(re, "NOFLAG"):
12
+ NOFLAG = re.NOFLAG
13
+ else:
14
+ NOFLAG = 0
15
+
16
+
17
+ class OPT(enum.IntFlag): # pylint: disable=no-member
18
+ """
19
+ Flags used to define a reference
20
+ """
21
+
22
+ NONE = 1
23
+ UNIQUE = 2
24
+ UNIQUE_GLOBAL = 4
25
+ PRODUCER = 8
26
+ PRODUCER_GLOBAL = 16
27
+ CONSUMER = 32
28
+ CONSUMER_GLOBAL = 64
29
+ ALLOW_ORPHAN_PRODUCER = 128
30
+
31
+
32
+ INIT_OPTIONS = OPT.UNIQUE | OPT.UNIQUE_GLOBAL | OPT.PRODUCER | OPT.PRODUCER_GLOBAL
33
+
34
+
35
+ class RegexObject:
36
+ """
37
+ Represents a regex allowed or not allowed value
38
+ """
39
+
40
+ def __init__(self, pattern: str, multiline: bool, fullmatch: bool):
41
+ self.multiline = multiline
42
+ self.fullmatch = fullmatch
43
+ self.pattern = re.compile(pattern, self.flags)
44
+
45
+ @property
46
+ def flags(self) -> list:
47
+ """
48
+ Create a list of re args to pass to match
49
+ """
50
+ if self.multiline:
51
+ return re.MULTILINE
52
+ return NOFLAG
53
+
54
+ def match(self, data: any) -> re.Match:
55
+ """
56
+ Test data if it matches
57
+ Returns:
58
+ re.Match object or None
59
+ """
60
+ if self.fullmatch:
61
+ return self.pattern.fullmatch(data)
62
+ return self.pattern.search(data)
63
+
64
+ def __hash__(self):
65
+ return self.pattern.pattern.__hash__()
66
+
67
+
68
+ class PreProcessingFlag(enum.IntFlag): # pylint: disable=no-member
69
+ """
70
+ Flags used as pre-processing options
71
+ """
72
+
73
+ NOFLAG = 0
74
+ SET_DEFAULTS = 1
75
+ CONVERT_DATA = 2
76
+
77
+
78
+ class ValEnv:
79
+ """
80
+ Contains information and references required during
81
+ validation and pre-processing
82
+ """
83
+
84
+ def __init__(
85
+ self,
86
+ references,
87
+ findings,
88
+ flags: PreProcessingFlag = PreProcessingFlag.NOFLAG,
89
+ ):
90
+ self.references = references
91
+ self.findings = findings
92
+ self.flags = flags
93
+
94
+
95
+ NOFLAG = PreProcessingFlag.NOFLAG
96
+ SET_DEFAULTS = PreProcessingFlag.SET_DEFAULTS
97
+ CONVERT_DATA = PreProcessingFlag.CONVERT_DATA
98
+ UNDEFINED = object()
99
+
100
+
101
+ def number_to_str_number(x: any) -> any:
102
+ """
103
+ Test if x is a str and represents an integer.
104
+ If yes return a string 'x', else return x
105
+ """
106
+ if isinstance(x, str):
107
+ try:
108
+ int(x)
109
+ x = f"'{x}'"
110
+ except ValueError:
111
+ try:
112
+ float(x)
113
+ x = f"'{x}'"
114
+ except ValueError:
115
+ pass
116
+ return x
117
+
118
+
119
+ def merge_lists(left: list, right: list, list_merge: str = "append_rp") -> list:
120
+ """
121
+ Merge elements of list 'right' into list 'left'
122
+ """
123
+ if not isinstance(left, list):
124
+ raise ValueError("'left' is not a list")
125
+ if not isinstance(right, list):
126
+ raise ValueError("'right' is not a list")
127
+ _left = copy.deepcopy(left)
128
+ if list_merge == "replace":
129
+ return copy.deepcopy(right)
130
+ if list_merge == "append":
131
+ return _left + copy.deepcopy(right)
132
+ if list_merge == "prepend":
133
+ return copy.deepcopy(right) + _left
134
+ if list_merge in ["append_rp", "prepend_rp"]:
135
+ append = list_merge == "append_rp"
136
+ for element in right:
137
+ if element not in _left:
138
+ if append:
139
+ _left.append(copy.deepcopy(element))
140
+ else:
141
+ _left.insert(0, copy.deepcopy(element))
142
+ return _left
143
+ raise ValueError(f"'list_merge' option {list_merge} is not known")
144
+
145
+
146
+ def merge_dictionaries(
147
+ left: dict, right: dict, recursive: bool = True, list_merge: str = "append_rp"
148
+ ) -> dict:
149
+ """
150
+ Merge keys (recursively) from dictionary 'right' into dictionary 'left'.
151
+ """
152
+ if not isinstance(left, dict):
153
+ raise ValueError("'left' is not a dictionary")
154
+ if not isinstance(right, dict):
155
+ raise ValueError("'right' is not a dictionary")
156
+ _left = copy.deepcopy(left)
157
+ for key, r_value in right.items():
158
+ if key not in _left or not isinstance(r_value, type(_left[key])):
159
+ # right element not in left or mismatching types => Just copy right to left
160
+ _left[key] = copy.deepcopy(r_value)
161
+ else:
162
+ # left value is same type as right value
163
+ if isinstance(r_value, list):
164
+ # Merge left list with right list
165
+ _left[key] = merge_lists(
166
+ left=_left[key], right=right[key], list_merge=list_merge
167
+ )
168
+ elif isinstance(r_value, dict):
169
+ if recursive:
170
+ # Merge left dict with right dict
171
+ _left[key] = merge_dictionaries(
172
+ left=_left[key],
173
+ right=right[key],
174
+ recursive=recursive,
175
+ list_merge=list_merge,
176
+ )
177
+ else:
178
+ # Overwrite left with right value
179
+ _left[key] = copy.deepcopy(r_value)
180
+ else:
181
+ # Something like integer or string and just copy right to left
182
+ _left[key] = copy.deepcopy(r_value)
183
+ return _left
184
+
185
+
186
+ def merge_template_data(
187
+ template_data: dict,
188
+ additional_data: dict,
189
+ recursive: bool = True,
190
+ list_merge: str = "append_rp",
191
+ ) -> dict:
192
+ """
193
+ Merge definition from 'additional' template into template.
194
+ """
195
+ template_data = template_data.copy()
196
+ template_data_keys = set(list(template_data.keys()))
197
+ additional_data_keys = set(list(additional_data.keys()))
198
+ return_dict = template_data
199
+ for key in template_data_keys.intersection(additional_data_keys):
200
+ if isinstance(additional_data[key], list):
201
+ if isinstance(template_data[key], list):
202
+ return_dict[key] = merge_lists(
203
+ left=template_data[key],
204
+ right=additional_data[key],
205
+ list_merge=list_merge,
206
+ )
207
+ else:
208
+ return_dict[key] = additional_data[key]
209
+ elif not isinstance(additional_data[key], dict) or not isinstance(
210
+ template_data[key], dict
211
+ ):
212
+ return_dict[key] = additional_data[key]
213
+ else:
214
+ return_dict[key] = merge_dictionaries(
215
+ left=template_data[key],
216
+ right=additional_data[key],
217
+ recursive=recursive,
218
+ list_merge=list_merge,
219
+ )
220
+ for key in additional_data_keys - template_data_keys:
221
+ return_dict[key] = additional_data[key]
222
+ return return_dict
nadap/errors.py ADDED
@@ -0,0 +1,28 @@
1
+ """
2
+ Execption classes
3
+ """
4
+
5
+ # pylint: disable=too-few-public-methods
6
+
7
+
8
+ class SchemaDefinitionError(Exception):
9
+ """
10
+ Error while parsing schema definition.
11
+ """
12
+
13
+ def __init__(self, msg: str, path: str = None):
14
+ if path is not None:
15
+ msg = f"{path}: {msg}"
16
+ super().__init__(msg)
17
+
18
+
19
+ class DataValidationError(Exception):
20
+ """
21
+ Exception indicating that data validation failed.
22
+ """
23
+
24
+
25
+ class NadapReferenceError(Exception):
26
+ """
27
+ Class to identify an error during reference processing
28
+ """
@@ -0,0 +1,88 @@
1
+ """
2
+ Accuracy Mixin
3
+ """
4
+
5
+ # pylint: disable=too-few-public-methods
6
+
7
+ import nadap.schema
8
+ from nadap.base import ValEnv
9
+ from nadap.errors import SchemaDefinitionError, DataValidationError
10
+
11
+
12
+ class AccuracyMixin:
13
+ """
14
+ Add options for maximum_decimals
15
+ """
16
+
17
+ def __init__(self, **kwargs):
18
+ self._maximum_decimals = None
19
+ self._round_to = None
20
+ super().__init__(**kwargs)
21
+
22
+ def _pop_options(self, definition: dict, schema_path: str):
23
+ self._maximum_decimals = definition.pop("maximum_decimals", None)
24
+ self._round_to = definition.pop("round_to_decimals", None)
25
+ super()._pop_options(definition=definition, schema_path=schema_path)
26
+
27
+ def _validate_options(self, schema_path: str):
28
+ super()._validate_options(schema_path=schema_path)
29
+ if self._maximum_decimals is not None:
30
+ _path = f"{schema_path}.maximum_decimals"
31
+ nadap.schema.is_int(self._maximum_decimals, _path)
32
+ if not 0 <= self._maximum_decimals <= 17:
33
+ raise SchemaDefinitionError("Must be >= 0 and <= 17", _path)
34
+ if self._round_to is not None:
35
+ _path = f"{schema_path}.round_to_decimals"
36
+ nadap.schema.is_int(self._round_to, _path)
37
+ if not 0 <= self._round_to <= 17:
38
+ raise SchemaDefinitionError("Must be >= 0 and <= 17", _path)
39
+
40
+ def _validate_data(
41
+ self,
42
+ data: any,
43
+ path: str,
44
+ env: "ValEnv" = None,
45
+ ) -> "any":
46
+ data = super()._validate_data(data=data, path=path, env=env)
47
+ if self._maximum_decimals is not None and data != round(
48
+ data, self._maximum_decimals
49
+ ):
50
+ env.findings.append(
51
+ nadap.results.ValidationFinding(
52
+ "Float value has too many decimals",
53
+ path,
54
+ )
55
+ )
56
+ raise DataValidationError()
57
+ return data
58
+
59
+ def _convert_data(self, data: any) -> any:
60
+ if self._round_to is not None:
61
+ data = round(data, self._round_to)
62
+ return super()._convert_data(data)
63
+
64
+ @property
65
+ def restrictions(self) -> "list[str]":
66
+ """
67
+ Get all restrictions for valid data
68
+ """
69
+ ret_list = super().restrictions
70
+ if self._maximum_decimals is not None:
71
+ ret_list.append(f"max allowed decimals: {self._maximum_decimals}")
72
+ return ret_list
73
+
74
+ @classmethod
75
+ def _doc_options_md_upper_part(cls) -> list[str]:
76
+ return super()._doc_options_md_upper_part() + [
77
+ "| **maximum_decimals** | `int` | | Must be >= 0 and <= 17 | "
78
+ + "Value's accuracy must be lower or equal |",
79
+ "| **round_to_decimals** | `int` | | Must be >= 0 and <= 17 | "
80
+ + "Convert value to this accuracy |",
81
+ ]
82
+
83
+ @classmethod
84
+ def _doc_options_yaml_upper_part(cls) -> list[str]:
85
+ return super()._doc_options_yaml_upper_part() + [
86
+ "maximum_decimals: <int>",
87
+ "round_to_decimals: <int>",
88
+ ]
@@ -0,0 +1,79 @@
1
+ """
2
+ AllowedValueMixin
3
+ """
4
+
5
+ # pylint: disable=too-few-public-methods
6
+
7
+ import nadap.schema
8
+ from nadap.base import ValEnv, number_to_str_number
9
+
10
+
11
+ class AllowedValueMixin:
12
+ """
13
+ Add allowed value tests for data
14
+ """
15
+
16
+ def __init__(self, **kwargs):
17
+ self.allowed_values = None
18
+ super().__init__(**kwargs)
19
+
20
+ def _validate_data(
21
+ self,
22
+ data: any,
23
+ path: str,
24
+ env: "ValEnv" = None,
25
+ ) -> "any":
26
+ """
27
+ Check if data matches allowed values
28
+ """
29
+ data = super()._validate_data(data=data, path=path, env=env)
30
+ if self.allowed_values and data not in self.allowed_values:
31
+ self._create_finding_with_error(
32
+ msg="Data not within allowed values",
33
+ path=path,
34
+ env=env,
35
+ )
36
+ return data
37
+
38
+ def _validate_options(self, schema_path: str):
39
+ super()._validate_options(schema_path=schema_path)
40
+ if self.allowed_values is not None:
41
+ nadap.schema.is_non_empty_list(
42
+ self.allowed_values, f"{schema_path}.allowed_values"
43
+ )
44
+ for index, v in enumerate(self.allowed_values):
45
+ self._test_data_type(
46
+ data=v, path=f"{schema_path}.allowed_values[{index}]"
47
+ )
48
+
49
+ def _pop_options(self, definition: dict, schema_path: str):
50
+ self.allowed_values = definition.pop("allowed_values", None)
51
+ super()._pop_options(definition=definition, schema_path=schema_path)
52
+
53
+ @property
54
+ def restrictions(self) -> "list[str]":
55
+ """
56
+ Get all restrictions for valid data
57
+ """
58
+ ret_list = super().restrictions
59
+ if self.allowed_values:
60
+ ret_list.append("allowed values:")
61
+ ret_list.extend(
62
+ [f" - {str(number_to_str_number(x))}" for x in self.allowed_values]
63
+ )
64
+ return ret_list
65
+
66
+ @classmethod
67
+ def _doc_options_md_upper_part(cls) -> list[str]:
68
+ return super()._doc_options_md_upper_part() + [
69
+ "| **allowed_values** | `list` | | | "
70
+ + " Data must match one of these values |",
71
+ "| &nbsp;&nbsp;- <value> | `any` | | Must match data type's type(s) | |",
72
+ ]
73
+
74
+ @classmethod
75
+ def _doc_options_yaml_upper_part(cls) -> list[str]:
76
+ return super()._doc_options_yaml_upper_part() + [
77
+ "allowed_values:",
78
+ " - <any>",
79
+ ]
nadap/mixin/ip.py ADDED
@@ -0,0 +1,177 @@
1
+ """
2
+ Mixin for IP data types
3
+ """
4
+
5
+ # pylint: disable=too-few-public-methods
6
+
7
+ import ipaddress
8
+
9
+ from nadap.base import ValEnv
10
+ from nadap.errors import SchemaDefinitionError
11
+ import nadap.mixin.allowed_value
12
+ import nadap.mixin.not_allowed_value
13
+
14
+
15
+ class IpMixin(
16
+ nadap.mixin.not_allowed_value.NotAllowedValueMixin,
17
+ nadap.mixin.allowed_value.AllowedValueMixin,
18
+ ):
19
+ """
20
+ Add handling of strings representing IPv4 or IPv6 string
21
+ """
22
+
23
+ _cls_python_classes = [str]
24
+ _doc_data_type = "str"
25
+ _ip_data_type_name = "IP"
26
+
27
+ def __init__(self, **kwargs):
28
+ self._test_loopback = None
29
+ self._test_link_local = None
30
+ self._test_multicast = None
31
+ self._test_private = None
32
+ self._test_public = None
33
+ super().__init__(**kwargs)
34
+
35
+ def _get_ip_obj(self, data):
36
+ return ipaddress.ip_address(data)
37
+
38
+ def _test_data_type(self, data: any, path: str, env: "ValEnv" = None) -> "any":
39
+ # pylint: disable=raise-missing-from
40
+ data = super()._test_data_type(data=data, path=path, env=env)
41
+ try:
42
+ self._get_ip_obj(data)
43
+ except ValueError:
44
+ msg = f"String does not represent an {self._ip_data_type_name}"
45
+ if env is None:
46
+ raise SchemaDefinitionError(
47
+ msg=msg,
48
+ path=path,
49
+ )
50
+ self._create_finding_with_error(msg=msg, path=path, env=env)
51
+ return data
52
+
53
+ def _pop_options(self, definition: dict, schema_path: str):
54
+ self._test_loopback = definition.pop("loopback", None)
55
+ self._test_link_local = definition.pop("link_local", None)
56
+ self._test_private = definition.pop("private", None)
57
+ self._test_public = definition.pop("public", None)
58
+ self._test_multicast = definition.pop("multicast", None)
59
+ super()._pop_options(definition=definition, schema_path=schema_path)
60
+
61
+ def _validate_options(self, schema_path: str):
62
+ super()._validate_options(schema_path=schema_path)
63
+ if (
64
+ self._test_link_local is not None
65
+ and self._test_loopback is not None
66
+ and self._test_link_local == self._test_loopback
67
+ ):
68
+ raise SchemaDefinitionError(
69
+ "Mismatching test for link_local and loopback", schema_path
70
+ )
71
+ if (
72
+ self._test_link_local is not None
73
+ and self._test_multicast is not None
74
+ and self._test_link_local == self._test_multicast
75
+ ):
76
+ raise SchemaDefinitionError(
77
+ "Mismatching test for link_local and multicast", schema_path
78
+ )
79
+ if (
80
+ self._test_loopback is not None
81
+ and self._test_multicast is not None
82
+ and self._test_loopback == self._test_multicast
83
+ ):
84
+ raise SchemaDefinitionError(
85
+ "Mismatching test for loopback and multicast", schema_path
86
+ )
87
+ if (
88
+ self._test_private is not None
89
+ and self._test_public is not None
90
+ and self._test_private == self._test_public
91
+ ):
92
+ raise SchemaDefinitionError(
93
+ "Mismatching test for private and public", schema_path
94
+ )
95
+
96
+ def _validate_data(
97
+ self,
98
+ data: any,
99
+ path: str,
100
+ env: "ValEnv" = None,
101
+ ) -> "any":
102
+ """
103
+ Check if data matches IP options
104
+ """
105
+ data = super()._validate_data(data=data, path=path, env=env)
106
+ ip_obj = self._get_ip_obj(data)
107
+ msg = None
108
+ if (
109
+ self._test_link_local is not None
110
+ and self._test_link_local != ip_obj.is_link_local
111
+ ):
112
+ msg = "Is link-local" if ip_obj.is_link_local else "Is not link-local"
113
+ elif (
114
+ self._test_loopback is not None
115
+ and self._test_loopback != ip_obj.is_loopback
116
+ ):
117
+ msg = "Is loopback" if ip_obj.is_loopback else "Is not loopback"
118
+ elif (
119
+ self._test_multicast is not None
120
+ and self._test_multicast != ip_obj.is_multicast
121
+ ):
122
+ msg = "Is multicast" if ip_obj.is_multicast else "Is not multicast"
123
+ elif self._test_private is not None and self._test_private != ip_obj.is_private:
124
+ msg = "Is private" if ip_obj.is_private else "Is not private"
125
+ elif self._test_public is not None and self._test_public != ip_obj.is_global:
126
+ msg = "Is public" if ip_obj.is_global else "Is not public"
127
+ if msg:
128
+ self._create_finding_with_error(msg=msg, path=path, env=env)
129
+
130
+ return data
131
+
132
+ @property
133
+ def restrictions(self) -> "list[str]":
134
+ """
135
+ Get all restrictions for valid data
136
+ """
137
+ ret_list = super().restrictions
138
+ opt_list = []
139
+ if self._test_link_local is not None:
140
+ opt_list.append(
141
+ f"{self._markdown_indent}- link-local: {self._test_link_local}"
142
+ )
143
+ if self._test_loopback is not None:
144
+ opt_list.append(f"{self._markdown_indent}- loopback: {self._test_loopback}")
145
+ if self._test_multicast is not None:
146
+ opt_list.append(
147
+ f"{self._markdown_indent}- multicast: {self._test_multicast}"
148
+ )
149
+ if self._test_private is not None:
150
+ opt_list.append(f"{self._markdown_indent}- private: {self._test_private}")
151
+ if self._test_public is not None:
152
+ opt_list.append(f"{self._markdown_indent}- public: {self._test_public}")
153
+ if opt_list:
154
+ ret_list.append("IP restrictions:")
155
+ ret_list.extend(opt_list)
156
+ return ret_list
157
+
158
+ @classmethod
159
+ def _doc_options_md_upper_part(cls) -> list[str]:
160
+ return super()._doc_options_md_upper_part() + [
161
+ "| **link_local** | `bool` | | | IP must be link-local |",
162
+ "| **loopback** | `bool` | | | IP must be loopback |",
163
+ "| **multicast** | `bool` | | | IP must be multicast |",
164
+ "| **private** | `bool` | | | IP must be private |",
165
+ "| **public** | `bool` | | | IP must be public/global |",
166
+ ]
167
+
168
+ @classmethod
169
+ def _doc_options_yaml_upper_part(cls) -> list[str]:
170
+ return super()._doc_options_yaml_upper_part() + [
171
+ "",
172
+ "link_local: <true|false",
173
+ "loopback: <true|false",
174
+ "multicast: <true|false",
175
+ "private: <true|false",
176
+ "public: <true|false",
177
+ ]