1# -*- coding: utf-8 -*-
2#
3# gPodder - A media aggregator and podcast client
4# Copyright (c) 2005-2018 The gPodder Team
5#
6# gPodder is free software; you can redistribute it and/or modify
7# it under the terms of the GNU General Public License as published by
8# the Free Software Foundation; either version 3 of the License, or
9# (at your option) any later version.
10#
11# gPodder is distributed in the hope that it will be useful,
12# but WITHOUT ANY WARRANTY; without even the implied warranty of
13# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14# GNU General Public License for more details.
15#
16# You should have received a copy of the GNU General Public License
17# along with this program.  If not, see <http://www.gnu.org/licenses/>.
18#
19
20
21#
22#  jsonconfig.py -- JSON Config Backend
23#  Thomas Perl <thp@gpodder.org>   2012-01-18
24#
25
26import copy
27import json
28from functools import reduce
29
30
31class JsonConfigSubtree(object):
32    def __init__(self, parent, name):
33        self._parent = parent
34        self._name = name
35
36    def __repr__(self):
37        return '<Subtree %r of JsonConfig>' % (self._name,)
38
39    def _attr(self, name):
40        return '.'.join((self._name, name))
41
42    def __getitem__(self, name):
43        return self._parent._lookup(self._name).__getitem__(name)
44
45    def __delitem__(self, name):
46        self._parent._lookup(self._name).__delitem__(name)
47
48    def __setitem__(self, name, value):
49        self._parent._lookup(self._name).__setitem__(name, value)
50
51    def __getattr__(self, name):
52        if name == 'keys':
53            # Kludge for using dict() on a JsonConfigSubtree
54            return getattr(self._parent._lookup(self._name), name)
55
56        return getattr(self._parent, self._attr(name))
57
58    def __setattr__(self, name, value):
59        if name.startswith('_'):
60            object.__setattr__(self, name, value)
61        else:
62            self._parent.__setattr__(self._attr(name), value)
63
64
65class JsonConfig(object):
66    _INDENT = 2
67
68    def __init__(self, data=None, default=None, on_key_changed=None):
69        """
70        Create a new JsonConfig object
71
72        data: A JSON string that contains the data to load (optional)
73        default: A dict that contains default config values (optional)
74        on_key_changed: Callback when a value changes (optional)
75
76        The signature of on_key_changed looks like this:
77
78            func(name, old_value, new_value)
79
80            name: The key name, e.g. "ui.gtk.show_toolbar"
81            old_value: The old value, e.g. False
82            new_value: The new value, e.g. True
83
84        For newly-set keys, on_key_changed is also called. In this case,
85        None will be the old_value:
86
87        >>> def callback(*args): print('callback:', args)
88        >>> c = JsonConfig(on_key_changed=callback)
89        >>> c.a.b = 10
90        callback: ('a.b', None, 10)
91        >>> c.a.b = 11
92        callback: ('a.b', 10, 11)
93        >>> c.x.y.z = [1,2,3]
94        callback: ('x.y.z', None, [1, 2, 3])
95        >>> c.x.y.z = 42
96        callback: ('x.y.z', [1, 2, 3], 42)
97
98        Please note that dict-style access will not call on_key_changed:
99
100        >>> def callback(*args): print('callback:', args)
101        >>> c = JsonConfig(on_key_changed=callback)
102        >>> c.a.b = 1        # This works as expected
103        callback: ('a.b', None, 1)
104        >>> c.a['c'] = 10    # This doesn't call on_key_changed!
105        >>> del c.a['c']     # This also doesn't call on_key_changed!
106        """
107        self._default = default
108        self._data = copy.deepcopy(self._default) or {}
109        self._on_key_changed = on_key_changed
110        if data is not None:
111            self._restore(data)
112
113    def _restore(self, backup):
114        """
115        Restore a previous state saved with repr()
116
117        This function allows you to "snapshot" the current values of
118        the configuration and reload them later on. Any missing
119        default values will be added on top of the restored config.
120
121        Returns True if new keys from the default config have been added,
122        False if no keys have been added (backup contains all default keys)
123
124        >>> c = JsonConfig()
125        >>> c.a.b = 10
126        >>> backup = repr(c)
127        >>> print(c.a.b)
128        10
129        >>> c.a.b = 11
130        >>> print(c.a.b)
131        11
132        >>> c._restore(backup)
133        False
134        >>> print(c.a.b)
135        10
136        """
137        self._data = json.loads(backup)
138        # Add newly-added default configuration options
139        if self._default is not None:
140            return self._merge_keys(self._default)
141
142        return False
143
144    def _merge_keys(self, merge_source):
145        """Merge keys from merge_source into this config object
146
147        Return True if new keys were merged, False otherwise
148        """
149        added_new_key = False
150        # Recurse into the data and add missing items
151        work_queue = [(self._data, merge_source)]
152        while work_queue:
153            data, default = work_queue.pop()
154            for key, value in default.items():
155                if key not in data:
156                    # Copy defaults for missing key
157                    data[key] = copy.deepcopy(value)
158                    added_new_key = True
159                elif isinstance(value, dict):
160                    # Recurse into sub-dictionaries
161                    work_queue.append((data[key], value))
162                elif type(value) != type(data[key]):  # noqa
163                    # Type mismatch of current value and default
164                    if type(value) == int and type(data[key]) == float:
165                        # Convert float to int if default value is int
166                        data[key] = int(data[key])
167
168        return added_new_key
169
170    def __repr__(self):
171        """
172        >>> c = JsonConfig('{"a": 1}')
173        >>> print(c)
174        {
175          "a": 1
176        }
177        """
178        return json.dumps(self._data, indent=self._INDENT, sort_keys=True)
179
180    def _lookup(self, name):
181        return reduce(lambda d, k: d[k], name.split('.'), self._data)
182
183    def _keys_iter(self):
184        work_queue = []
185        work_queue.append(([], self._data))
186        while work_queue:
187            path, data = work_queue.pop(0)
188
189            if isinstance(data, dict):
190                for key in sorted(data.keys()):
191                    work_queue.append((path + [key], data[key]))
192            else:
193                yield '.'.join(path)
194
195    def __getattr__(self, name):
196        try:
197            value = self._lookup(name)
198            if not isinstance(value, dict):
199                return value
200        except KeyError:
201            pass
202
203        return JsonConfigSubtree(self, name)
204
205    def __setattr__(self, name, value):
206        if name.startswith('_'):
207            object.__setattr__(self, name, value)
208            return
209
210        attrs = name.split('.')
211        target_dict = self._data
212
213        while attrs:
214            attr = attrs.pop(0)
215            if not attrs:
216                old_value = target_dict.get(attr, None)
217                if old_value != value or attr not in target_dict:
218                    target_dict[attr] = value
219                    if self._on_key_changed is not None:
220                        self._on_key_changed(name, old_value, value)
221                break
222
223            target = target_dict.get(attr, None)
224            if target is None or not isinstance(target, dict):
225                target_dict[attr] = target = {}
226            target_dict = target
227