edq.util.json

This file standardizes how we write and read JSON. Specifically, we try to be flexible when reading (using JSON5), and strict when writing (using vanilla JSON).

  1"""
  2This file standardizes how we write and read JSON.
  3Specifically, we try to be flexible when reading (using JSON5),
  4and strict when writing (using vanilla JSON).
  5"""
  6
  7import enum
  8import json
  9import os
 10import typing
 11
 12import json5
 13
 14import edq.util.dirent
 15
 16class DictConverter():
 17    """
 18    A base class for class that can represent (serialize) and reconstruct (deserialize) themselves as/from a dict.
 19    The intention is that the dict can then be cleanly converted to/from JSON.
 20
 21    General (but inefficient) implementations of several core Python equality, comparison, and representation methods are provided.
 22    """
 23
 24    def to_dict(self) -> typing.Dict[str, typing.Any]:
 25        """
 26        Return a dict that can be used to represent this object.
 27        If the dict is passed to from_dict(), an identical object should be reconstructed.
 28
 29        A general (but inefficient) implementation is provided by default.
 30        """
 31
 32        return vars(self).copy()
 33
 34    @classmethod
 35    # Note that `typing.Self` is returned, but that is introduced in Python 3.12.
 36    def from_dict(cls, data: typing.Dict[str, typing.Any]) -> typing.Any:
 37        """
 38        Return an instance of this subclass created using the given dict.
 39        If the dict came from to_dict(), the returned object should be identical to the original.
 40
 41        A general (but inefficient) implementation is provided by default.
 42        """
 43
 44        return cls(**data)
 45
 46    def __eq__(self, other: object) -> bool:
 47        """
 48        Check for equality.
 49
 50        This check uses to_dict() and compares the results.
 51        This may not be complete or efficient depending on the child class.
 52        """
 53
 54        # Note the hard type check (done so we can keep this method general).
 55        if (type(self) != type(other)):  # pylint: disable=unidiomatic-typecheck
 56            return False
 57
 58        return bool(self.to_dict() == other.to_dict())  # type: ignore[attr-defined]
 59
 60    def __lt__(self, other: 'DictConverter') -> bool:
 61        return dumps(self) < dumps(other)
 62
 63    def __hash__(self) -> int:
 64        return hash(dumps(self))
 65
 66    def __str__(self) -> str:
 67        return dumps(self)
 68
 69    def __repr__(self) -> str:
 70        return dumps(self)
 71
 72def _custom_handle(value: typing.Any) -> typing.Union[typing.Dict[str, typing.Any], str]:
 73    """
 74    Handle objects that are not JSON serializable by default,
 75    e.g., calling vars() on an object.
 76    """
 77
 78    if (isinstance(value, DictConverter)):
 79        return value.to_dict()
 80
 81    if (isinstance(value, enum.Enum)):
 82        return str(value)
 83
 84    if (hasattr(value, '__dict__')):
 85        return dict(vars(value))
 86
 87    raise ValueError(f"Could not JSON serialize object: '{value}'.")
 88
 89def load(file_obj: typing.TextIO, strict: bool = False, **kwargs: typing.Any) -> typing.Any:
 90    """
 91    Load a file object/handler as JSON.
 92    If strict is set, then use standard Python JSON,
 93    otherwise use JSON5.
 94    """
 95
 96    if (strict):
 97        return json.load(file_obj, **kwargs)
 98
 99    return json5.load(file_obj, **kwargs)
100
101def loads(text: str, strict: bool = False, **kwargs: typing.Any) -> typing.Any:
102    """
103    Load a string as JSON.
104    If strict is set, then use standard Python JSON,
105    otherwise use JSON5.
106    """
107
108    if (strict):
109        return json.loads(text, **kwargs)
110
111    return json5.loads(text, **kwargs)
112
113def load_path(
114        path: str,
115        strict: bool = False,
116        encoding: str = edq.util.dirent.DEFAULT_ENCODING,
117        **kwargs: typing.Any) -> typing.Any:
118    """
119    Load a file path as JSON.
120    If strict is set, then use standard Python JSON,
121    otherwise use JSON5.
122    """
123
124    if (os.path.isdir(path)):
125        raise IsADirectoryError(f"Cannot open JSON file, expected a file but got a directory at '{path}'.")
126
127    try:
128        with open(path, 'r', encoding = encoding) as file:
129            return load(file, strict = strict, **kwargs)
130    except Exception as ex:
131        raise ValueError(f"Failed to read JSON file '{path}'.") from ex
132
133def loads_object(text: str, cls: typing.Type[DictConverter], **kwargs: typing.Any) -> DictConverter:
134    """ Load a JSON string into an object (which is a subclass of DictConverter). """
135
136    data = loads(text, **kwargs)
137    if (not isinstance(data, dict)):
138        raise ValueError(f"JSON to load into an object is not a dict, found '{type(data)}'.")
139
140    return cls.from_dict(data)  # type: ignore[no-any-return]
141
142def load_object_path(path: str, cls: typing.Type[DictConverter], **kwargs: typing.Any) -> DictConverter:
143    """ Load a JSON file into an object (which is a subclass of DictConverter). """
144
145    data = load_path(path, **kwargs)
146    if (not isinstance(data, dict)):
147        raise ValueError(f"JSON to load into an object is not a dict, found '{type(data)}'.")
148
149    return cls.from_dict(data)  # type: ignore[no-any-return]
150
151def dump(
152        data: typing.Any,
153        file_obj: typing.TextIO,
154        default: typing.Union[typing.Callable, None] = _custom_handle,
155        sort_keys: bool = True,
156        **kwargs: typing.Any) -> None:
157    """ Dump an object as a JSON file object. """
158
159    json.dump(data, file_obj, default = default, sort_keys = sort_keys, **kwargs)
160
161def dumps(
162        data: typing.Any,
163        default: typing.Union[typing.Callable, None] = _custom_handle,
164        sort_keys: bool = True,
165        **kwargs: typing.Any) -> str:
166    """ Dump an object as a JSON string. """
167
168    return json.dumps(data, default = default, sort_keys = sort_keys, **kwargs)
169
170def dump_path(
171        data: typing.Any,
172        path: str,
173        default: typing.Union[typing.Callable, None] = _custom_handle,
174        sort_keys: bool = True,
175        encoding: str = edq.util.dirent.DEFAULT_ENCODING,
176        **kwargs: typing.Any) -> None:
177    """ Dump an object as a JSON file. """
178
179    with open(path, 'w', encoding = encoding) as file:
180        dump(data, file, default = default, sort_keys = sort_keys, **kwargs)
class DictConverter:
17class DictConverter():
18    """
19    A base class for class that can represent (serialize) and reconstruct (deserialize) themselves as/from a dict.
20    The intention is that the dict can then be cleanly converted to/from JSON.
21
22    General (but inefficient) implementations of several core Python equality, comparison, and representation methods are provided.
23    """
24
25    def to_dict(self) -> typing.Dict[str, typing.Any]:
26        """
27        Return a dict that can be used to represent this object.
28        If the dict is passed to from_dict(), an identical object should be reconstructed.
29
30        A general (but inefficient) implementation is provided by default.
31        """
32
33        return vars(self).copy()
34
35    @classmethod
36    # Note that `typing.Self` is returned, but that is introduced in Python 3.12.
37    def from_dict(cls, data: typing.Dict[str, typing.Any]) -> typing.Any:
38        """
39        Return an instance of this subclass created using the given dict.
40        If the dict came from to_dict(), the returned object should be identical to the original.
41
42        A general (but inefficient) implementation is provided by default.
43        """
44
45        return cls(**data)
46
47    def __eq__(self, other: object) -> bool:
48        """
49        Check for equality.
50
51        This check uses to_dict() and compares the results.
52        This may not be complete or efficient depending on the child class.
53        """
54
55        # Note the hard type check (done so we can keep this method general).
56        if (type(self) != type(other)):  # pylint: disable=unidiomatic-typecheck
57            return False
58
59        return bool(self.to_dict() == other.to_dict())  # type: ignore[attr-defined]
60
61    def __lt__(self, other: 'DictConverter') -> bool:
62        return dumps(self) < dumps(other)
63
64    def __hash__(self) -> int:
65        return hash(dumps(self))
66
67    def __str__(self) -> str:
68        return dumps(self)
69
70    def __repr__(self) -> str:
71        return dumps(self)

A base class for class that can represent (serialize) and reconstruct (deserialize) themselves as/from a dict. The intention is that the dict can then be cleanly converted to/from JSON.

General (but inefficient) implementations of several core Python equality, comparison, and representation methods are provided.

def to_dict(self) -> Dict[str, Any]:
25    def to_dict(self) -> typing.Dict[str, typing.Any]:
26        """
27        Return a dict that can be used to represent this object.
28        If the dict is passed to from_dict(), an identical object should be reconstructed.
29
30        A general (but inefficient) implementation is provided by default.
31        """
32
33        return vars(self).copy()

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.

@classmethod
def from_dict(cls, data: Dict[str, Any]) -> Any:
35    @classmethod
36    # Note that `typing.Self` is returned, but that is introduced in Python 3.12.
37    def from_dict(cls, data: typing.Dict[str, typing.Any]) -> typing.Any:
38        """
39        Return an instance of this subclass created using the given dict.
40        If the dict came from to_dict(), the returned object should be identical to the original.
41
42        A general (but inefficient) implementation is provided by default.
43        """
44
45        return cls(**data)

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

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

def load(file_obj: <class 'TextIO'>, strict: bool = False, **kwargs: Any) -> Any:
 90def load(file_obj: typing.TextIO, strict: bool = False, **kwargs: typing.Any) -> typing.Any:
 91    """
 92    Load a file object/handler as JSON.
 93    If strict is set, then use standard Python JSON,
 94    otherwise use JSON5.
 95    """
 96
 97    if (strict):
 98        return json.load(file_obj, **kwargs)
 99
100    return json5.load(file_obj, **kwargs)

Load a file object/handler as JSON. If strict is set, then use standard Python JSON, otherwise use JSON5.

def loads(text: str, strict: bool = False, **kwargs: Any) -> Any:
102def loads(text: str, strict: bool = False, **kwargs: typing.Any) -> typing.Any:
103    """
104    Load a string as JSON.
105    If strict is set, then use standard Python JSON,
106    otherwise use JSON5.
107    """
108
109    if (strict):
110        return json.loads(text, **kwargs)
111
112    return json5.loads(text, **kwargs)

Load a string as JSON. If strict is set, then use standard Python JSON, otherwise use JSON5.

def load_path( path: str, strict: bool = False, encoding: str = 'utf-8', **kwargs: Any) -> Any:
114def load_path(
115        path: str,
116        strict: bool = False,
117        encoding: str = edq.util.dirent.DEFAULT_ENCODING,
118        **kwargs: typing.Any) -> typing.Any:
119    """
120    Load a file path as JSON.
121    If strict is set, then use standard Python JSON,
122    otherwise use JSON5.
123    """
124
125    if (os.path.isdir(path)):
126        raise IsADirectoryError(f"Cannot open JSON file, expected a file but got a directory at '{path}'.")
127
128    try:
129        with open(path, 'r', encoding = encoding) as file:
130            return load(file, strict = strict, **kwargs)
131    except Exception as ex:
132        raise ValueError(f"Failed to read JSON file '{path}'.") from ex

Load a file path as JSON. If strict is set, then use standard Python JSON, otherwise use JSON5.

def loads_object( text: str, cls: Type[DictConverter], **kwargs: Any) -> DictConverter:
134def loads_object(text: str, cls: typing.Type[DictConverter], **kwargs: typing.Any) -> DictConverter:
135    """ Load a JSON string into an object (which is a subclass of DictConverter). """
136
137    data = loads(text, **kwargs)
138    if (not isinstance(data, dict)):
139        raise ValueError(f"JSON to load into an object is not a dict, found '{type(data)}'.")
140
141    return cls.from_dict(data)  # type: ignore[no-any-return]

Load a JSON string into an object (which is a subclass of DictConverter).

def load_object_path( path: str, cls: Type[DictConverter], **kwargs: Any) -> DictConverter:
143def load_object_path(path: str, cls: typing.Type[DictConverter], **kwargs: typing.Any) -> DictConverter:
144    """ Load a JSON file into an object (which is a subclass of DictConverter). """
145
146    data = load_path(path, **kwargs)
147    if (not isinstance(data, dict)):
148        raise ValueError(f"JSON to load into an object is not a dict, found '{type(data)}'.")
149
150    return cls.from_dict(data)  # type: ignore[no-any-return]

Load a JSON file into an object (which is a subclass of DictConverter).

def dump( data: Any, file_obj: <class 'TextIO'>, default: Optional[Callable] = <function _custom_handle>, sort_keys: bool = True, **kwargs: Any) -> None:
152def dump(
153        data: typing.Any,
154        file_obj: typing.TextIO,
155        default: typing.Union[typing.Callable, None] = _custom_handle,
156        sort_keys: bool = True,
157        **kwargs: typing.Any) -> None:
158    """ Dump an object as a JSON file object. """
159
160    json.dump(data, file_obj, default = default, sort_keys = sort_keys, **kwargs)

Dump an object as a JSON file object.

def dumps( data: Any, default: Optional[Callable] = <function _custom_handle>, sort_keys: bool = True, **kwargs: Any) -> str:
162def dumps(
163        data: typing.Any,
164        default: typing.Union[typing.Callable, None] = _custom_handle,
165        sort_keys: bool = True,
166        **kwargs: typing.Any) -> str:
167    """ Dump an object as a JSON string. """
168
169    return json.dumps(data, default = default, sort_keys = sort_keys, **kwargs)

Dump an object as a JSON string.

def dump_path( data: Any, path: str, default: Optional[Callable] = <function _custom_handle>, sort_keys: bool = True, encoding: str = 'utf-8', **kwargs: Any) -> None:
171def dump_path(
172        data: typing.Any,
173        path: str,
174        default: typing.Union[typing.Callable, None] = _custom_handle,
175        sort_keys: bool = True,
176        encoding: str = edq.util.dirent.DEFAULT_ENCODING,
177        **kwargs: typing.Any) -> None:
178    """ Dump an object as a JSON file. """
179
180    with open(path, 'w', encoding = encoding) as file:
181        dump(data, file, default = default, sort_keys = sort_keys, **kwargs)

Dump an object as a JSON file.