Skip to content

Commit

Permalink
Enhance to/from_json support. #3902
Browse files Browse the repository at this point in the history
- Support loading JSON using open file or file path.
- Support serializing to open file or file path.
- Customizable JSON formatting. Defaults differ from what ``json``
  uses by default.
  • Loading branch information
pekkaklarck committed Jan 10, 2023
1 parent d81386e commit ddfb05b
Show file tree
Hide file tree
Showing 2 changed files with 206 additions and 17 deletions.
107 changes: 98 additions & 9 deletions src/robot/model/modelobject.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@

import copy
import json
import os
import pathlib

from robot.utils import SetterAwareType
from robot.utils import SetterAwareType, type_name


class ModelObject(metaclass=SetterAwareType):
Expand All @@ -25,21 +27,64 @@ class ModelObject(metaclass=SetterAwareType):

@classmethod
def from_dict(cls, data):
"""Create this object based on data in a dictionary.
Data can be got from the :meth:`to_dict` method or created externally.
"""
try:
return cls().config(**data)
except AttributeError as err:
raise ValueError(f"Creating '{full_name(cls)}' object from dictionary "
f"failed: {err}")

@classmethod
def from_json(cls, data):
return cls.from_dict(json.loads(data))
def from_json(cls, source):
"""Create this object based on JSON data.
The data is given as the ``source`` parameter. It can be
- a string (or bytes) containing the data directly,
- an open file object where to read the data, or
- a path (string or ``pathlib.Path``) to a UTF-8 encoded file to read.
The JSON data is first converted to a Python dictionary and the object
created using the :meth:`from_dict` method.
Notice that ``source`` is considered to be JSON data if it is a string
and contains ``{``. If you need to use ``{`` in a file path, pass it in
as a ``pathlib.Path`` instance.
"""
try:
data = JsonLoader().load(source)
except ValueError as err:
raise ValueError(f'Loading JSON data failed: {err}')
return cls.from_dict(data)

def to_dict(self):
"""Serialize this object into a dictionary.
The object can be later restored by using the :meth:`from_dict` method.
"""
raise NotImplementedError

def to_json(self):
return json.dumps(self.to_dict())
def to_json(self, file=None, *, ensure_ascii=False, indent=0,
separators=(',', ':')):
"""Serialize this object into JSON.
The object is first converted to a Python dictionary using the
:meth:`to_dict` method and then the dictionary is converted to JSON.
The ``file`` parameter controls what to do with the resulting JSON data.
It can be
- ``None`` (default) to return the data as a string,
- an open file object where to write the data, or
- a path to a file where to write the data using UTF-8 encoding.
JSON formatting can be configured using optional parameters that
are passed directly to the underlying ``json`` module. Notice that
the defaults differ from what ``json`` uses.
"""
return JsonDumper(ensure_ascii=ensure_ascii, indent=indent,
separators=separators).dump(self.to_dict(), file)

def config(self, **attributes):
"""Configure model object with given attributes.
Expand All @@ -52,13 +97,12 @@ def config(self, **attributes):
for name in attributes:
try:
setattr(self, name, attributes[name])
except AttributeError:
except AttributeError as err:
# Ignore error setting attribute if the object already has it.
# Avoids problems with `to/from_dict` roundtrip with body items
# having unsettable `type` attribute that is needed in dict data.
if getattr(self, name, object()) == attributes[name]:
continue
raise AttributeError
if getattr(self, name, object()) != attributes[name]:
raise AttributeError(f"Setting attribute '{name}' failed: {err}")
return self

def copy(self, **attributes):
Expand Down Expand Up @@ -105,3 +149,48 @@ def full_name(obj):
if len(parts) > 1 and parts[0] == 'robot':
parts[2:-1] = []
return '.'.join(parts)


class JsonLoader:

def load(self, source):
try:
data = self._load(source)
except (json.JSONDecodeError, TypeError) as err:
raise ValueError(f'Invalid JSON data: {err}')
if not isinstance(data, dict):
raise ValueError(f"Expected dictionary, got {type_name(data)}.")
return data

def _load(self, source):
if self._is_path(source):
with open(source, encoding='UTF-8') as file:
return json.load(file)
if hasattr(source, 'read'):
return json.load(source)
return json.loads(source)

def _is_path(self, source):
if isinstance(source, os.PathLike):
return True
if not isinstance(source, str) or '{' in source:
return False
return os.path.isfile(source)


class JsonDumper:

def __init__(self, **config):
self.config = config

def dump(self, data, output=None):
if not output:
return json.dumps(data, **self.config)
elif isinstance(output, (str, pathlib.Path)):
with open(output, 'w', encoding='UTF-8') as file:
json.dump(data, file, **self.config)
elif hasattr(output, 'write'):
json.dump(data, output, **self.config)
else:
raise TypeError(f"Output should be None, open file or path, "
f"got {type_name(output)}.")
116 changes: 108 additions & 8 deletions utest/model/test_modelobject.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,21 @@
import io
import json
import os
import pathlib
import unittest
import tempfile

from robot.model.modelobject import ModelObject
from robot.utils.asserts import assert_equal, assert_raises
from robot.utils.asserts import assert_equal, assert_raises, assert_raises_with_msg


class Example(ModelObject):

def __init__(self, **attrs):
self.__dict__.update(attrs)

def to_dict(self):
return self.__dict__


class TestRepr(unittest.TestCase):
Expand Down Expand Up @@ -36,13 +50,11 @@ def __init__(self, a=1, b=2):
assert_equal(x.b, True)

def test_other_attributes(self):
class X(ModelObject):
pass
x = X.from_dict({'a': 1})
assert_equal(x.a, 1)
x = X.from_json('{"a": null, "b": 42}')
assert_equal(x.a, None)
assert_equal(x.b, 42)
obj = Example.from_dict({'a': 1})
assert_equal(obj.a, 1)
obj = Example.from_json('{"a": null, "b": 42}')
assert_equal(obj.a, None)
assert_equal(obj.b, 42)

def test_not_accepted_attribute(self):
class X(ModelObject):
Expand All @@ -52,6 +64,94 @@ class X(ModelObject):
assert_equal(str(error).split(':')[0],
f"Creating '{__name__}.X' object from dictionary failed")

def test_json_as_bytes(self):
obj = Example.from_json(b'{"a": null, "b": 42}')
assert_equal(obj.a, None)
assert_equal(obj.b, 42)

def test_json_as_open_file(self):
obj = Example.from_json(io.StringIO('{"a": null, "b": 42, "c": "åäö"}'))
assert_equal(obj.a, None)
assert_equal(obj.b, 42)
assert_equal(obj.c, "åäö")

def test_json_as_path(self):
with tempfile.NamedTemporaryFile('w', delete=False) as file:
file.write('{"a": null, "b": 42, "c": "åäö"}')
try:
for path in file.name, pathlib.Path(file.name):
obj = Example.from_json(path)
assert_equal(obj.a, None)
assert_equal(obj.b, 42)
assert_equal(obj.c, "åäö")
finally:
os.remove(file.name)

def test_invalid_json_type(self):
error = self._get_json_load_error(None)
assert_raises_with_msg(
ValueError, f"Loading JSON data failed: Invalid JSON data: {error}",
ModelObject.from_json, None
)

def test_invalid_json_syntax(self):
error = self._get_json_load_error('bad')
assert_raises_with_msg(
ValueError, f"Loading JSON data failed: Invalid JSON data: {error}",
ModelObject.from_json, 'bad'
)

def test_invalid_json_content(self):
assert_raises_with_msg(
ValueError, "Loading JSON data failed: Expected dictionary, got list.",
ModelObject.from_json, '["bad"]'
)

def _get_json_load_error(self, value):
try:
json.loads(value)
except (json.JSONDecodeError, TypeError) as err:
return str(err)


class TestToJson(unittest.TestCase):
data = {'a': 1, 'b': [True, False], 'c': 'nön-äscii'}
default_config = {'ensure_ascii': False, 'indent': 0, 'separators': (',', ':')}
custom_config = {'indent': None, 'separators': (', ', ': '), 'ensure_ascii': True}

def test_default_config(self):
assert_equal(Example(**self.data).to_json(),
json.dumps(self.data, **self.default_config))

def test_custom_config(self):
assert_equal(Example(**self.data).to_json(**self.custom_config),
json.dumps(self.data, **self.custom_config))

def test_write_to_open_file(self):
for config in {}, self.custom_config:
output = io.StringIO()
Example(**self.data).to_json(output, **config)
expected = json.dumps(self.data, **(config or self.default_config))
assert_equal(output.getvalue(), expected)

def test_write_to_path(self):
with tempfile.NamedTemporaryFile(delete=False) as file:
pass
try:
for path in file.name, pathlib.Path(file.name):
for config in {}, self.custom_config:
Example(**self.data).to_json(path, **config)
expected = json.dumps(self.data, **(config or self.default_config))
with open(path) as file:
assert_equal(file.read(), expected)
finally:
os.remove(file.name)

def test_invalid_output(self):
assert_raises_with_msg(TypeError,
"Output should be None, open file or path, got integer.",
Example().to_json, 42)


if __name__ == '__main__':
unittest.main()

0 comments on commit ddfb05b

Please sign in to comment.