aboutsummaryrefslogtreecommitdiff
path: root/diplomacy/utils/jsonable.py
blob: 0ce1a21009df741a270190940e5b8e68c1a063ad (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
# ==============================================================================
# Copyright (C) 2019 - Philip Paquette, Steven Bocco
#
#  This program is free software: you can redistribute it and/or modify it under
#  the terms of the GNU Affero General Public License as published by the Free
#  Software Foundation, either version 3 of the License, or (at your option) any
#  later version.
#
#  This program is distributed in the hope that it will be useful, but WITHOUT
#  ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
#  FOR A PARTICULAR PURPOSE.  See the GNU Affero General Public License for more
#  details.
#
#  You should have received a copy of the GNU Affero General Public License along
#  with this program.  If not, see <https://www.gnu.org/licenses/>.
# ==============================================================================
""" Abstract Jsonable class with automatic attributes checking and conversion to/from JSON dict.
    To write a Jsonable sub-class:
    - Define a model with expected attribute names and types. Use module `parsing` to describe expected types.
    - Override initializer __init__(**kwargs):
      - **first**: initialize each attribute defined in model with value None.
      - **then** : call parent __init__() method. Attributes will be checked and filled by
        Jsonable's __init__() method.
      - If needed, add further initialization code after call to parent __init__() method. At this point,
        attributes were correctly set based on defined model, and you can now work with them.

    Example:
    ```
    class MyClass(Jsonable):
        model = {
            'my_attribute': parsing.Sequence(int),
        }
        def __init__(**kwargs):
            self.my_attribute = None
            super(MyClass, self).__init__(**kwargs)
            # my_attribute is now initialized based on model. You can then do any further initialization if needed.
    ```
"""
import logging
import ujson as json

from diplomacy.utils import exceptions, parsing

LOGGER = logging.getLogger(__name__)

class Jsonable():
    """ Abstract class to ease conversion from/to JSON dict. """
    __slots__ = []
    __cached__models__ = {}
    model = {}

    def __init__(self, **kwargs):
        """ Validates given arguments, update them if necessary (e.g. to add default values),
            and fill instance attributes with updated argument.
            If a derived class adds new attributes, it must override __init__() method and
            initialize new attributes (e.g. `self.attribute = None`)
            **BEFORE** calling parent __init__() method.

            :param kwargs: arguments to build class. Must match keys and values types defined in model.
        """
        model = self.get_model()

        # Adding default value
        updated_kwargs = {model_key: None for model_key in model}
        updated_kwargs.update(kwargs)

        # Validating and updating
        try:
            parsing.validate_data(updated_kwargs, model)
        except exceptions.TypeException as exception:
            LOGGER.error('Error occurred while building class %s', self.__class__)
            raise exception
        updated_kwargs = parsing.update_data(updated_kwargs, model)

        # Building.
        for model_key in model:
            setattr(self, model_key, updated_kwargs[model_key])

    def json(self):
        """ Convert this object to a JSON string ready to be sent/saved.
            :return: string
        """
        return json.dumps(self.to_dict())

    def to_dict(self):
        """ Convert this object to a python dictionary ready for any JSON work.
            :return: dict
        """
        model = self.get_model()
        return {key: parsing.to_json(getattr(self, key), key_type) for key, key_type in model.items()}

    @classmethod
    def update_json_dict(cls, json_dict):
        """ Update a JSON dictionary before being parsed with class model.
            JSON dictionary is passed by class method from_dict() (see below), and is guaranteed to contain
            at least all expected model keys. Some keys may be associated to None if initial JSON dictionary
            did not provide values for them.
            :param json_dict: a JSON dictionary to be parsed.
            :type json_dict: dict
        """

    @classmethod
    def from_dict(cls, json_dict):
        """ Convert a JSON dictionary to an instance of this class.
            :param json_dict: a JSON dictionary to parse. Dictionary with basic types (int, bool, dict, str, None, etc.)
            :return: an instance from this class or from a derived one from which it's called.
            :rtype: cls
        """
        model = cls.get_model()

        # json_dict must be a a dictionary
        if not isinstance(json_dict, dict):
            raise exceptions.TypeException(dict, type(json_dict))

        # By default, we set None for all expected keys
        default_json_dict = {key: None for key in model}
        default_json_dict.update(json_dict)
        cls.update_json_dict(json_dict)

        # Building this object
        # NB: We don't care about extra keys in provided dict, we just focus on expected keys, nothing more.
        kwargs = {key: parsing.to_type(default_json_dict[key], key_type) for key, key_type in model.items()}
        return cls(**kwargs)

    @classmethod
    def build_model(cls):
        """ Return model associated to current class. You can either define model class field
            or override this function.
        """
        return cls.model

    @classmethod
    def get_model(cls):
        """ Return model associated to current class, and cache it for future uses, to avoid
            multiple rendering of model for each class derived from Jsonable. Private method.
            :return: dict: model associated to current class.
        """
        if cls not in cls.__cached__models__:
            cls.__cached__models__[cls] = cls.build_model()
        return cls.__cached__models__[cls]