FourCIPP 1.50.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,138 @@
1
+ # The MIT License (MIT)
2
+ #
3
+ # Copyright (c) 2025 FourCIPP Authors
4
+ #
5
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ # of this software and associated documentation files (the "Software"), to deal
7
+ # in the Software without restriction, including without limitation the rights
8
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ # copies of the Software, and to permit persons to whom the Software is
10
+ # furnished to do so, subject to the following conditions:
11
+ #
12
+ # The above copyright notice and this permission notice shall be included in
13
+ # all copies or substantial portions of the Software.
14
+ #
15
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ # THE SOFTWARE.
22
+ """Converter to convert custom types to native Python types."""
23
+
24
+ from __future__ import annotations
25
+
26
+ from collections.abc import Callable
27
+ from typing import Any
28
+
29
+ import numpy as np
30
+
31
+
32
+ class Converter:
33
+ """Converter class to convert custom types to native Python types."""
34
+
35
+ def __init__(self) -> None:
36
+ self._custom_converters: dict = {}
37
+
38
+ def register_type(
39
+ self,
40
+ custom_type: type,
41
+ converter_function: Callable[[Converter, Any], Any],
42
+ ) -> Converter:
43
+ """Register a custom type and its converter function.
44
+
45
+ The first argument of the converter_function is a converter object.
46
+ This allows you to pass self down to your converter function to
47
+ recursively call it and use custom types you already registered.
48
+ The second argument is the object to be converted.
49
+
50
+ Args:
51
+ custom_type: Custom class to register
52
+ converter_function: Converter function
53
+ """
54
+ self._custom_converters[custom_type] = converter_function
55
+ return self
56
+
57
+ def register_types(self, types_dict: dict) -> Converter:
58
+ """Register multiple custom types and their converter functions.
59
+
60
+ Args:
61
+ types_dict: Dictionary with custom types as keys and
62
+ converter functions as values
63
+ """
64
+ self._custom_converters.update(types_dict)
65
+ return self
66
+
67
+ def register_numpy_types(self) -> Converter:
68
+ """Register NumPy types and their converter functions."""
69
+
70
+ def convert_ndarray(converter: Converter, obj: np.ndarray) -> list[Any]:
71
+ """Convert a NumPy ndarray to a list.
72
+
73
+ Args:
74
+ converter: Converter object
75
+ obj: NumPy ndarray to convert
76
+ """
77
+ return converter(obj.tolist())
78
+
79
+ def convert_generic(
80
+ converter: Converter, obj: np.generic
81
+ ) -> int | float | bool | str:
82
+ """Convert a NumPy generic type to a native Python type.
83
+
84
+ Args:
85
+ converter: Converter object
86
+ obj: NumPy generic type to convert
87
+ """
88
+ return obj.item()
89
+
90
+ self.register_type(np.generic, convert_generic)
91
+ self.register_type(np.ndarray, convert_ndarray)
92
+ return self
93
+
94
+ def __call__(self, obj: Any) -> Any:
95
+ """Convert the object to a native Python type.
96
+
97
+ Args:
98
+ obj: Object to convert
99
+ """
100
+ # If no custom converters are present, no need to do a conversion
101
+ if not self._custom_converters:
102
+ return obj
103
+
104
+ # Look if object is present in the custom converters
105
+ for custom_type in self._custom_converters:
106
+ if isinstance(obj, custom_type):
107
+ # First match will be used.
108
+ # Keep in mind for inheritance you have think about child classes!
109
+ # Make sure if you have parent and child classes registered separately, to first register the child classes!
110
+ return self._custom_converters[custom_type](self, obj)
111
+
112
+ # Lets convert
113
+ match obj:
114
+ # Convert the nested types
115
+ case list():
116
+ return [self(entry) for entry in obj]
117
+ case set():
118
+ return (self(entry) for entry in obj)
119
+ case dict():
120
+ return {k: self(v) for k, v in obj.items()}
121
+
122
+ # Nothing to do here, since these are the accepted types
123
+ case int() | float() | bool() | str() | None:
124
+ return obj
125
+
126
+ # Type was not registered and is not one of the standards one
127
+ case _:
128
+ raise TypeError(
129
+ f"Object {obj} of type {type(obj)} can not be converted"
130
+ )
131
+
132
+ def __str__(self) -> str:
133
+ """String representation of the Converter class."""
134
+ string = "Converter with custom object (objects will be converted from top to bottom):"
135
+ for k, v in self._custom_converters.items():
136
+ string += f"\n\t{k}\t: {v}"
137
+
138
+ return string
@@ -0,0 +1,407 @@
1
+ # The MIT License (MIT)
2
+ #
3
+ # Copyright (c) 2025 FourCIPP Authors
4
+ #
5
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ # of this software and associated documentation files (the "Software"), to deal
7
+ # in the Software without restriction, including without limitation the rights
8
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ # copies of the Software, and to permit persons to whom the Software is
10
+ # furnished to do so, subject to the following conditions:
11
+ #
12
+ # The above copyright notice and this permission notice shall be included in
13
+ # all copies or substantial portions of the Software.
14
+ #
15
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ # THE SOFTWARE.
22
+ """Dict utils."""
23
+
24
+ from collections.abc import Callable, Iterator, Sequence
25
+ from typing import Any
26
+
27
+ import numpy as np
28
+ from loguru import logger
29
+
30
+
31
+ def compare_nested_dicts_or_lists(
32
+ obj: Any,
33
+ reference_obj: Any,
34
+ allow_int_vs_float_comparison: bool = False,
35
+ rtol: float = 1.0e-5,
36
+ atol: float = 1.0e-8,
37
+ equal_nan: bool = False,
38
+ custom_compare: Callable | None = None,
39
+ ) -> bool:
40
+ """Recursively compare two nested dictionaries or lists.
41
+
42
+ In case objects are not within the provided tolerance an `AssertionError` is raised.
43
+
44
+ To compare custom python objects, a `custom_compare` callable can be provided which:
45
+ - Returns nothing/`None` if the objects where not compared within `custom_compare`
46
+ - Returns `True` if the objects are seen as equal
47
+ - Raises AssertionError if the objects are not equal
48
+
49
+ Args:
50
+ obj: Object for comparison
51
+ reference_obj: Reference object
52
+ allow_int_vs_float_comparison: Allow a tolerance based comparison between int and
53
+ float
54
+ rtol: The relative tolerance parameter for numpy.isclose
55
+ atol: The absolute tolerance parameter for numpy.isclose
56
+ equal_nan: Whether to compare NaN's as equal for numpy.isclose
57
+ custom_compare: Callable to compare objects within this nested framework
58
+
59
+ Returns:
60
+ True if the dictionaries are equal
61
+ """
62
+ # Compare non-standard python objects
63
+ if custom_compare is not None:
64
+ # Check if result is not None
65
+ if result := custom_compare(obj, reference_obj) is not None:
66
+ return result
67
+
68
+ # Ensures the types are the same
69
+ if not type(obj) is type(reference_obj):
70
+ if (
71
+ not allow_int_vs_float_comparison # Except floats can be ints
72
+ or not isinstance(obj, (float, int))
73
+ or not isinstance(reference_obj, (float, int))
74
+ ):
75
+ raise AssertionError(
76
+ f"Object is of type {type(obj)}, but the reference is of type {type(reference_obj)}"
77
+ )
78
+
79
+ # Objects are numerics
80
+ if isinstance(obj, (float, int)):
81
+ if not np.isclose(obj, reference_obj, rtol, atol, equal_nan):
82
+ raise AssertionError(
83
+ f"The numerics are not close:\n\nobj = {obj}\n\nreference_obj = {reference_obj}"
84
+ )
85
+ return True
86
+
87
+ # Object are dicts
88
+ if isinstance(obj, dict):
89
+ # ^ is the symmetric difference operator, i.e. union of the sets without the intersection
90
+ if missing_keys := set(obj.keys()) ^ set(reference_obj.keys()):
91
+ raise AssertionError(
92
+ f"The following keys could not be found in both dicts {missing_keys}:"
93
+ f"\nobj: {obj}\n\nreference_obj:{reference_obj}"
94
+ )
95
+ for key in obj:
96
+ compare_nested_dicts_or_lists(
97
+ obj[key],
98
+ reference_obj[key],
99
+ allow_int_vs_float_comparison,
100
+ rtol,
101
+ atol,
102
+ equal_nan,
103
+ custom_compare,
104
+ )
105
+ return True
106
+
107
+ # Objects are lists
108
+ if isinstance(obj, list):
109
+ if len(obj) != len(reference_obj):
110
+ raise AssertionError(
111
+ f"The list lengths differ (got {len(obj)} and {len(reference_obj)}).\nobj "
112
+ f"{obj}\n\nreference_obj:{reference_obj}"
113
+ )
114
+ for obj_item, reference_obj_item in zip(obj, reference_obj):
115
+ compare_nested_dicts_or_lists(
116
+ obj_item,
117
+ reference_obj_item,
118
+ allow_int_vs_float_comparison,
119
+ rtol,
120
+ atol,
121
+ equal_nan,
122
+ custom_compare,
123
+ )
124
+ return True
125
+
126
+ # Otherwise compare the objects directly
127
+ if obj != reference_obj:
128
+ raise AssertionError(
129
+ f"The objects are not equal:\n\nobj = {obj}\n\nreference_obj = {reference_obj}"
130
+ )
131
+
132
+ return True
133
+
134
+
135
+ def _get_dict(
136
+ nested_dict: dict | list, keys: Sequence, optional: bool = True
137
+ ) -> Iterator[dict]:
138
+ """Return dict entry within a nested dict by keys.
139
+
140
+ In case a list is encountered, this function yields over every entry.
141
+
142
+ Args:
143
+ nested_dict: dict to iterate. Due to recursiveness, this can also be a list
144
+ keys: List of keys to access
145
+ optional: If the entry is part of a collection that does no exist as it is optional
146
+
147
+ Yields:
148
+ Desired data
149
+ """
150
+ # Start with the original data
151
+ sub_data = nested_dict
152
+ sub_keys = list(keys)
153
+
154
+ # Get from dict
155
+ if isinstance(nested_dict, dict):
156
+ # Loop over all keys
157
+ for key in keys:
158
+ # Jump into the dict
159
+ if isinstance(sub_data, dict):
160
+ if key in sub_data:
161
+ # Jump into the entry key
162
+ sub_data = sub_data[key]
163
+ sub_keys.pop(0)
164
+ else:
165
+ # Unknown key
166
+ if optional:
167
+ logger.debug(f"Entry {keys} not found and is set as optional")
168
+ return
169
+ else:
170
+ raise KeyError(
171
+ f"Key '{key}' not found in dictionary {sub_data}."
172
+ )
173
+ else:
174
+ # Jump into the sub_data with the remaining keys
175
+ yield from _get_dict(sub_data, sub_keys)
176
+
177
+ # Exit function afterwards
178
+ return
179
+
180
+ # Check the last entry type
181
+ # dict: nothing to do
182
+ if isinstance(sub_data, dict):
183
+ yield sub_data
184
+ # List: jump in an do it all over
185
+ elif isinstance(sub_data, list):
186
+ # Last key is a list of objects
187
+ if not sub_keys:
188
+ for item in sub_data:
189
+ # Only dicts are allowed
190
+ if isinstance(item, dict):
191
+ yield item
192
+ else:
193
+ raise TypeError(f"Expected type dict, got {type(item)}")
194
+ # More nested keys
195
+ else:
196
+ for item in sub_data:
197
+ yield from _get_dict(item, sub_keys)
198
+ # Unsupported type
199
+ else:
200
+ raise TypeError(
201
+ f"The current data {sub_data} for type {type(sub_data)} for keys {keys} is neither a dict nor a list."
202
+ )
203
+
204
+ # Exit function afterwards
205
+ return
206
+
207
+
208
+ def _split_off_last_key(
209
+ nested_dict: dict,
210
+ keys: Sequence,
211
+ optional: bool = True,
212
+ yield_dict_if_missing: bool = False,
213
+ ) -> Iterator[Any]:
214
+ """Utility to return the last key and its parent entry.
215
+
216
+ Args:
217
+ nested_dict: dict to iterate. Due to recursiveness, this can also be a list
218
+ keys: List of keys to access
219
+ optional: If the entry is part of a collection that does no exist as it is optional
220
+ yield_dict_if_missing: Return parent entry even if the entry is not provided
221
+
222
+ Yields:
223
+ Parent entry
224
+ """
225
+ last_key = keys[-1]
226
+
227
+ for entry in _get_dict(nested_dict, keys[:-1], optional):
228
+ # Key has to be provided
229
+ if last_key not in entry:
230
+ if optional:
231
+ logger.debug(f"Entry {keys} not found and was set to optional.")
232
+ # Still return the dict
233
+ if yield_dict_if_missing:
234
+ yield entry, last_key
235
+ # Ignore
236
+ else:
237
+ continue
238
+ else:
239
+ raise KeyError(f"Entry {last_key} not in {entry}")
240
+
241
+ yield entry, last_key
242
+
243
+
244
+ def get_entry(
245
+ nested_dict: dict,
246
+ keys: Sequence,
247
+ optional: bool = True,
248
+ ) -> Iterator[Any]:
249
+ """Get entry by a list of keys.
250
+
251
+ Args:
252
+ nested_dict: Nested data dict
253
+ keys: List of keys to get the entry
254
+ optional: If the entry is part of a collection that does no exist as it is optional
255
+
256
+ Yields:
257
+ Entry
258
+ """
259
+ for entry, last_key in _split_off_last_key(nested_dict, keys, optional):
260
+ yield entry[last_key]
261
+
262
+
263
+ def remove(
264
+ nested_dict: dict,
265
+ keys: Sequence,
266
+ ) -> None:
267
+ """Remove entry.
268
+
269
+ Args:
270
+ nested_dict: Nested data dict
271
+ keys: List of keys to the entry
272
+ optional: If the entry is part of a collection that does no exist as it is optional
273
+ """
274
+ for entry, last_key in _split_off_last_key(nested_dict, keys):
275
+ entry.pop(last_key)
276
+
277
+
278
+ def replace_value(
279
+ nested_dict: dict,
280
+ keys: Sequence,
281
+ new_value: Any,
282
+ ) -> None:
283
+ """Replace value.
284
+
285
+ Args:
286
+ nested_dict: Nested data dict
287
+ keys: List of keys to the entry
288
+ new_value: New value to set
289
+ """
290
+ for entry, last_key in _split_off_last_key(nested_dict, keys):
291
+ logger.debug(f"Replacing {last_key}: from {entry[last_key]} to {new_value}")
292
+ entry[last_key] = new_value
293
+
294
+
295
+ def make_default_explicit(
296
+ nested_dict: dict,
297
+ keys: Sequence,
298
+ default_value: Any,
299
+ ) -> None:
300
+ """Make default explicit, i.e. set the value in the input.
301
+
302
+ Args:
303
+ nested_dict: Nested data dict
304
+ keys: List of keys to the entry
305
+ default_value: Default value to set
306
+ """
307
+ for entry, last_key in _split_off_last_key(
308
+ nested_dict, keys, yield_dict_if_missing=True
309
+ ):
310
+ if last_key not in entry:
311
+ entry[last_key] = default_value
312
+
313
+
314
+ def make_default_implicit(
315
+ nested_dict: dict,
316
+ keys: Sequence,
317
+ default_value: Any,
318
+ ) -> None:
319
+ """Make default implicit, i.e., removed it if set with the default value in
320
+ the input.
321
+
322
+ Args:
323
+ nested_dict: Nested data dict
324
+ keys: List of keys to the entry
325
+ default_value: Default value to set
326
+ """
327
+
328
+ for entry, last_key in _split_off_last_key(nested_dict, keys):
329
+ if entry[last_key] == default_value:
330
+ entry.pop(last_key)
331
+
332
+
333
+ def change_default(
334
+ nested_dict: dict,
335
+ keys: Sequence,
336
+ old_default: Any,
337
+ new_default: Any,
338
+ ) -> None:
339
+ """Change default value.
340
+
341
+ If default value is not provided the old default is set. Entries where the value equals the new default value is removed.
342
+
343
+ Args:
344
+ nested_dict: Nested data dict
345
+ keys: List of keys to the entry
346
+ old_default: Old default value to set
347
+ new_default: New default value
348
+ """
349
+ for entry, last_key in _split_off_last_key(
350
+ nested_dict, keys, yield_dict_if_missing=True
351
+ ):
352
+ # Optional entry is missing
353
+ if last_key not in entry:
354
+ entry[last_key] = old_default
355
+ # If entry is set with the new default, remove it
356
+ else:
357
+ if entry[last_key] == new_default:
358
+ entry.pop(last_key)
359
+
360
+
361
+ def rename_parameter(
362
+ nested_dict: dict,
363
+ keys: Sequence,
364
+ new_name: str,
365
+ ) -> None:
366
+ """Rename parameter.
367
+
368
+ Args:
369
+ nested_dict: Nested data dict
370
+ keys: List of keys to the entry
371
+ new_name: New name of the parameter
372
+ """
373
+
374
+ for entry, last_key in _split_off_last_key(nested_dict, keys):
375
+ entry[new_name] = entry.pop(last_key)
376
+
377
+
378
+ def sort_by_key_order(data: dict, key_order: list[str]) -> dict:
379
+ """Sort a dictionary by a specific key order.
380
+
381
+ Args:
382
+ data: Dictionary to sort.
383
+ key_order: List of keys in the desired order.
384
+
385
+ Returns:
386
+ Sorted dictionary.
387
+ """
388
+ if set(key_order) != set(data.keys()):
389
+ raise ValueError("'key_order' must include all keys in the dictionary!")
390
+
391
+ return {key: data[key] for key in key_order if key in data}
392
+
393
+
394
+ def sort_alphabetically(
395
+ data: dict,
396
+ ) -> dict:
397
+ """Sort a dictionary alphabetically.
398
+
399
+ Args:
400
+ data: Dictionary to sort.
401
+
402
+ Returns:
403
+ Sorted dictionary.
404
+ """
405
+ return sort_by_key_order(
406
+ data, sorted(data.keys(), key=lambda s: (s.lower(), 0 if s.islower() else 1))
407
+ )