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