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

spesmilo / electrum / 6062151246282752

14 Jul 2025 10:19PM UTC coverage: 59.869% (+0.04%) from 59.827%
6062151246282752

Pull #10016

CirrusCI

f321x
wizard: add unittests for passphrase flow
Pull Request #10016: wizard: qml: separate passphrase input from seed input

1 of 1 new or added line in 1 file covered. (100.0%)

693 existing lines in 9 files now uncovered.

21984 of 36720 relevant lines covered (59.87%)

2.99 hits per line

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

85.21
/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, Optional
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✔
UNCOV
36
    from .storage import WalletStorage
×
37

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

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

51

52
registered_names = {}
5✔
53
registered_dicts = {}
5✔
54
registered_dict_keys = {}
5✔
55
registered_parent_keys = {}
5✔
56

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

60
def register_name(name, method, _type):
5✔
UNCOV
61
    registered_names[name] = method, _type
×
62

63
def register_dict_key(name, method):
5✔
64
    registered_dict_keys[name] = method
5✔
65

66
def register_parent_key(name, method):
5✔
67
    registered_parent_keys[name] = method
5✔
68

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

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

83

84
def key_path(path, key):
5✔
85
    def to_str(x):
5✔
86
        if isinstance(x, int):
5✔
UNCOV
87
            return str(int(x))
×
88
        else:
89
            assert isinstance(x, str)
5✔
90
            return x
5✔
91
    items = [to_str(x) for x in path]
5✔
92
    if key is not None:
5✔
93
        items.append(to_str(key))
5✔
94
    return '/' + '/'.join(items)
5✔
95

96

97
class StoredObject:
5✔
98

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

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

108
    def set_db(self, db, path):
5✔
109
        self.db = db
5✔
110
        self.path = path
5✔
111

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

121

122
_RaiseKeyError = object() # singleton for no-default behavior
5✔
123

124
class StoredDict(dict):
5✔
125

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

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

174
    @locked
5✔
175
    def __delitem__(self, key):
5✔
176
        dict.__delitem__(self, key)
5✔
177
        if self.db:
5✔
178
            self.db.add_patch({'op': 'remove', 'path': key_path(self.path, key)})
5✔
179

180
    @locked
5✔
181
    def pop(self, key, v=_RaiseKeyError):
5✔
182
        if key not in self:
5✔
183
            if v is _RaiseKeyError:
5✔
UNCOV
184
                raise KeyError(key)
×
185
            else:
186
                return v
5✔
187
        r = dict.pop(self, key)
5✔
188
        if self.db:
5✔
189
            self.db.add_patch({'op': 'remove', 'path': key_path(self.path, key)})
5✔
190
        return r
5✔
191

192
    def setdefault(self, key, default = None, /):
5✔
UNCOV
193
        if key not in self:
×
194
            self.__setitem__(key, default)
×
195
        return self[key]
×
196

197

198
class StoredList(list):
5✔
199

200
    def __init__(self, data, db, path):
5✔
201
        list.__init__(self, data)
5✔
202
        self.db = db
5✔
203
        self.lock = self.db.lock if self.db else threading.RLock()
5✔
204
        self.path = path
5✔
205

206
    @locked
5✔
207
    def append(self, item):
5✔
208
        n = len(self)
5✔
209
        list.append(self, item)
5✔
210
        if self.db:
5✔
211
            self.db.add_patch({'op': 'add', 'path': key_path(self.path, '%d'%n), 'value':item})
5✔
212

213
    @locked
5✔
214
    def remove(self, item):
5✔
UNCOV
215
        n = self.index(item)
×
216
        list.remove(self, item)
×
217
        if self.db:
×
218
            self.db.add_patch({'op': 'remove', 'path': key_path(self.path, '%d'%n)})
×
219

220
    @locked
5✔
221
    def clear(self):
5✔
222
        list.clear(self)
5✔
223
        if self.db:
5✔
224
            self.db.add_patch({'op': 'replace', 'path': key_path(self.path, None), 'value':[]})
5✔
225

226

227

228
class JsonDB(Logger):
5✔
229

230
    def __init__(
5✔
231
        self,
232
        s: str,
233
        *,
234
        storage: Optional['WalletStorage'] = None,
235
        encoder=None,
236
        upgrader=None,
237
    ):
238
        Logger.__init__(self)
5✔
239
        self.lock = threading.RLock()
5✔
240
        self.storage = storage
5✔
241
        self.encoder = encoder
5✔
242
        self.pending_changes = []
5✔
243
        self._modified = False
5✔
244
        # load data
245
        data = self.load_data(s)
5✔
246
        if upgrader:
5✔
247
            data, was_upgraded = upgrader(data)
5✔
248
            self._modified |= was_upgraded
5✔
249
        # convert to StoredDict
250
        self.data = StoredDict(data, self, [])
5✔
251
        # write file in case there was a db upgrade
252
        if self.storage and self.storage.file_exists():
5✔
253
            self.write_and_force_consolidation()
5✔
254

255
    def load_data(self, s: str) -> dict:
5✔
256
        if s == '':
5✔
257
            return {}
5✔
258
        try:
5✔
259
            data = json.loads('[' + s + ']')
5✔
260
            data, patches = data[0], data[1:]
5✔
261
        except Exception:
5✔
262
            if r := self.maybe_load_ast_data(s):
5✔
263
                data, patches = r, []
5✔
UNCOV
264
            elif r := self.maybe_load_incomplete_data(s):
×
265
                data, patches = r, []
×
266
            else:
UNCOV
267
                raise WalletFileException("Cannot read wallet file. (parsing failed)")
×
268
        if not isinstance(data, dict):
5✔
UNCOV
269
            raise WalletFileException("Malformed wallet file (not dict)")
×
270
        if patches:
5✔
271
            # apply patches
272
            self.logger.info('found %d patches'%len(patches))
5✔
273
            patch = jsonpatch.JsonPatch(patches)
5✔
274
            data = patch.apply(data)
5✔
275
            self.set_modified(True)
5✔
276
        return data
5✔
277

278
    def maybe_load_ast_data(self, s):
5✔
279
        """ for old wallets """
280
        try:
5✔
281
            import ast
5✔
282
            d = ast.literal_eval(s)
5✔
283
            labels = d.get('labels', {})
5✔
UNCOV
284
        except Exception as e:
×
285
            return
×
286
        data = {}
5✔
287
        for key, value in d.items():
5✔
288
            try:
5✔
289
                json.dumps(key)
5✔
290
                json.dumps(value)
5✔
UNCOV
291
            except Exception:
×
292
                self.logger.info(f'Failed to convert label to json format: {key}')
×
293
                continue
×
294
            data[key] = value
5✔
295
        return data
5✔
296

297
    def maybe_load_incomplete_data(self, s):
5✔
UNCOV
298
        n = s.count('{') - s.count('}')
×
299
        i = len(s)
×
300
        while n > 0 and i > 0:
×
301
            i = i - 1
×
302
            if s[i] == '{':
×
303
                n = n - 1
×
304
            if s[i] == '}':
×
305
                n = n + 1
×
306
            if n == 0:
×
307
                s = s[0:i]
×
308
                assert s[-2:] == ',\n'
×
309
                self.logger.info('found incomplete data {s[i:]}')
×
310
                return self.load_data(s[0:-2])
×
311

312
    def set_modified(self, b):
5✔
313
        with self.lock:
5✔
314
            self._modified = b
5✔
315

316
    def modified(self):
5✔
317
        return self._modified
5✔
318

319
    @locked
5✔
320
    def add_patch(self, patch):
5✔
321
        self.pending_changes.append(json.dumps(patch, cls=self.encoder))
5✔
322
        self.set_modified(True)
5✔
323

324
    @locked
5✔
325
    def get(self, key, default=None):
5✔
326
        v = self.data.get(key)
5✔
327
        if v is None:
5✔
328
            v = default
5✔
329
        return v
5✔
330

331
    @modifier
5✔
332
    def put(self, key, value):
5✔
333
        try:
5✔
334
            json.dumps(key, cls=self.encoder)
5✔
335
            json.dumps(value, cls=self.encoder)
5✔
UNCOV
336
        except Exception:
×
337
            self.logger.info(f"json error: cannot save {repr(key)} ({repr(value)})")
×
338
            return False
×
339
        if value is not None:
5✔
340
            if self.data.get(key) != value:
5✔
341
                self.data[key] = copy.deepcopy(value)
5✔
342
                return True
5✔
343
        elif key in self.data:
5✔
344
            self.data.pop(key)
5✔
345
            return True
5✔
346
        return False
5✔
347

348
    @locked
5✔
349
    def get_dict(self, name) -> dict:
5✔
350
        # Warning: interacts un-intuitively with 'put': certain parts
351
        # of 'data' will have pointers saved as separate variables.
352
        if name not in self.data:
5✔
353
            self.data[name] = {}
5✔
354
        return self.data[name]
5✔
355

356
    @locked
5✔
357
    def get_stored_item(self, key, default) -> dict:
5✔
358
        if key not in self.data:
5✔
359
            self.data[key] = default
5✔
360
        return self.data[key]
5✔
361

362
    @locked
5✔
363
    def dump(self, *, human_readable: bool = True) -> str:
5✔
364
        """Serializes the DB as a string.
365
        'human_readable': makes the json indented and sorted, but this is ~2x slower
366
        """
367
        return json.dumps(
5✔
368
            self.data,
369
            indent=4 if human_readable else None,
370
            sort_keys=bool(human_readable),
371
            cls=self.encoder,
372
        )
373

374
    def _should_convert_to_stored_dict(self, key) -> bool:
5✔
UNCOV
375
        return True
×
376

377
    def _convert_dict(self, path, key, v):
5✔
378
        if key in registered_dicts:
5✔
379
            constructor, _type = registered_dicts[key]
5✔
380
            if _type == dict:
5✔
381
                v = dict((k, constructor(**x)) for k, x in v.items())
5✔
382
            elif _type == tuple:
5✔
383
                v = dict((k, constructor(*x)) for k, x in v.items())
5✔
384
            else:
385
                v = dict((k, constructor(x)) for k, x in v.items())
5✔
386
        if key in registered_dict_keys:
5✔
387
            convert_key = registered_dict_keys[key]
5✔
388
        elif path and path[-1] in registered_parent_keys:
5✔
389
            convert_key = registered_parent_keys.get(path[-1])
5✔
390
        else:
391
            convert_key = None
5✔
392
        if convert_key:
5✔
393
            v = dict((convert_key(k), x) for k, x in v.items())
5✔
394
        return v
5✔
395

396
    def _convert_value(self, path, key, v):
5✔
397
        if key in registered_names:
5✔
398
            constructor, _type = registered_names[key]
5✔
399
            if _type == dict:
5✔
400
                v = constructor(**v)
5✔
401
            else:
402
                v = constructor(v)
5✔
403
        return v
5✔
404

405
    @locked
5✔
406
    def write(self):
5✔
407
        if (not self.storage.file_exists()
5✔
408
                or self.storage.is_encrypted()
409
                or self.storage.needs_consolidation()):
410
            self.write_and_force_consolidation()
5✔
411
        else:
412
            self._append_pending_changes()
5✔
413

414
    @locked
5✔
415
    def _append_pending_changes(self):
5✔
416
        if threading.current_thread().daemon:
5✔
UNCOV
417
            raise Exception('daemon thread cannot write db')
×
418
        if not self.pending_changes:
5✔
419
            self.logger.info('no pending changes')
5✔
420
            return
5✔
421
        self.logger.info(f'appending {len(self.pending_changes)} pending changes')
5✔
422
        s = ''.join([',\n' + x for x in self.pending_changes])
5✔
423
        self.storage.append(s)
5✔
424
        self.pending_changes = []
5✔
425

426
    @locked
5✔
427
    @profiler
5✔
428
    def write_and_force_consolidation(self):
5✔
429
        if threading.current_thread().daemon:
5✔
UNCOV
430
            raise Exception('daemon thread cannot write db')
×
431
        if not self.modified():
5✔
432
            return
5✔
433
        json_str = self.dump(human_readable=not self.storage.is_encrypted())
5✔
434
        self.storage.write(json_str)
5✔
435
        self.pending_changes = []
5✔
436
        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

© 2026 Coveralls, Inc