edq.util.serial

  1import collections
  2import enum
  3import os
  4import types
  5import typing
  6
  7import edq.core.errors
  8
  9import edq.util.common
 10import edq.util.enum
 11import edq.util.json
 12
 13PODType = typing.Union[bool, float, int, str, typing.List['PODType'], typing.Dict[str, 'PODType'], None]  # pylint: disable=invalid-name
 14""" A "Plain Old Data" type that can be easily represented (e.g., in JSON). """
 15
 16SerializationBaseClass = typing.TypeVar('SerializationBaseClass', bound = 'SerializationBase')
 17
 18DictSerializerClass = typing.TypeVar('DictSerializerClass', bound = 'DictSerializer')
 19DictDeserializerClass = typing.TypeVar('DictDeserializerClass', bound = 'DictDeserializer')
 20DictConverterClass = typing.TypeVar('DictConverterClass', bound = 'DictConverter')
 21
 22PODSerializerClass = typing.TypeVar('PODSerializerClass', bound = 'PODSerializer')
 23PODDeserializerClass = typing.TypeVar('PODDeserializerClass', bound = 'PODDeserializer')
 24PODConverterClass = typing.TypeVar('PODConverterClass', bound = 'PODConverter')
 25
 26# Alias the context (which we don't store here because of a cyclic dependency with edq.util.json).
 27SerializationContext = edq.util.common.SerializationContext
 28
 29# For Python 3.9 compatibility, we need to dynamically check for which union types are available.
 30_UNION_TYPES: typing.Set[typing.Any] = {typing.Union}
 31if (hasattr(types, 'UnionType')):
 32    _UNION_TYPES.add(getattr(types, 'UnionType'))
 33
 34class SerializationBase:
 35    """
 36    A base class for the serialization classes.
 37
 38    Note that this causes diamond inheritance for classes that extend *Converter,
 39    but since this class only provides simple functionality not overwritten by any branch child,
 40    there should be no issues.
 41
 42    General (but inefficient) implementations of several core Python equality, comparison, and representation methods are provided.
 43    A default hash implementation is provided, but it is up to child classes themselves to ensure they are immutable
 44    if they want to be used as set elements or dict keys.
 45    """
 46
 47    serialization_omit_empty: bool = False
 48    """
 49    Do not include empty fields in serialization.
 50    An empty field meets one of the following conditions:
 51     - Has a `__len__` method which returns 0.
 52     - Has a `_serialization_is_empty` method that returns true.
 53    """
 54
 55    serialization_omit_none: bool = False
 56    """ Do not include None (null) fields in serialization. """
 57
 58    serialization_skip_fields: typing.Union[typing.Set[str], None] = None
 59    """ A list of field names to skip. """
 60
 61    serialization_include_init_context: bool = False
 62    """ Do not send the serialization context to an object's init. """
 63
 64    serialization_error_class: typing.Type[Exception] = ValueError
 65    """ The class to use when raising errors. """
 66
 67    @classmethod
 68    def skip_field(cls, name: str, value: typing.Any) -> bool:
 69        """ Check if a field should be skipped. """
 70
 71        if ((cls.serialization_skip_fields is not None) and (name in cls.serialization_skip_fields)):
 72            return True
 73
 74        if (cls.serialization_omit_none and (value is None)):
 75            return True
 76
 77        if (cls.serialization_omit_empty and hasattr(value, '__len__') and (len(value) == 0)):
 78            return True
 79
 80        if (cls.serialization_omit_empty and hasattr(value, '_serialization_is_empty') and value._serialization_is_empty()):
 81            return True
 82
 83        return False
 84
 85    def __lt__(self, other: 'SerializationBase') -> bool:
 86        return repr(self) < repr(other)
 87
 88    def __hash__(self) -> int:
 89        return hash(repr(self))
 90
 91    def __str__(self) -> str:
 92        return repr(self)
 93
 94    def __repr__(self) -> str:
 95        return edq.util.json.dumps(self)
 96
 97    def _eq(self,
 98            other: object,
 99            self_serializer: typing.Callable,
100            other_serializer: typing.Callable,
101            context: typing.Union[SerializationContext, None] = None,
102            ) -> bool:
103        """
104        Check for equality using the given func..
105        """
106
107        if (not isinstance(other, type(self))):
108            return False
109
110        return bool(self_serializer(context) == other_serializer(context))
111
112    @classmethod
113    def _from_path(cls: typing.Type[SerializationBaseClass],
114            path: str,
115            deserializer: typing.Callable,
116            context: typing.Union[SerializationContext, None] = None,
117            ) -> SerializationBaseClass:
118        """
119        The internal helper for from_path().
120        """
121
122        path = os.path.abspath(path)
123
124        if (context is None):
125            context = SerializationContext()
126        else:
127            context = context.copy()
128
129        context.base_dir = os.path.dirname(path)
130        context.source_path = path
131
132        data = edq.util.json.load_path(path, **context.json_options)
133
134        return deserializer(data, context)  # type: ignore[no-any-return]
135
136    def _to_path(self,
137            path: str,
138            serializer: typing.Callable,
139            context: typing.Union[SerializationContext, None] = None,
140            ) -> None:
141        """ Write this object to the given path as JSON. """
142
143        if (context is None):
144            context = SerializationContext()
145        else:
146            context = context.copy()
147
148        if ((not os.path.isabs(path)) and (context.base_dir is not None)):
149            path = os.path.join(context.base_dir, path)
150
151        context.source_path = os.path.abspath(path)
152        context.base_dir = os.path.dirname(context.source_path)
153
154        data = serializer(context)
155        edq.util.json.dump_path(data, context.source_path, **context.json_options)
156
157class PODSerializer(SerializationBase):
158    """
159    A class that can represent itself as a POD type.
160    Sibling to PODDeserializer.
161    """
162
163    def to_pod(self,
164            context: typing.Union[SerializationContext, None] = None,
165            ) -> PODType:
166        """
167        Get a POD representation of this object.
168
169        The default implementation will convert to a dict (similar to a DictSerializer).
170        """
171
172        if (context is None):
173            context = SerializationContext()
174
175        data: typing.Dict[str, typing.Any] = {}
176
177        for (key, value) in vars(self).items():
178            if (self.skip_field(key, value)):
179                continue
180
181            data[key] = generic_to_pod(value, context, self.serialization_error_class)
182
183        return data
184
185    def to_path(self,
186            path: str,
187            context: typing.Union[SerializationContext, None] = None,
188            ) -> None:
189        """ Write this object to the given path as JSON. """
190
191        self._to_path(path, self.to_pod, context)
192
193    def __eq__(self, other: object) -> bool:
194        """
195        Check for equality.
196
197        This check uses to_pod() and compares the results.
198        This may not be complete or efficient depending on the child class.
199        """
200
201        return self._eq(other, self.to_pod, getattr(other, 'to_pod', None))  # type: ignore[arg-type]
202
203class PODDeserializer(SerializationBase):
204    """
205    A class that can construct itself from a POD type.
206    Sibling to PODSerializer.
207    """
208
209    @classmethod
210    def prep_init_data(cls,
211            data: typing.Dict[str, typing.Any],
212            context: typing.Union[SerializationContext, None] = None,
213            ) -> typing.Dict[str, typing.Any]:
214        """
215        Prepare data to be passed into this class' constructor.
216
217        By default, this is called by from_pod().
218        A child can override this or prep_init_data() depending on the functionality they want.
219
220        A general (but inefficient) implementation is provided by default.
221        This implementation will attempt to use type hints (of the classes constructor) to convert enums and DictDeserializers.
222        """
223
224        if (context is None):
225            context = SerializationContext()
226
227        new_data = {}
228        constructor_types = typing.get_type_hints(cls.__init__)
229
230        for (key, value) in data.items():
231            if (cls.skip_field(key, value)):
232                continue
233
234            new_data[key] = _from_pod(f"field {key}", constructor_types.get(key, None), value, context, cls.serialization_error_class)
235
236        return new_data
237
238    @classmethod
239    def from_pod(cls: typing.Type[PODDeserializerClass],
240            data: PODType,
241            context: typing.Union[SerializationContext, None] = None,
242            ) -> PODDeserializerClass:
243        """
244        Create an instance of this class from a POD.
245
246        The default implementation will call the class' constructor with one of two things:
247        a splat/unpacking (**) of the incoming data if the data is a dict,
248        otherwise the data itself.
249        """
250
251        if (context is None):
252            context = SerializationContext()
253
254        if (isinstance(data, dict)):
255            new_data = cls.prep_init_data(data, context)
256
257            if (cls.serialization_include_init_context):
258                new_data['context'] = context
259
260            return cls(**new_data)
261
262        return cls(data)  # type: ignore[call-arg]
263
264    @classmethod
265    def from_path(cls: typing.Type[PODDeserializerClass],
266            path: str,
267            context: typing.Union[SerializationContext, None] = None,
268            ) -> PODDeserializerClass:
269        """
270        Read the path (as JSON) and call from_pod().
271
272        If a serialization context is passed in to this function,
273        a copy will be made with the new base dir and source path.
274        """
275
276        return cls._from_path(path, cls.from_pod, context)
277
278class PODConverter(PODSerializer, PODDeserializer):
279    """ A PODSerializer and PODDeserializer. """
280
281    def copy(self,
282            context: typing.Union[SerializationContext, None] = None,
283            ) -> 'PODConverter':
284        """
285        Make a deep copy of this object.
286        The default implementation will use to_pod() and from_pod() to make a copy.
287
288        Callers should be cautious of fileds that are skipped in serialization,
289        e.g., via `SerializationBase.serialization_skip_fields`.
290        """
291
292        if (context is None):
293            context = SerializationContext()
294
295        return self.from_pod(self.to_pod(context), context)
296
297class DictSerializer(PODSerializer):
298    """
299    A base class for class that can represent itself as a dict.
300    The intention is that the dict can then be cleanly converted to/from JSON.
301    """
302
303    def to_dict(self,
304            context: typing.Union[SerializationContext, None] = None,
305            ) -> typing.Dict[str, PODType]:
306        """
307        Return a dict that can be used to represent this object.
308        If the dict is passed to from_dict(), an identical object should be reconstructed.
309
310        A general (but inefficient) implementation is provided by default.
311        """
312
313        data = self.to_pod(context)
314        if (not isinstance(data, dict)):
315            raise self.serialization_error_class(f"DictSerializer's to_pod() did not return a dict, found '{type(data)}'.")
316
317        return data
318
319    def to_path(self,
320            path: str,
321            context: typing.Union[SerializationContext, None] = None,
322            ) -> None:
323        """ Write this object to the given path as JSON. """
324
325        self._to_path(path, self.to_dict, context)
326
327    def __eq__(self, other: object) -> bool:
328        """
329        Check for equality.
330
331        This check uses to_pod() and compares the results.
332        This may not be complete or efficient depending on the child class.
333        """
334
335        return self._eq(other, self.to_dict, getattr(other, 'to_dict', None))  # type: ignore[arg-type]
336
337class DictDeserializer(PODDeserializer):
338    """
339    A base class for class that can reconstruct (deserialize) themselves from a dict.
340    The intention is that the class can be cleanly converted from JSON.
341
342    Any class that uses the default implementations should have a constructor
343    that accepts arguments with the same name as their members.
344    It is generally recommended to also have the constructor accept a kwargs,
345    since values will be blindly passed to the constructor.
346    """
347
348    @classmethod
349    def from_dict(cls: typing.Type[DictDeserializerClass],
350            data: typing.Dict[str, PODType],
351            context: typing.Union[SerializationContext, None] = None,
352            ) -> DictDeserializerClass:
353        """
354        Return an instance of this subclass created using the given dict.
355        If the dict came from to_dict(), the returned object should be equivalent to the original.
356
357        By default, this function just calls the class' constructor with the output of prep_init_data().
358        A child can override this or prep_init_data() depending on the functionality they want.
359
360        A general (but inefficient) implementation is provided by default.
361        This implementation will attempt to use type hints (of the classes constructor) to convert enums and DictDeserializers.
362        """
363
364        return cls.from_pod(data, context)
365
366    @classmethod
367    def from_path(cls: typing.Type[DictDeserializerClass],
368            path: str,
369            context: typing.Union[SerializationContext, None] = None,
370            ) -> DictDeserializerClass:
371        """
372        Read the path (as JSON) and call from_dict().
373
374        If a serialization context is passed in to this function,
375        a copy will be made with the new base dir and source path.
376        """
377
378        return cls._from_path(path, cls.from_dict, context)
379
380class DictConverter(PODConverter, DictSerializer, DictDeserializer):
381    """ A DictSerializer and DictDeserializer. """
382
383def _check_issubclass(allowed_type: typing.Any, target: typing.Type) -> bool:
384    """
385    Call issubclass(), but squash and type errors.
386    issubclass() can be picky about the input types is accepts.
387    """
388
389    if (allowed_type is None):
390        return False
391
392    try:
393        return issubclass(allowed_type, target)
394    except TypeError:
395        return False
396
397def generic_to_pod(
398        raw_value: typing.Any,
399        context: SerializationContext,
400        serialization_error_class: typing.Type[Exception] = ValueError,
401        ) -> PODType:
402    """
403    Attempt to convert a value to a POD type.
404
405    Has special handling for:
406        - DictSerializer
407        - PODSerializer
408        - enum.Enum
409        - (list, tuple, set)
410        - dict
411    """
412
413    if (raw_value is None):
414        return None
415
416    # Simple types that are already POD.
417    if (isinstance(raw_value, (bool, float, int, str))):
418        return raw_value
419
420    if (isinstance(raw_value, DictSerializer)):
421        return raw_value.to_dict(context)
422
423    if (isinstance(raw_value, PODSerializer)):
424        return raw_value.to_pod(context)
425
426    if (isinstance(raw_value, enum.Enum)):
427        return generic_to_pod(raw_value.value, context, serialization_error_class)
428
429    if (isinstance(raw_value, (list, tuple, set))):
430        items = [generic_to_pod(item, context, serialization_error_class) for item in raw_value]
431
432        # Sort sets for consistency.
433        if (isinstance(raw_value, set)):
434            # Sort using the string representation of the items (since the set could be heterogeneous and have non-comparable items).
435            sort_list = sorted([(item, i) for (i, item) in enumerate(map(str, items))])
436            items = [items[i] for (_, i) in sort_list]
437
438        return items
439
440    if (isinstance(raw_value, dict)):
441        return {key: generic_to_pod(value, context, serialization_error_class) for (key, value) in raw_value.items()}
442
443    raise serialization_error_class(f"Unable to convert value to simple (edq.util.serial.POD) type: '{raw_value}' (type: '{type(raw_value)}').")
444
445def _from_pod(
446        label: str,
447        type_hint: typing.Any,
448        raw_value: typing.Any,
449        context: SerializationContext,
450        serialization_error_class: typing.Type[Exception] = ValueError,
451        ) -> typing.Any:
452    """ Attempt to convert a value to the hinted value. """
453
454    try:
455        return _from_pod_internal(label, type_hint, raw_value, context, serialization_error_class)
456    except Exception as ex:
457        raise serialization_error_class(f"Failed to deserialize {label}.") from ex
458
459def _from_pod_internal(
460        label: str,
461        type_hint: typing.Any,
462        raw_value: typing.Any,
463        context: SerializationContext,
464        serialization_error_class: typing.Type[Exception] = ValueError,
465        ) -> typing.Any:
466    """ Attempt to convert a value to the hinted value. """
467
468    # If there is no type hint or anything is allowed, then just return the raw value.
469    if ((type_hint is None) or (type_hint is typing.Any)):
470        return raw_value
471
472    allowed_types: typing.Tuple[typing.Any, ...] = tuple([type_hint])
473
474    # Check if the hint is a union.
475    if (typing.get_origin(type_hint) in _UNION_TYPES):
476        allowed_types = typing.get_args(type_hint)
477
478    if (len(allowed_types) == 0):
479        return raw_value
480
481    # Check for a None early.
482    if ((raw_value is None) and (type(None) in allowed_types)):
483        return None
484
485    # Check each possible type and match the first one.
486    for allowed_type in allowed_types:
487        if (_check_issubclass(allowed_type, DictDeserializer) and isinstance(raw_value, dict)):
488            return allowed_type.from_dict(raw_value, context)
489
490        if (_check_issubclass(allowed_type, PODDeserializer)):
491            return allowed_type.from_pod(raw_value, context)
492
493        if (_check_issubclass(allowed_type, enum.Enum)):
494            if (edq.util.enum.has_value(allowed_type, raw_value)):
495                return allowed_type(raw_value)
496
497        # Sequence container types.
498        if ((typing.get_origin(allowed_type) in (list, tuple, set, collections.abc.Sequence)) and isinstance(raw_value, (list, tuple, set))):
499            collection_type = typing.get_origin(allowed_type)
500            if (collection_type is collections.abc.Sequence):
501                collection_type = list
502
503            item_type = None
504            args = typing.get_args(allowed_type)
505            if (len(args) > 0):
506                item_type = args[0]
507
508            return collection_type([
509                _from_pod(f"{label}[{i}]", item_type, item, context, serialization_error_class)
510                for (i, item)
511                in enumerate(raw_value)
512            ])
513
514        # Dict
515        if ((typing.get_origin(allowed_type) in (dict, collections.abc.Mapping)) and isinstance(raw_value, dict)):
516            key_type = None
517            value_type = None
518
519            args = typing.get_args(allowed_type)
520            if (len(args) == 2):
521                key_type = args[0]
522                value_type = args[1]
523
524            return {
525                _from_pod(f"{label}.({key} (key))", key_type, key, context, serialization_error_class):
526                _from_pod(f"{label}.{key}", value_type, value, context, serialization_error_class)
527                for (key, value)
528                in raw_value.items()
529            }
530
531    # No special conversion was made, try to force the first type.
532
533    force_type = allowed_types[0]
534    if (not isinstance(force_type, type)):
535        force_type = typing.get_origin(force_type)
536
537    if (force_type is not None):
538        return force_type(raw_value)
539
540    return raw_value
PODType = typing.Union[bool, float, int, str, typing.List[ForwardRef('PODType')], typing.Dict[str, ForwardRef('PODType')], NoneType]

A "Plain Old Data" type that can be easily represented (e.g., in JSON).

class SerializationContext:
11class SerializationContext:
12    """
13    Context information (context and options) to aid the serialization process.
14    An instance of this class will be passed around the core serialization functions to provide context and extra options.
15    """
16
17    def __init__(self,
18            base_dir: typing.Union[str, None] = None,
19            source_path: typing.Union[str, None] = None,
20            key: typing.Union[str, None] = None,
21            json_options: typing.Union[typing.Dict[str, typing.Any], None] = None,
22            extra: typing.Union[typing.Dict[str, typing.Any], None] = None,
23            **kwargs: typing.Any) -> None:
24        if (base_dir is None):
25            base_dir = '.'
26
27        self.base_dir: str = base_dir
28        """
29        The base directory for any relative paths this object needs to resolve.
30        Defaults to '.'.
31        """
32
33        self.source_path: typing.Union[str, None] = source_path
34        """ If we are reading from a file, this attribute should be the absolute path to that file. """
35
36        self.key: typing.Union[str, None] = key
37        """
38        An key to use during (de)serialization.
39        For example, this may be an encryption key.
40        """
41
42        if (json_options is None):
43            json_options = {}
44
45        self.json_options: typing.Dict[str, typing.Any] = json_options
46        """ Options to pass to JSON functions. """
47
48        if (extra is None):
49            extra = {}
50        else:
51            extra = extra.copy()
52
53        extra.update(kwargs)
54
55        self.extra: typing.Dict[str, typing.Any] = extra
56        """
57        Additional data to pass along the serialization process.
58        This is where users can pass additional data.
59        """
60
61    def copy(self) -> 'SerializationContext':
62        """ Make a deep copy of this context. """
63
64        return copy.deepcopy(self)

Context information (context and options) to aid the serialization process. An instance of this class will be passed around the core serialization functions to provide context and extra options.

SerializationContext( base_dir: Optional[str] = None, source_path: Optional[str] = None, key: Optional[str] = None, json_options: Optional[Dict[str, Any]] = None, extra: Optional[Dict[str, Any]] = None, **kwargs: Any)
17    def __init__(self,
18            base_dir: typing.Union[str, None] = None,
19            source_path: typing.Union[str, None] = None,
20            key: typing.Union[str, None] = None,
21            json_options: typing.Union[typing.Dict[str, typing.Any], None] = None,
22            extra: typing.Union[typing.Dict[str, typing.Any], None] = None,
23            **kwargs: typing.Any) -> None:
24        if (base_dir is None):
25            base_dir = '.'
26
27        self.base_dir: str = base_dir
28        """
29        The base directory for any relative paths this object needs to resolve.
30        Defaults to '.'.
31        """
32
33        self.source_path: typing.Union[str, None] = source_path
34        """ If we are reading from a file, this attribute should be the absolute path to that file. """
35
36        self.key: typing.Union[str, None] = key
37        """
38        An key to use during (de)serialization.
39        For example, this may be an encryption key.
40        """
41
42        if (json_options is None):
43            json_options = {}
44
45        self.json_options: typing.Dict[str, typing.Any] = json_options
46        """ Options to pass to JSON functions. """
47
48        if (extra is None):
49            extra = {}
50        else:
51            extra = extra.copy()
52
53        extra.update(kwargs)
54
55        self.extra: typing.Dict[str, typing.Any] = extra
56        """
57        Additional data to pass along the serialization process.
58        This is where users can pass additional data.
59        """
base_dir: str

The base directory for any relative paths this object needs to resolve. Defaults to '.'.

source_path: Optional[str]

If we are reading from a file, this attribute should be the absolute path to that file.

key: Optional[str]

An key to use during (de)serialization. For example, this may be an encryption key.

json_options: Dict[str, Any]

Options to pass to JSON functions.

extra: Dict[str, Any]

Additional data to pass along the serialization process. This is where users can pass additional data.

def copy(self) -> SerializationContext:
61    def copy(self) -> 'SerializationContext':
62        """ Make a deep copy of this context. """
63
64        return copy.deepcopy(self)

Make a deep copy of this context.

class SerializationBase:
 35class SerializationBase:
 36    """
 37    A base class for the serialization classes.
 38
 39    Note that this causes diamond inheritance for classes that extend *Converter,
 40    but since this class only provides simple functionality not overwritten by any branch child,
 41    there should be no issues.
 42
 43    General (but inefficient) implementations of several core Python equality, comparison, and representation methods are provided.
 44    A default hash implementation is provided, but it is up to child classes themselves to ensure they are immutable
 45    if they want to be used as set elements or dict keys.
 46    """
 47
 48    serialization_omit_empty: bool = False
 49    """
 50    Do not include empty fields in serialization.
 51    An empty field meets one of the following conditions:
 52     - Has a `__len__` method which returns 0.
 53     - Has a `_serialization_is_empty` method that returns true.
 54    """
 55
 56    serialization_omit_none: bool = False
 57    """ Do not include None (null) fields in serialization. """
 58
 59    serialization_skip_fields: typing.Union[typing.Set[str], None] = None
 60    """ A list of field names to skip. """
 61
 62    serialization_include_init_context: bool = False
 63    """ Do not send the serialization context to an object's init. """
 64
 65    serialization_error_class: typing.Type[Exception] = ValueError
 66    """ The class to use when raising errors. """
 67
 68    @classmethod
 69    def skip_field(cls, name: str, value: typing.Any) -> bool:
 70        """ Check if a field should be skipped. """
 71
 72        if ((cls.serialization_skip_fields is not None) and (name in cls.serialization_skip_fields)):
 73            return True
 74
 75        if (cls.serialization_omit_none and (value is None)):
 76            return True
 77
 78        if (cls.serialization_omit_empty and hasattr(value, '__len__') and (len(value) == 0)):
 79            return True
 80
 81        if (cls.serialization_omit_empty and hasattr(value, '_serialization_is_empty') and value._serialization_is_empty()):
 82            return True
 83
 84        return False
 85
 86    def __lt__(self, other: 'SerializationBase') -> bool:
 87        return repr(self) < repr(other)
 88
 89    def __hash__(self) -> int:
 90        return hash(repr(self))
 91
 92    def __str__(self) -> str:
 93        return repr(self)
 94
 95    def __repr__(self) -> str:
 96        return edq.util.json.dumps(self)
 97
 98    def _eq(self,
 99            other: object,
100            self_serializer: typing.Callable,
101            other_serializer: typing.Callable,
102            context: typing.Union[SerializationContext, None] = None,
103            ) -> bool:
104        """
105        Check for equality using the given func..
106        """
107
108        if (not isinstance(other, type(self))):
109            return False
110
111        return bool(self_serializer(context) == other_serializer(context))
112
113    @classmethod
114    def _from_path(cls: typing.Type[SerializationBaseClass],
115            path: str,
116            deserializer: typing.Callable,
117            context: typing.Union[SerializationContext, None] = None,
118            ) -> SerializationBaseClass:
119        """
120        The internal helper for from_path().
121        """
122
123        path = os.path.abspath(path)
124
125        if (context is None):
126            context = SerializationContext()
127        else:
128            context = context.copy()
129
130        context.base_dir = os.path.dirname(path)
131        context.source_path = path
132
133        data = edq.util.json.load_path(path, **context.json_options)
134
135        return deserializer(data, context)  # type: ignore[no-any-return]
136
137    def _to_path(self,
138            path: str,
139            serializer: typing.Callable,
140            context: typing.Union[SerializationContext, None] = None,
141            ) -> None:
142        """ Write this object to the given path as JSON. """
143
144        if (context is None):
145            context = SerializationContext()
146        else:
147            context = context.copy()
148
149        if ((not os.path.isabs(path)) and (context.base_dir is not None)):
150            path = os.path.join(context.base_dir, path)
151
152        context.source_path = os.path.abspath(path)
153        context.base_dir = os.path.dirname(context.source_path)
154
155        data = serializer(context)
156        edq.util.json.dump_path(data, context.source_path, **context.json_options)

A base class for the serialization classes.

Note that this causes diamond inheritance for classes that extend *Converter, but since this class only provides simple functionality not overwritten by any branch child, there should be no issues.

General (but inefficient) implementations of several core Python equality, comparison, and representation methods are provided. A default hash implementation is provided, but it is up to child classes themselves to ensure they are immutable if they want to be used as set elements or dict keys.

serialization_omit_empty: bool = False

Do not include empty fields in serialization. An empty field meets one of the following conditions:

  • Has a __len__ method which returns 0.
  • Has a _serialization_is_empty method that returns true.
serialization_omit_none: bool = False

Do not include None (null) fields in serialization.

serialization_skip_fields: Optional[Set[str]] = None

A list of field names to skip.

serialization_include_init_context: bool = False

Do not send the serialization context to an object's init.

serialization_error_class: Type[Exception] = <class 'ValueError'>

The class to use when raising errors.

@classmethod
def skip_field(cls, name: str, value: Any) -> bool:
68    @classmethod
69    def skip_field(cls, name: str, value: typing.Any) -> bool:
70        """ Check if a field should be skipped. """
71
72        if ((cls.serialization_skip_fields is not None) and (name in cls.serialization_skip_fields)):
73            return True
74
75        if (cls.serialization_omit_none and (value is None)):
76            return True
77
78        if (cls.serialization_omit_empty and hasattr(value, '__len__') and (len(value) == 0)):
79            return True
80
81        if (cls.serialization_omit_empty and hasattr(value, '_serialization_is_empty') and value._serialization_is_empty()):
82            return True
83
84        return False

Check if a field should be skipped.

class PODSerializer(SerializationBase):
158class PODSerializer(SerializationBase):
159    """
160    A class that can represent itself as a POD type.
161    Sibling to PODDeserializer.
162    """
163
164    def to_pod(self,
165            context: typing.Union[SerializationContext, None] = None,
166            ) -> PODType:
167        """
168        Get a POD representation of this object.
169
170        The default implementation will convert to a dict (similar to a DictSerializer).
171        """
172
173        if (context is None):
174            context = SerializationContext()
175
176        data: typing.Dict[str, typing.Any] = {}
177
178        for (key, value) in vars(self).items():
179            if (self.skip_field(key, value)):
180                continue
181
182            data[key] = generic_to_pod(value, context, self.serialization_error_class)
183
184        return data
185
186    def to_path(self,
187            path: str,
188            context: typing.Union[SerializationContext, None] = None,
189            ) -> None:
190        """ Write this object to the given path as JSON. """
191
192        self._to_path(path, self.to_pod, context)
193
194    def __eq__(self, other: object) -> bool:
195        """
196        Check for equality.
197
198        This check uses to_pod() and compares the results.
199        This may not be complete or efficient depending on the child class.
200        """
201
202        return self._eq(other, self.to_pod, getattr(other, 'to_pod', None))  # type: ignore[arg-type]

A class that can represent itself as a POD type. Sibling to PODDeserializer.

def to_pod( self, context: Optional[SerializationContext] = None) -> Union[bool, float, int, str, List[Union[bool, float, int, str, List[ForwardRef('PODType')], Dict[str, ForwardRef('PODType')], NoneType]], Dict[str, Union[bool, float, int, str, List[ForwardRef('PODType')], Dict[str, ForwardRef('PODType')], NoneType]], NoneType]:
164    def to_pod(self,
165            context: typing.Union[SerializationContext, None] = None,
166            ) -> PODType:
167        """
168        Get a POD representation of this object.
169
170        The default implementation will convert to a dict (similar to a DictSerializer).
171        """
172
173        if (context is None):
174            context = SerializationContext()
175
176        data: typing.Dict[str, typing.Any] = {}
177
178        for (key, value) in vars(self).items():
179            if (self.skip_field(key, value)):
180                continue
181
182            data[key] = generic_to_pod(value, context, self.serialization_error_class)
183
184        return data

Get a POD representation of this object.

The default implementation will convert to a dict (similar to a DictSerializer).

def to_path( self, path: str, context: Optional[SerializationContext] = None) -> None:
186    def to_path(self,
187            path: str,
188            context: typing.Union[SerializationContext, None] = None,
189            ) -> None:
190        """ Write this object to the given path as JSON. """
191
192        self._to_path(path, self.to_pod, context)

Write this object to the given path as JSON.

class PODDeserializer(SerializationBase):
204class PODDeserializer(SerializationBase):
205    """
206    A class that can construct itself from a POD type.
207    Sibling to PODSerializer.
208    """
209
210    @classmethod
211    def prep_init_data(cls,
212            data: typing.Dict[str, typing.Any],
213            context: typing.Union[SerializationContext, None] = None,
214            ) -> typing.Dict[str, typing.Any]:
215        """
216        Prepare data to be passed into this class' constructor.
217
218        By default, this is called by from_pod().
219        A child can override this or prep_init_data() depending on the functionality they want.
220
221        A general (but inefficient) implementation is provided by default.
222        This implementation will attempt to use type hints (of the classes constructor) to convert enums and DictDeserializers.
223        """
224
225        if (context is None):
226            context = SerializationContext()
227
228        new_data = {}
229        constructor_types = typing.get_type_hints(cls.__init__)
230
231        for (key, value) in data.items():
232            if (cls.skip_field(key, value)):
233                continue
234
235            new_data[key] = _from_pod(f"field {key}", constructor_types.get(key, None), value, context, cls.serialization_error_class)
236
237        return new_data
238
239    @classmethod
240    def from_pod(cls: typing.Type[PODDeserializerClass],
241            data: PODType,
242            context: typing.Union[SerializationContext, None] = None,
243            ) -> PODDeserializerClass:
244        """
245        Create an instance of this class from a POD.
246
247        The default implementation will call the class' constructor with one of two things:
248        a splat/unpacking (**) of the incoming data if the data is a dict,
249        otherwise the data itself.
250        """
251
252        if (context is None):
253            context = SerializationContext()
254
255        if (isinstance(data, dict)):
256            new_data = cls.prep_init_data(data, context)
257
258            if (cls.serialization_include_init_context):
259                new_data['context'] = context
260
261            return cls(**new_data)
262
263        return cls(data)  # type: ignore[call-arg]
264
265    @classmethod
266    def from_path(cls: typing.Type[PODDeserializerClass],
267            path: str,
268            context: typing.Union[SerializationContext, None] = None,
269            ) -> PODDeserializerClass:
270        """
271        Read the path (as JSON) and call from_pod().
272
273        If a serialization context is passed in to this function,
274        a copy will be made with the new base dir and source path.
275        """
276
277        return cls._from_path(path, cls.from_pod, context)

A class that can construct itself from a POD type. Sibling to PODSerializer.

@classmethod
def prep_init_data( cls, data: Dict[str, Any], context: Optional[SerializationContext] = None) -> Dict[str, Any]:
210    @classmethod
211    def prep_init_data(cls,
212            data: typing.Dict[str, typing.Any],
213            context: typing.Union[SerializationContext, None] = None,
214            ) -> typing.Dict[str, typing.Any]:
215        """
216        Prepare data to be passed into this class' constructor.
217
218        By default, this is called by from_pod().
219        A child can override this or prep_init_data() depending on the functionality they want.
220
221        A general (but inefficient) implementation is provided by default.
222        This implementation will attempt to use type hints (of the classes constructor) to convert enums and DictDeserializers.
223        """
224
225        if (context is None):
226            context = SerializationContext()
227
228        new_data = {}
229        constructor_types = typing.get_type_hints(cls.__init__)
230
231        for (key, value) in data.items():
232            if (cls.skip_field(key, value)):
233                continue
234
235            new_data[key] = _from_pod(f"field {key}", constructor_types.get(key, None), value, context, cls.serialization_error_class)
236
237        return new_data

Prepare data to be passed into this class' constructor.

By default, this is called by from_pod(). A child can override this or prep_init_data() depending on the functionality they want.

A general (but inefficient) implementation is provided by default. This implementation will attempt to use type hints (of the classes constructor) to convert enums and DictDeserializers.

@classmethod
def from_pod( cls: Type[~PODDeserializerClass], data: Union[bool, float, int, str, List[Union[bool, float, int, str, List[ForwardRef('PODType')], Dict[str, ForwardRef('PODType')], NoneType]], Dict[str, Union[bool, float, int, str, List[ForwardRef('PODType')], Dict[str, ForwardRef('PODType')], NoneType]], NoneType], context: Optional[SerializationContext] = None) -> ~PODDeserializerClass:
239    @classmethod
240    def from_pod(cls: typing.Type[PODDeserializerClass],
241            data: PODType,
242            context: typing.Union[SerializationContext, None] = None,
243            ) -> PODDeserializerClass:
244        """
245        Create an instance of this class from a POD.
246
247        The default implementation will call the class' constructor with one of two things:
248        a splat/unpacking (**) of the incoming data if the data is a dict,
249        otherwise the data itself.
250        """
251
252        if (context is None):
253            context = SerializationContext()
254
255        if (isinstance(data, dict)):
256            new_data = cls.prep_init_data(data, context)
257
258            if (cls.serialization_include_init_context):
259                new_data['context'] = context
260
261            return cls(**new_data)
262
263        return cls(data)  # type: ignore[call-arg]

Create an instance of this class from a POD.

The default implementation will call the class' constructor with one of two things: a splat/unpacking (**) of the incoming data if the data is a dict, otherwise the data itself.

@classmethod
def from_path( cls: Type[~PODDeserializerClass], path: str, context: Optional[SerializationContext] = None) -> ~PODDeserializerClass:
265    @classmethod
266    def from_path(cls: typing.Type[PODDeserializerClass],
267            path: str,
268            context: typing.Union[SerializationContext, None] = None,
269            ) -> PODDeserializerClass:
270        """
271        Read the path (as JSON) and call from_pod().
272
273        If a serialization context is passed in to this function,
274        a copy will be made with the new base dir and source path.
275        """
276
277        return cls._from_path(path, cls.from_pod, context)

Read the path (as JSON) and call from_pod().

If a serialization context is passed in to this function, a copy will be made with the new base dir and source path.

class PODConverter(PODSerializer, PODDeserializer):
279class PODConverter(PODSerializer, PODDeserializer):
280    """ A PODSerializer and PODDeserializer. """
281
282    def copy(self,
283            context: typing.Union[SerializationContext, None] = None,
284            ) -> 'PODConverter':
285        """
286        Make a deep copy of this object.
287        The default implementation will use to_pod() and from_pod() to make a copy.
288
289        Callers should be cautious of fileds that are skipped in serialization,
290        e.g., via `SerializationBase.serialization_skip_fields`.
291        """
292
293        if (context is None):
294            context = SerializationContext()
295
296        return self.from_pod(self.to_pod(context), context)

A PODSerializer and PODDeserializer.

def copy( self, context: Optional[SerializationContext] = None) -> PODConverter:
282    def copy(self,
283            context: typing.Union[SerializationContext, None] = None,
284            ) -> 'PODConverter':
285        """
286        Make a deep copy of this object.
287        The default implementation will use to_pod() and from_pod() to make a copy.
288
289        Callers should be cautious of fileds that are skipped in serialization,
290        e.g., via `SerializationBase.serialization_skip_fields`.
291        """
292
293        if (context is None):
294            context = SerializationContext()
295
296        return self.from_pod(self.to_pod(context), context)

Make a deep copy of this object. The default implementation will use to_pod() and from_pod() to make a copy.

Callers should be cautious of fileds that are skipped in serialization, e.g., via SerializationBase.serialization_skip_fields.

class DictSerializer(PODSerializer):
298class DictSerializer(PODSerializer):
299    """
300    A base class for class that can represent itself as a dict.
301    The intention is that the dict can then be cleanly converted to/from JSON.
302    """
303
304    def to_dict(self,
305            context: typing.Union[SerializationContext, None] = None,
306            ) -> typing.Dict[str, PODType]:
307        """
308        Return a dict that can be used to represent this object.
309        If the dict is passed to from_dict(), an identical object should be reconstructed.
310
311        A general (but inefficient) implementation is provided by default.
312        """
313
314        data = self.to_pod(context)
315        if (not isinstance(data, dict)):
316            raise self.serialization_error_class(f"DictSerializer's to_pod() did not return a dict, found '{type(data)}'.")
317
318        return data
319
320    def to_path(self,
321            path: str,
322            context: typing.Union[SerializationContext, None] = None,
323            ) -> None:
324        """ Write this object to the given path as JSON. """
325
326        self._to_path(path, self.to_dict, context)
327
328    def __eq__(self, other: object) -> bool:
329        """
330        Check for equality.
331
332        This check uses to_pod() and compares the results.
333        This may not be complete or efficient depending on the child class.
334        """
335
336        return self._eq(other, self.to_dict, getattr(other, 'to_dict', None))  # type: ignore[arg-type]

A base class for class that can represent itself as a dict. The intention is that the dict can then be cleanly converted to/from JSON.

def to_dict( self, context: Optional[SerializationContext] = None) -> Dict[str, Union[bool, float, int, str, List[Union[bool, float, int, str, List[ForwardRef('PODType')], Dict[str, ForwardRef('PODType')], NoneType]], Dict[str, Union[bool, float, int, str, List[ForwardRef('PODType')], Dict[str, ForwardRef('PODType')], NoneType]], NoneType]]:
304    def to_dict(self,
305            context: typing.Union[SerializationContext, None] = None,
306            ) -> typing.Dict[str, PODType]:
307        """
308        Return a dict that can be used to represent this object.
309        If the dict is passed to from_dict(), an identical object should be reconstructed.
310
311        A general (but inefficient) implementation is provided by default.
312        """
313
314        data = self.to_pod(context)
315        if (not isinstance(data, dict)):
316            raise self.serialization_error_class(f"DictSerializer's to_pod() did not return a dict, found '{type(data)}'.")
317
318        return data

Return a dict that can be used to represent this object. If the dict is passed to from_dict(), an identical object should be reconstructed.

A general (but inefficient) implementation is provided by default.

def to_path( self, path: str, context: Optional[SerializationContext] = None) -> None:
320    def to_path(self,
321            path: str,
322            context: typing.Union[SerializationContext, None] = None,
323            ) -> None:
324        """ Write this object to the given path as JSON. """
325
326        self._to_path(path, self.to_dict, context)

Write this object to the given path as JSON.

class DictDeserializer(PODDeserializer):
338class DictDeserializer(PODDeserializer):
339    """
340    A base class for class that can reconstruct (deserialize) themselves from a dict.
341    The intention is that the class can be cleanly converted from JSON.
342
343    Any class that uses the default implementations should have a constructor
344    that accepts arguments with the same name as their members.
345    It is generally recommended to also have the constructor accept a kwargs,
346    since values will be blindly passed to the constructor.
347    """
348
349    @classmethod
350    def from_dict(cls: typing.Type[DictDeserializerClass],
351            data: typing.Dict[str, PODType],
352            context: typing.Union[SerializationContext, None] = None,
353            ) -> DictDeserializerClass:
354        """
355        Return an instance of this subclass created using the given dict.
356        If the dict came from to_dict(), the returned object should be equivalent to the original.
357
358        By default, this function just calls the class' constructor with the output of prep_init_data().
359        A child can override this or prep_init_data() depending on the functionality they want.
360
361        A general (but inefficient) implementation is provided by default.
362        This implementation will attempt to use type hints (of the classes constructor) to convert enums and DictDeserializers.
363        """
364
365        return cls.from_pod(data, context)
366
367    @classmethod
368    def from_path(cls: typing.Type[DictDeserializerClass],
369            path: str,
370            context: typing.Union[SerializationContext, None] = None,
371            ) -> DictDeserializerClass:
372        """
373        Read the path (as JSON) and call from_dict().
374
375        If a serialization context is passed in to this function,
376        a copy will be made with the new base dir and source path.
377        """
378
379        return cls._from_path(path, cls.from_dict, context)

A base class for class that can reconstruct (deserialize) themselves from a dict. The intention is that the class can be cleanly converted from JSON.

Any class that uses the default implementations should have a constructor that accepts arguments with the same name as their members. It is generally recommended to also have the constructor accept a kwargs, since values will be blindly passed to the constructor.

@classmethod
def from_dict( cls: Type[~DictDeserializerClass], data: Dict[str, Union[bool, float, int, str, List[Union[bool, float, int, str, List[ForwardRef('PODType')], Dict[str, ForwardRef('PODType')], NoneType]], Dict[str, Union[bool, float, int, str, List[ForwardRef('PODType')], Dict[str, ForwardRef('PODType')], NoneType]], NoneType]], context: Optional[SerializationContext] = None) -> ~DictDeserializerClass:
349    @classmethod
350    def from_dict(cls: typing.Type[DictDeserializerClass],
351            data: typing.Dict[str, PODType],
352            context: typing.Union[SerializationContext, None] = None,
353            ) -> DictDeserializerClass:
354        """
355        Return an instance of this subclass created using the given dict.
356        If the dict came from to_dict(), the returned object should be equivalent to the original.
357
358        By default, this function just calls the class' constructor with the output of prep_init_data().
359        A child can override this or prep_init_data() depending on the functionality they want.
360
361        A general (but inefficient) implementation is provided by default.
362        This implementation will attempt to use type hints (of the classes constructor) to convert enums and DictDeserializers.
363        """
364
365        return cls.from_pod(data, context)

Return an instance of this subclass created using the given dict. If the dict came from to_dict(), the returned object should be equivalent to the original.

By default, this function just calls the class' constructor with the output of prep_init_data(). A child can override this or prep_init_data() depending on the functionality they want.

A general (but inefficient) implementation is provided by default. This implementation will attempt to use type hints (of the classes constructor) to convert enums and DictDeserializers.

@classmethod
def from_path( cls: Type[~DictDeserializerClass], path: str, context: Optional[SerializationContext] = None) -> ~DictDeserializerClass:
367    @classmethod
368    def from_path(cls: typing.Type[DictDeserializerClass],
369            path: str,
370            context: typing.Union[SerializationContext, None] = None,
371            ) -> DictDeserializerClass:
372        """
373        Read the path (as JSON) and call from_dict().
374
375        If a serialization context is passed in to this function,
376        a copy will be made with the new base dir and source path.
377        """
378
379        return cls._from_path(path, cls.from_dict, context)

Read the path (as JSON) and call from_dict().

If a serialization context is passed in to this function, a copy will be made with the new base dir and source path.

class DictConverter(PODConverter, DictSerializer, DictDeserializer):
381class DictConverter(PODConverter, DictSerializer, DictDeserializer):
382    """ A DictSerializer and DictDeserializer. """

A DictSerializer and DictDeserializer.

def generic_to_pod( raw_value: Any, context: SerializationContext, serialization_error_class: Type[Exception] = <class 'ValueError'>) -> Union[bool, float, int, str, List[Union[bool, float, int, str, List[ForwardRef('PODType')], Dict[str, ForwardRef('PODType')], NoneType]], Dict[str, Union[bool, float, int, str, List[ForwardRef('PODType')], Dict[str, ForwardRef('PODType')], NoneType]], NoneType]:
398def generic_to_pod(
399        raw_value: typing.Any,
400        context: SerializationContext,
401        serialization_error_class: typing.Type[Exception] = ValueError,
402        ) -> PODType:
403    """
404    Attempt to convert a value to a POD type.
405
406    Has special handling for:
407        - DictSerializer
408        - PODSerializer
409        - enum.Enum
410        - (list, tuple, set)
411        - dict
412    """
413
414    if (raw_value is None):
415        return None
416
417    # Simple types that are already POD.
418    if (isinstance(raw_value, (bool, float, int, str))):
419        return raw_value
420
421    if (isinstance(raw_value, DictSerializer)):
422        return raw_value.to_dict(context)
423
424    if (isinstance(raw_value, PODSerializer)):
425        return raw_value.to_pod(context)
426
427    if (isinstance(raw_value, enum.Enum)):
428        return generic_to_pod(raw_value.value, context, serialization_error_class)
429
430    if (isinstance(raw_value, (list, tuple, set))):
431        items = [generic_to_pod(item, context, serialization_error_class) for item in raw_value]
432
433        # Sort sets for consistency.
434        if (isinstance(raw_value, set)):
435            # Sort using the string representation of the items (since the set could be heterogeneous and have non-comparable items).
436            sort_list = sorted([(item, i) for (i, item) in enumerate(map(str, items))])
437            items = [items[i] for (_, i) in sort_list]
438
439        return items
440
441    if (isinstance(raw_value, dict)):
442        return {key: generic_to_pod(value, context, serialization_error_class) for (key, value) in raw_value.items()}
443
444    raise serialization_error_class(f"Unable to convert value to simple (edq.util.serial.POD) type: '{raw_value}' (type: '{type(raw_value)}').")

Attempt to convert a value to a POD type.

Has special handling for: - DictSerializer - PODSerializer - enum.Enum - (list, tuple, set) - dict