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

@abc.abstractmethod
def to_dict(self) -> Dict[str, Any]:
23    @abc.abstractmethod
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        """

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.

@classmethod
@abc.abstractmethod
def from_dict(cls, data: Dict[str, Any]) -> Any:
30    @classmethod
31    @abc.abstractmethod
32    # Note that `typing.Self` is returned, but that is introduced in Python 3.12.
33    def from_dict(cls, data: typing.Dict[str, typing.Any]) -> typing.Any:
34        """
35        Return an instance of this subclass created using the given dict.
36        If the dict came from to_dict(), the returned object should be identical to the original.
37        """

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.

def load(file_obj: <class 'TextIO'>, strict: bool = False, **kwargs: Any) -> Any:
76def load(file_obj: typing.TextIO, strict: bool = False, **kwargs: typing.Any) -> typing.Any:
77    """
78    Load a file object/handler as JSON.
79    If strict is set, then use standard Python JSON,
80    otherwise use JSON5.
81    """
82
83    if (strict):
84        return json.load(file_obj, **kwargs)
85
86    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:
88def loads(text: str, strict: bool = False, **kwargs: typing.Any) -> typing.Any:
89    """
90    Load a string as JSON.
91    If strict is set, then use standard Python JSON,
92    otherwise use JSON5.
93    """
94
95    if (strict):
96        return json.loads(text, **kwargs)
97
98    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:
100def load_path(
101        path: str,
102        strict: bool = False,
103        encoding: str = edq.util.dirent.DEFAULT_ENCODING,
104        **kwargs: typing.Any) -> typing.Any:
105    """
106    Load a file path as JSON.
107    If strict is set, then use standard Python JSON,
108    otherwise use JSON5.
109    """
110
111    try:
112        with open(path, 'r', encoding = encoding) as file:
113            return load(file, strict = strict, **kwargs)
114    except Exception as ex:
115        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:
117def loads_object(text: str, cls: typing.Type[DictConverter], **kwargs: typing.Any) -> DictConverter:
118    """ Load a JSON string into an object (which is a subclass of DictConverter). """
119
120    data = loads(text, **kwargs)
121    if (not isinstance(data, dict)):
122        raise ValueError(f"JSON to load into an object is not a dict, found '{type(data)}'.")
123
124    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:
126def load_object_path(path: str, cls: typing.Type[DictConverter], **kwargs: typing.Any) -> DictConverter:
127    """ Load a JSON file into an object (which is a subclass of DictConverter). """
128
129    data = load_path(path, **kwargs)
130    if (not isinstance(data, dict)):
131        raise ValueError(f"JSON to load into an object is not a dict, found '{type(data)}'.")
132
133    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:
135def dump(
136        data: typing.Any,
137        file_obj: typing.TextIO,
138        default: typing.Union[typing.Callable, None] = _custom_handle,
139        sort_keys: bool = True,
140        **kwargs: typing.Any) -> None:
141    """ Dump an object as a JSON file object. """
142
143    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:
145def dumps(
146        data: typing.Any,
147        default: typing.Union[typing.Callable, None] = _custom_handle,
148        sort_keys: bool = True,
149        **kwargs: typing.Any) -> str:
150    """ Dump an object as a JSON string. """
151
152    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:
154def dump_path(
155        data: typing.Any,
156        path: str,
157        default: typing.Union[typing.Callable, None] = _custom_handle,
158        sort_keys: bool = True,
159        encoding: str = edq.util.dirent.DEFAULT_ENCODING,
160        **kwargs: typing.Any) -> None:
161    """ Dump an object as a JSON file. """
162
163    with open(path, 'w', encoding = encoding) as file:
164        dump(data, file, default = default, sort_keys = sort_keys, **kwargs)

Dump an object as a JSON file.