• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

spesmilo / electrum / 5304010765238272

17 Aug 2023 02:17PM UTC coverage: 59.027% (+0.02%) from 59.008%
5304010765238272

Pull #8493

CirrusCI

ecdsa
storage.append: fail if the file length is not what we expect
Pull Request #8493: partial-writes using jsonpatch

165 of 165 new or added lines in 9 files covered. (100.0%)

18653 of 31601 relevant lines covered (59.03%)

2.95 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

82.31
/electrum/json_db.py
1
#!/usr/bin/env python
2
#
3
# Electrum - lightweight Bitcoin client
4
# Copyright (C) 2019 The Electrum Developers
5
#
6
# Permission is hereby granted, free of charge, to any person
7
# obtaining a copy of this software and associated documentation files
8
# (the "Software"), to deal in the Software without restriction,
9
# including without limitation the rights to use, copy, modify, merge,
10
# publish, distribute, sublicense, and/or sell copies of the Software,
11
# and to permit persons to whom the Software is furnished to do so,
12
# subject to the following conditions:
13
#
14
# The above copyright notice and this permission notice shall be
15
# included in all copies or substantial portions of the Software.
16
#
17
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
18
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
19
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
20
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
21
# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
22
# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
23
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
24
# SOFTWARE.
25
import threading
5✔
26
import copy
5✔
27
import json
5✔
28
from typing import TYPE_CHECKING
5✔
29
import jsonpatch
5✔
30

31
from . import util
5✔
32
from .util import WalletFileException, profiler
5✔
33
from .logging import Logger
5✔
34

35
if TYPE_CHECKING:
5✔
36
    from .storage import WalletStorage
×
37

38
JsonDBJsonEncoder = util.MyEncoder
5✔
39

40
def modifier(func):
5✔
41
    def wrapper(self, *args, **kwargs):
5✔
42
        with self.lock:
5✔
43
            self._modified = True
5✔
44
            return func(self, *args, **kwargs)
5✔
45
    return wrapper
5✔
46

47
def locked(func):
5✔
48
    def wrapper(self, *args, **kwargs):
5✔
49
        with self.lock:
5✔
50
            return func(self, *args, **kwargs)
5✔
51
    return wrapper
5✔
52

53

54
registered_names = {}
5✔
55
registered_dicts = {}
5✔
56
registered_dict_keys = {}
5✔
57
registered_parent_keys = {}
5✔
58

59
def register_dict(name, method, _type):
5✔
60
    registered_dicts[name] = method, _type
5✔
61

62
def register_name(name, method, _type):
5✔
63
    registered_names[name] = method, _type
×
64

65
def register_dict_key(name, method):
5✔
66
    registered_dict_keys[name] = method
5✔
67

68
def register_parent_key(name, method):
5✔
69
    registered_parent_keys[name] = method
5✔
70

71
def stored_as(name, _type=dict):
5✔
72
    """ decorator that indicates the storage key of a stored object"""
73
    def decorator(func):
5✔
74
        registered_names[name] = func, _type
5✔
75
        return func
5✔
76
    return decorator
5✔
77

78
def stored_in(name, _type=dict):
5✔
79
    """ decorator that indicates the storage key of an element in a StoredDict"""
80
    def decorator(func):
5✔
81
        registered_dicts[name] = func, _type
5✔
82
        return func
5✔
83
    return decorator
5✔
84

85

86
def key_path(path, key):
5✔
87
    def to_str(x):
5✔
88
        if isinstance(x, int):
5✔
89
            return str(int(x))
×
90
        else:
91
            assert isinstance(x, str)
5✔
92
            return x
5✔
93
    return '/' + '/'.join([to_str(x) for x in path + [to_str(key)]])
5✔
94

95

96
class StoredObject:
5✔
97

98
    db = None
5✔
99
    path = None
5✔
100

101
    def __setattr__(self, key, value):
5✔
102
        if self.db and key not in ['path', 'db'] and not key.startswith('_'):
5✔
103
            self.db.add_patch({'op': 'replace', 'path': key_path(self.path, key), 'value': value})
×
104
        object.__setattr__(self, key, value)
5✔
105

106
    def set_db(self, db, path):
5✔
107
        self.db = db
5✔
108
        self.path = path
5✔
109

110
    def to_json(self):
5✔
111
        d = dict(vars(self))
5✔
112
        d.pop('db', None)
5✔
113
        d.pop('path', None)
5✔
114
        # don't expose/store private stuff
115
        d = {k: v for k, v in d.items()
5✔
116
             if not k.startswith('_')}
117
        return d
5✔
118

119

120
_RaiseKeyError = object() # singleton for no-default behavior
5✔
121

122
class StoredDict(dict):
5✔
123

124
    def __init__(self, data, db, path):
5✔
125
        self.db = db
5✔
126
        self.lock = self.db.lock if self.db else threading.RLock()
5✔
127
        self.path = path
5✔
128
        # recursively convert dicts to StoredDict
129
        for k, v in list(data.items()):
5✔
130
            self.__setitem__(k, v, patch=False)
5✔
131

132
    @locked
5✔
133
    def __setitem__(self, key, v, patch=True):
5✔
134
        is_new = key not in self
5✔
135
        # early return to prevent unnecessary disk writes
136
        if not is_new and patch:
5✔
137
            if json.dumps(v, cls=JsonDBJsonEncoder) == json.dumps(self[key], cls=JsonDBJsonEncoder):
5✔
138
                return
5✔
139
        # recursively set db and path
140
        if isinstance(v, StoredDict):
5✔
141
            #assert v.db is None
142
            v.db = self.db
5✔
143
            v.path = self.path + [key]
5✔
144
            for k, vv in v.items():
5✔
145
                v.__setitem__(k, vv, patch=False)
×
146
        # recursively convert dict to StoredDict.
147
        # _convert_dict is called breadth-first
148
        elif isinstance(v, dict):
5✔
149
            if self.db:
5✔
150
                v = self.db._convert_dict(self.path, key, v)
5✔
151
            if not self.db or self.db._should_convert_to_stored_dict(key):
5✔
152
                v = StoredDict(v, self.db, self.path + [key])
5✔
153
        # convert_value is called depth-first
154
        if isinstance(v, dict) or isinstance(v, str) or isinstance(v, int):
5✔
155
            if self.db:
5✔
156
                v = self.db._convert_value(self.path, key, v)
5✔
157
        # set parent of StoredObject
158
        if isinstance(v, StoredObject):
5✔
159
            v.set_db(self.db, self.path + [key])
5✔
160
        # convert lists
161
        if isinstance(v, list):
5✔
162
            v = StoredList(v, self.db, self.path + [key])
5✔
163
        # set item
164
        dict.__setitem__(self, key, v)
5✔
165
        if self.db and patch:
5✔
166
            op = 'add' if is_new else 'replace'
5✔
167
            self.db.add_patch({'op': op, 'path': key_path(self.path, key), 'value': v})
5✔
168

169
    @locked
5✔
170
    def __delitem__(self, key):
5✔
171
        dict.__delitem__(self, key)
5✔
172
        if self.db:
5✔
173
            self.db.add_patch({'op': 'remove', 'path': key_path(self.path, key)})
×
174

175
    @locked
5✔
176
    def pop(self, key, v=_RaiseKeyError):
5✔
177
        if key not in self:
5✔
178
            if v is _RaiseKeyError:
5✔
179
                raise KeyError(key)
×
180
            else:
181
                return v
5✔
182
        r = dict.pop(self, key)
5✔
183
        if self.db:
5✔
184
            self.db.add_patch({'op': 'remove', 'path': key_path(self.path, key)})
5✔
185
        return r
5✔
186

187

188
class StoredList(list):
5✔
189

190
    def __init__(self, data, db, path):
5✔
191
        list.__init__(self, data)
5✔
192
        self.db = db
5✔
193
        self.lock = self.db.lock if self.db else threading.RLock()
5✔
194
        self.path = path
5✔
195

196
    @locked
5✔
197
    def append(self, item):
5✔
198
        n = len(self)
5✔
199
        list.append(self, item)
5✔
200
        if self.db:
5✔
201
            self.db.add_patch({'op': 'add', 'path': key_path(self.path, '%d'%n), 'value':item})
5✔
202

203
    @locked
5✔
204
    def remove(self, item):
5✔
205
        n = self.index(item)
×
206
        list.remove(self, item)
×
207
        if self.db:
×
208
            self.db.add_patch({'op': 'remove', 'path': key_path(self.path, '%d'%n)})
×
209

210

211

212
class JsonDB(Logger):
5✔
213

214
    def __init__(self, data, storage=None):
5✔
215
        Logger.__init__(self)
5✔
216
        self.lock = threading.RLock()
5✔
217
        self.storage = storage
5✔
218
        self.pending_changes = []
5✔
219
        self._modified = False
5✔
220
        # load data
221
        if data:
5✔
222
            self.load_data(data)
5✔
223
        else:
224
            self.data = {}
5✔
225

226
    def load_data(self, s):
5✔
227
        try:
5✔
228
            data = json.loads('[' + s + ']')
5✔
229
            self.data, patches = data[0], data[1:]
5✔
230
        except Exception:
5✔
231
            if self.maybe_load_incomplete_data(s):
5✔
232
                return
×
233
            raise WalletFileException("Cannot read wallet file. (parsing failed)")
5✔
234
        if not isinstance(self.data, dict):
5✔
235
            raise WalletFileException("Malformed wallet file (not dict)")
×
236
        if patches:
5✔
237
            # apply patches
238
            self.logger.info('found %d patches'%len(patches))
×
239
            patch = jsonpatch.JsonPatch(patches)
×
240
            self.data = patch.apply(self.data)
×
241
            self.set_modified(True)
×
242
            # if file exists, consolidate pending changes
243
            if self.storage and self.storage.file_exists():
×
244
                self._write()
×
245

246
    def maybe_load_incomplete_data(self, s):
5✔
247
        n = s.count('{') - s.count('}')
5✔
248
        i = len(s)
5✔
249
        while n > 0 and i > 0:
5✔
250
            i = i - 1
×
251
            if s[i] == '{':
×
252
                n = n - 1
×
253
            if s[i] == '}':
×
254
                n = n + 1
×
255
            if n == 0:
×
256
                s = s[0:i]
×
257
                assert s[-2:] == ',\n'
×
258
                self.load_data(s[0:-2])
×
259
                self.logger.info('found incomplete data {s[i:]}')
×
260
                self._modified = True
×
261
                self._write()
×
262
                return True
×
263
        return False
5✔
264

265
    def set_modified(self, b):
5✔
266
        with self.lock:
5✔
267
            self._modified = b
5✔
268

269
    def modified(self):
5✔
270
        return self._modified
5✔
271

272
    @locked
5✔
273
    def add_patch(self, patch):
5✔
274
        self.pending_changes.append(json.dumps(patch, cls=JsonDBJsonEncoder))
5✔
275

276
    @locked
5✔
277
    def get(self, key, default=None):
5✔
278
        v = self.data.get(key)
5✔
279
        if v is None:
5✔
280
            v = default
5✔
281
        return v
5✔
282

283
    @modifier
5✔
284
    def put(self, key, value):
5✔
285
        try:
5✔
286
            json.dumps(key, cls=JsonDBJsonEncoder)
5✔
287
            json.dumps(value, cls=JsonDBJsonEncoder)
5✔
288
        except Exception:
×
289
            self.logger.info(f"json error: cannot save {repr(key)} ({repr(value)})")
×
290
            return False
×
291
        if value is not None:
5✔
292
            if self.data.get(key) != value:
5✔
293
                self.data[key] = copy.deepcopy(value)
5✔
294
                return True
5✔
295
        elif key in self.data:
5✔
296
            self.data.pop(key)
5✔
297
            return True
5✔
298
        return False
5✔
299

300
    @locked
5✔
301
    def get_dict(self, name) -> dict:
5✔
302
        # Warning: interacts un-intuitively with 'put': certain parts
303
        # of 'data' will have pointers saved as separate variables.
304
        if name not in self.data:
5✔
305
            self.data[name] = {}
5✔
306
        return self.data[name]
5✔
307

308
    @locked
5✔
309
    def get_stored_item(self, key, default) -> dict:
5✔
310
        if key not in self.data:
×
311
            self.data[key] = default
×
312
        return self.data[key]
×
313

314
    @locked
5✔
315
    def dump(self, *, human_readable: bool = True) -> str:
5✔
316
        """Serializes the DB as a string.
317
        'human_readable': makes the json indented and sorted, but this is ~2x slower
318
        """
319
        return json.dumps(
5✔
320
            self.data,
321
            indent=4 if human_readable else None,
322
            sort_keys=bool(human_readable),
323
            cls=JsonDBJsonEncoder,
324
        )
325

326
    def _should_convert_to_stored_dict(self, key) -> bool:
5✔
327
        return True
×
328

329
    def _convert_dict(self, path, key, v):
5✔
330
        if key in registered_dicts:
5✔
331
            constructor, _type = registered_dicts[key]
5✔
332
            if _type == dict:
5✔
333
                v = dict((k, constructor(**x)) for k, x in v.items())
5✔
334
            elif _type == tuple:
5✔
335
                v = dict((k, constructor(*x)) for k, x in v.items())
5✔
336
            else:
337
                v = dict((k, constructor(x)) for k, x in v.items())
5✔
338
        if key in registered_dict_keys:
5✔
339
            convert_key = registered_dict_keys[key]
×
340
        elif path and path[-1] in registered_parent_keys:
5✔
341
            convert_key = registered_parent_keys.get(path[-1])
×
342
        else:
343
            convert_key = None
5✔
344
        if convert_key:
5✔
345
            v = dict((convert_key(k), x) for k, x in v.items())
×
346
        return v
5✔
347

348
    def _convert_value(self, path, key, v):
5✔
349
        if key in registered_names:
5✔
350
            constructor, _type = registered_names[key]
5✔
351
            if _type == dict:
5✔
352
                v = constructor(**v)
5✔
353
            else:
354
                v = constructor(v)
×
355
        return v
5✔
356

357
    @locked
5✔
358
    def write(self):
5✔
359
        if self.storage.file_exists() and not self.storage.is_encrypted():
5✔
360
            self._append_pending_changes()
5✔
361
        else:
362
            self._write()
5✔
363

364
    @locked
5✔
365
    def _append_pending_changes(self):
5✔
366
        if threading.current_thread().daemon:
5✔
367
            self.logger.warning('daemon thread cannot write db')
×
368
            return
×
369
        if not self.pending_changes:
5✔
370
            self.logger.info('no pending changes')
×
371
            return
×
372
        self.logger.info(f'appending {len(self.pending_changes)} pending changes')
5✔
373
        s = ''.join([',\n' + x for x in self.pending_changes])
5✔
374
        self.storage.append(s)
5✔
375
        self.pending_changes = []
5✔
376

377
    @locked
5✔
378
    @profiler
5✔
379
    def _write(self):
5✔
380
        if threading.current_thread().daemon:
5✔
381
            self.logger.warning('daemon thread cannot write db')
×
382
            return
×
383
        if not self.modified():
5✔
384
            return
5✔
385
        json_str = self.dump(human_readable=not self.storage.is_encrypted())
5✔
386
        self.storage.write(json_str)
5✔
387
        self.pending_changes = []
5✔
388
        self.set_modified(False)
5✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc