json-database 0.8.0a1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,387 @@
1
+ import json
2
+ import logging
3
+ import os
4
+ from os import makedirs, remove
5
+ from os.path import expanduser, isdir, dirname, exists, isfile, join
6
+ from pprint import pprint
7
+ from tempfile import gettempdir
8
+
9
+ from combo_lock import ComboLock
10
+
11
+ from json_database.crypto import decrypt_from_json, encrypt_as_json
12
+ from json_database.exceptions import InvalidItemID, DatabaseNotCommitted, \
13
+ SessionError, MatchError
14
+ from json_database.utils import DummyLock, load_commented_json, merge_dict, \
15
+ jsonify_recursively, get_key_recursively, get_key_recursively_fuzzy, \
16
+ get_value_recursively_fuzzy, get_value_recursively
17
+ from json_database.xdg_utils import xdg_cache_home, xdg_data_home, xdg_config_home
18
+
19
+ LOG = logging.getLogger("JsonDatabase")
20
+ LOG.setLevel("INFO")
21
+
22
+
23
+ class JsonStorage(dict):
24
+ """
25
+ persistent python dict
26
+ """
27
+
28
+ def __init__(self, path, disable_lock=False):
29
+ super().__init__()
30
+ lock_path = join(gettempdir(), path.split("/")[-1] + ".lock")
31
+ if disable_lock:
32
+ self.lock = DummyLock(lock_path)
33
+ else:
34
+ self.lock = ComboLock(lock_path)
35
+ self.path = path
36
+ if self.path:
37
+ self.load_local(self.path)
38
+
39
+ def load_local(self, path):
40
+ """
41
+ Load local json file into self.
42
+
43
+ Args:
44
+ path (str): file to load
45
+ """
46
+ with self.lock:
47
+ path = expanduser(path)
48
+ if exists(path) and isfile(path):
49
+ self.clear()
50
+ try:
51
+ config = load_commented_json(path)
52
+ for key in config:
53
+ self[key] = config[key]
54
+ LOG.debug("Json {} loaded".format(path))
55
+ except Exception as e:
56
+ LOG.error("Error loading json '{}'".format(path))
57
+ LOG.error(repr(e))
58
+ else:
59
+ LOG.debug("Json '{}' not defined, skipping".format(path))
60
+
61
+ def clear(self):
62
+ for k in dict(self):
63
+ self.pop(k)
64
+
65
+ def reload(self):
66
+ if exists(self.path) and isfile(self.path):
67
+ self.load_local(self.path)
68
+ else:
69
+ raise DatabaseNotCommitted
70
+
71
+ def store(self, path=None):
72
+ """
73
+ store the json db locally.
74
+ """
75
+ with self.lock:
76
+ path = path or self.path
77
+ if not path:
78
+ LOG.warning("json db path not set")
79
+ return
80
+ path = expanduser(path)
81
+ if dirname(path) and not isdir(dirname(path)):
82
+ makedirs(dirname(path))
83
+ with open(path, 'w', encoding="utf-8") as f:
84
+ json.dump(self, f, indent=4, ensure_ascii=False)
85
+
86
+ def remove(self):
87
+ with self.lock:
88
+ if isfile(self.path):
89
+ remove(self.path)
90
+
91
+ def merge(self, conf, merge_lists=True, skip_empty=True, no_dupes=True,
92
+ new_only=False):
93
+ merge_dict(self, conf, merge_lists, skip_empty, no_dupes, new_only)
94
+ return self
95
+
96
+ def __enter__(self):
97
+ """ Context handler """
98
+ return self
99
+
100
+ def __exit__(self, _type, value, traceback):
101
+ """ Commits changes and Closes the session """
102
+ try:
103
+ self.store()
104
+ except Exception as e:
105
+ LOG.error(e)
106
+ raise SessionError
107
+
108
+
109
+ class EncryptedJsonStorage(JsonStorage):
110
+ """persistent python dict, stored AES encrypted to file"""
111
+
112
+ def __init__(self, encrypt_key: str, path: str, disable_lock=False):
113
+ assert len(encrypt_key) == 16
114
+ self.encrypt_key = encrypt_key
115
+ super().__init__(path, disable_lock)
116
+
117
+ def load_local(self, path):
118
+ """
119
+ Load local json file into self.
120
+
121
+ Args:
122
+ path (str): file to load
123
+ """
124
+ super().load_local(path)
125
+ # decrypt after load
126
+ if self:
127
+ decrypted = json.loads(decrypt_from_json(self.encrypt_key, dict(self)))
128
+ self.clear()
129
+ self.update(decrypted)
130
+
131
+ def store(self, path=None):
132
+ """
133
+ store the json db locally.
134
+ """
135
+ decrypted = dict(self)
136
+ encrypted = json.loads(encrypt_as_json(self.encrypt_key, decrypted))
137
+ self.clear()
138
+ self.merge(encrypted) # encrypt before storage
139
+ super().store()
140
+ self.clear()
141
+ self.update(decrypted) # keep it decrypted in memory
142
+
143
+
144
+ class JsonDatabase(dict):
145
+ """ searchable persistent dict """
146
+
147
+ def __init__(self,
148
+ name,
149
+ path=None,
150
+ disable_lock=False,
151
+ extension="json"):
152
+ super().__init__()
153
+ self.name = name
154
+ self.path = path or f"{name}.{extension}"
155
+ self.db = JsonStorage(self.path, disable_lock=disable_lock)
156
+ self.db[name] = []
157
+ self.db.load_local(self.path)
158
+
159
+ # operator overloads
160
+ def __enter__(self):
161
+ """ Context handler """
162
+ return self
163
+
164
+ def __exit__(self, _type, value, traceback):
165
+ """ Commits changes and Closes the session """
166
+ try:
167
+ self.commit()
168
+ except Exception as e:
169
+ LOG.error(e)
170
+ raise SessionError
171
+
172
+ def __repr__(self):
173
+ return str(jsonify_recursively(self))
174
+
175
+ def __len__(self):
176
+ return len(self.db.get(self.name, []))
177
+
178
+ def __getitem__(self, item):
179
+ if not isinstance(item, int):
180
+ try:
181
+ item_id = int(item)
182
+ except Exception as e:
183
+ item_id = self.get_item_id(item)
184
+ if item_id < 0:
185
+ raise InvalidItemID
186
+ else:
187
+ item_id = item
188
+ if item_id >= len(self.db[self.name]):
189
+ raise InvalidItemID
190
+ return self.db[self.name][item_id]
191
+
192
+ def __setitem__(self, item_id, value):
193
+ if not isinstance(item_id, int) or item_id >= len(self) or item_id < 0:
194
+ raise InvalidItemID
195
+ else:
196
+ self.update_item(item_id, value)
197
+
198
+ def __iter__(self):
199
+ for item in self.db[self.name]:
200
+ yield item
201
+
202
+ def __contains__(self, item):
203
+ item = jsonify_recursively(item)
204
+ return item in self.db[self.name]
205
+
206
+ # database
207
+ def commit(self):
208
+ """
209
+ store the json db locally.
210
+ """
211
+ self.db.store(self.path)
212
+
213
+ def reset(self):
214
+ self.db.reload()
215
+
216
+ def print(self):
217
+ pprint(jsonify_recursively(self))
218
+
219
+ # item manipulations
220
+ def append(self, value):
221
+ value = jsonify_recursively(value)
222
+ self.db[self.name].append(value)
223
+ return len(self)
224
+
225
+ def add_item(self, value, allow_duplicates=False):
226
+ """ add an item to database
227
+ if allow_duplicates is True, item is added unconditionally,
228
+ else only if no exact match is present
229
+ """
230
+ if allow_duplicates or value not in self:
231
+ self.append(value)
232
+ return len(self)
233
+ return self.get_item_id(value)
234
+
235
+ def match_item(self, value, match_strategy=None):
236
+ """ match value to some item in database
237
+ returns a list of matched items
238
+ """
239
+ value = jsonify_recursively(value)
240
+ matches = []
241
+ for idx, item in enumerate(self):
242
+
243
+ # TODO match strategy
244
+ # - require exact match
245
+ # - require list of keys to match
246
+ # - require at least one of key list to match
247
+ # - require at exactly one of key list to match
248
+
249
+ # by default check for exact matches
250
+ if item == value:
251
+ matches.append((item, idx))
252
+
253
+ return matches
254
+
255
+ def merge_item(self, value, item_id=None, match_strategy=None,
256
+ merge_strategy=None):
257
+ """ search an item according to match criteria, merge fields"""
258
+ if item_id is None:
259
+ matches = self.match_item(value, match_strategy)
260
+ if not matches:
261
+ raise MatchError
262
+ match, item_id = matches[0][1]
263
+ else:
264
+ match = self[item_id]
265
+ # TODO merge strategy
266
+ # - only merge some keys
267
+ # - dont merge some keys
268
+ # - merge all keys
269
+ # - dont overwrite keys
270
+ value = jsonify_recursively(value)
271
+ self[item_id] = merge_dict(match, value)
272
+
273
+ def replace_item(self, value, item_id=None, match_strategy=None):
274
+ """ search an item according to match criteria, replace it"""
275
+ if item_id is None:
276
+ matches = self.match_item(value, match_strategy)
277
+ if not matches:
278
+ raise MatchError
279
+ match, item_id = matches[0][1]
280
+ value = jsonify_recursively(value)
281
+ self[item_id] = value
282
+
283
+ # item_id
284
+ def get_item_id(self, item):
285
+ """
286
+ item_id is simply the index of the item in the database
287
+ WARNING: this is not immutable across sessions
288
+ """
289
+ for match, idx in self.match_item(item):
290
+ return idx
291
+ return -1
292
+
293
+ def update_item(self, item_id, new_item):
294
+ """
295
+ item_id is simply the index of the item in the database
296
+ WARNING: this is not immutable across sessions
297
+ """
298
+ new_item = jsonify_recursively(new_item)
299
+ self.db[self.name][item_id] = new_item
300
+
301
+ def remove_item(self, item_id):
302
+ """
303
+ item_id is simply the index of the item in the database
304
+ WARNING: this is not immutable across sessions
305
+ """
306
+ return self.db[self.name].pop(item_id)
307
+
308
+ # search
309
+ def search_by_key(self, key, fuzzy=False, thresh=0.7, include_empty=False):
310
+ if fuzzy:
311
+ return get_key_recursively_fuzzy(self.db, key, thresh, not include_empty)
312
+ return get_key_recursively(self.db, key, not include_empty)
313
+
314
+ def search_by_value(self, key, value, fuzzy=False, thresh=0.7):
315
+ if fuzzy:
316
+ return get_value_recursively_fuzzy(self.db, key, value, thresh)
317
+ return get_value_recursively(self.db, key, value)
318
+
319
+
320
+ # XDG aware classes
321
+
322
+ class JsonStorageXDG(JsonStorage):
323
+ """ xdg respectful persistent dicts """
324
+
325
+ def __init__(self,
326
+ name,
327
+ xdg_folder=xdg_cache_home(),
328
+ disable_lock=False, subfolder="json_database",
329
+ extension="json"):
330
+ self.name = name
331
+ path = join(xdg_folder, subfolder, f"{name}.{extension}")
332
+ super().__init__(path, disable_lock=disable_lock)
333
+
334
+
335
+ class EncryptedJsonStorageXDG(EncryptedJsonStorage):
336
+ """ xdg respectful persistent dicts """
337
+
338
+ def __init__(self,
339
+ encrypt_key: str,
340
+ name: str,
341
+ xdg_folder=xdg_data_home(),
342
+ disable_lock=False,
343
+ subfolder="json_database",
344
+ extension="ejson"):
345
+ self.name = name
346
+ path = join(xdg_folder, subfolder, f"{name}.{extension}")
347
+ super().__init__(encrypt_key=encrypt_key, path=path,
348
+ disable_lock=disable_lock)
349
+
350
+
351
+ class JsonDatabaseXDG(JsonDatabase):
352
+ """ xdg respectful json database """
353
+
354
+ def __init__(self, name, xdg_folder=xdg_data_home(),
355
+ disable_lock=False, subfolder="json_database",
356
+ extension="jsondb"):
357
+ path = join(xdg_folder, subfolder, f"{name}.{extension}")
358
+ super().__init__(name, path, disable_lock=disable_lock, extension=extension)
359
+
360
+
361
+ class JsonConfigXDG(JsonStorageXDG):
362
+ """ xdg respectful config files, using json_storage.JsonStorageXDG """
363
+
364
+ def __init__(self, name, xdg_folder=xdg_config_home(),
365
+ disable_lock=False, subfolder="json_database",
366
+ extension="json"):
367
+ super().__init__(name, xdg_folder, disable_lock, subfolder, extension)
368
+
369
+
370
+ if __name__ == "__main__":
371
+ # quick test
372
+ os.remove("/tmp/test.json")
373
+ db = EncryptedJsonStorage("S" * 16, "/tmp/test.json")
374
+ db["A"] = "42"
375
+ print(db) # {'A': '42'} - not encrypted in memory
376
+ db.store()
377
+ print(db) # {'A': '42'} - still decrypted
378
+ db.reload()
379
+ print(db) # {'A': '42'} - still decrypted
380
+ db = EncryptedJsonStorage("S" * 16, "/tmp/test.json")
381
+ print(db) # {'A': '42'} - still not encrypted
382
+
383
+ db = JsonStorage("/tmp/test.json")
384
+ print(db) # encrypted
385
+ # {'ciphertext': 'ad0da72dc412d6b1240e478560354893d62caf',
386
+ # 'tag': '3bc39dbfad7b0d7e50f3e652ee341819',
387
+ # 'nonce': '3020ddafc9853e7686ee0368f9be6e25'}
@@ -0,0 +1,80 @@
1
+ import json
2
+ import zlib
3
+ from binascii import hexlify
4
+ from binascii import unhexlify
5
+
6
+ try:
7
+ # pycryptodomex
8
+ from Cryptodome.Cipher import AES
9
+ except ImportError:
10
+ # pycrypto + pycryptodome
11
+ try:
12
+ from Crypto.Cipher import AES
13
+ except:
14
+ AES = None
15
+
16
+
17
+ def encrypt(key, text, nonce=None):
18
+ if AES is None:
19
+ raise ImportError("run pip install pycryptodomex")
20
+ if not isinstance(text, bytes):
21
+ text = bytes(text, encoding="utf-8")
22
+ if not isinstance(key, bytes):
23
+ key = bytes(key, encoding="utf-8")
24
+ cipher = AES.new(key, AES.MODE_GCM, nonce=nonce)
25
+ text = compress_payload(text)
26
+ ciphertext, tag = cipher.encrypt_and_digest(text)
27
+ return ciphertext, tag, cipher.nonce
28
+
29
+
30
+ def decrypt(key, ciphertext, tag, nonce) -> str:
31
+ if AES is None:
32
+ raise ImportError("run pip install pycryptodomex")
33
+ if not isinstance(key, bytes):
34
+ key = bytes(key, encoding="utf-8")
35
+ cipher = AES.new(key, AES.MODE_GCM, nonce)
36
+ data = cipher.decrypt_and_verify(ciphertext, tag)
37
+ text = decompress_payload(data).decode(encoding="utf-8")
38
+ return text
39
+
40
+
41
+ def encrypt_as_json(key, data):
42
+ if isinstance(data, dict):
43
+ data = json.dumps(data)
44
+ if len(key) > 16:
45
+ key = key[0:16]
46
+ ciphertext, tag, nonce = encrypt(key, data)
47
+ return json.dumps({"ciphertext": hexlify(ciphertext).decode('utf-8'),
48
+ "tag": hexlify(tag).decode('utf-8'),
49
+ "nonce": hexlify(nonce).decode('utf-8')})
50
+
51
+
52
+ def decrypt_from_json(key, data):
53
+ if isinstance(data, str):
54
+ data = json.loads(data)
55
+ if len(key) > 16:
56
+ key = key[0:16]
57
+ ciphertext = unhexlify(data["ciphertext"])
58
+ if data.get("tag") is None: # web crypto
59
+ ciphertext, tag = ciphertext[:-16], ciphertext[-16:]
60
+ else:
61
+ tag = unhexlify(data["tag"])
62
+ nonce = unhexlify(data["nonce"])
63
+ return decrypt(key, ciphertext, tag, nonce)
64
+
65
+
66
+ def compress_payload(text):
67
+ # Compressing text
68
+ if isinstance(text, str):
69
+ decompressed = text.encode("utf-8")
70
+ else:
71
+ decompressed = text
72
+ return zlib.compress(decompressed)
73
+
74
+
75
+ def decompress_payload(compressed):
76
+ # Decompressing text
77
+ if isinstance(compressed, str):
78
+ # assume hex
79
+ compressed = unhexlify(compressed)
80
+ return zlib.decompress(compressed)
@@ -0,0 +1,22 @@
1
+ class InvalidItemID(ValueError):
2
+ """ ItemID is invalid """
3
+
4
+
5
+ class DatabaseNotCommitted(FileNotFoundError):
6
+ """ Database has not been saved in yet """
7
+
8
+
9
+ class SessionError(RuntimeError):
10
+ """ Could not commit database"""
11
+
12
+
13
+ class MatchError(ValueError):
14
+ """ could not match an item in db """
15
+
16
+
17
+ class DecryptionKeyError(KeyError):
18
+ """ Could not decrypt payload """
19
+
20
+
21
+ class EncryptionKeyError(KeyError):
22
+ """ Could not encrypt payload """
@@ -0,0 +1,169 @@
1
+ from json_database.utils import fuzzy_match, match_one
2
+ from json_database import JsonDatabase
3
+
4
+
5
+ class Query:
6
+ def __init__(self, db):
7
+ if isinstance(db, JsonDatabase):
8
+ self.result = list(db)
9
+ else:
10
+ self.result = [db]
11
+
12
+ def contains_key(self, key, fuzzy=False, thresh=0.7, ignore_case=False):
13
+ if fuzzy:
14
+ after = []
15
+ for e in self.result:
16
+ filter = True
17
+ for k in e:
18
+ if ignore_case:
19
+ score = fuzzy_match(k.lower(), key.lower())
20
+ else:
21
+ score = fuzzy_match(k, key)
22
+ if score < thresh:
23
+ continue
24
+ filter = False
25
+ if not filter:
26
+ after.append(e)
27
+ self.result = after
28
+ elif ignore_case:
29
+ self.result = [a for a in self.result
30
+ if a.get(key) or a.get(key.lower())]
31
+ else:
32
+ self.result = [a for a in self.result if a.get(key)]
33
+ return self
34
+
35
+ def contains_value(self, key, value, fuzzy=False, thresh=0.75, ignore_case=False):
36
+ self.contains_key(key, ignore_case=ignore_case)
37
+ if fuzzy:
38
+ after = []
39
+ for e in self.result:
40
+ if isinstance(e[key], str):
41
+ if ignore_case:
42
+ score = fuzzy_match(value.lower(), e[key].lower())
43
+ else:
44
+ score = fuzzy_match(value, e[key])
45
+ if score > thresh:
46
+ after.append(e)
47
+ elif isinstance(e[key], list):
48
+ if ignore_case:
49
+ v, score = match_one(value.lower(),
50
+ [_.lower() for _ in e[key]])
51
+ else:
52
+ v, score = match_one(value, e[key])
53
+ if score < thresh:
54
+ continue
55
+ after.append(e)
56
+ elif isinstance(e[key], dict):
57
+ if ignore_case:
58
+ v, score = match_one(value.lower(),
59
+ [_.lower() for _ in e[key].keys()])
60
+ else:
61
+ v, score = match_one(value, e[key])
62
+ if score < thresh:
63
+ continue
64
+ after.append(e)
65
+ self.result = after
66
+ elif ignore_case and isinstance(value, str):
67
+ after = []
68
+ for a in self.result:
69
+ if isinstance(a[key], str) and value.lower() in a[key].lower():
70
+ after.append(a)
71
+ elif value.lower() in a[key] or value in a[key]:
72
+ after.append(a)
73
+ self.result = after
74
+ else:
75
+ self.result = [a for a in self.result if value in a[key]]
76
+ return self
77
+
78
+ def value_contains(self, key, value, ignore_case=False):
79
+ self.contains_key(key, ignore_case=ignore_case)
80
+ if ignore_case:
81
+ after = []
82
+ value = str(value).lower()
83
+ for e in self.result:
84
+ if isinstance(e[key], str) and ignore_case:
85
+ if value.lower() in e[key].lower():
86
+ after.append(e)
87
+ elif isinstance(e[key], list) and ignore_case:
88
+ if value.lower() in [str(_).lower() for _ in e[key]]:
89
+ after.append(e)
90
+ elif isinstance(e[key], dict) and ignore_case:
91
+ if value.lower() in [str(_).lower() for _ in e[key].keys()]:
92
+ after.append(e)
93
+
94
+ elif isinstance(e[key], str):
95
+ if value in e[key]:
96
+ after.append(e)
97
+ elif isinstance(e[key], list):
98
+ if value in [str(_) for _ in e[key]]:
99
+ after.append(e)
100
+ elif isinstance(e[key], dict):
101
+ if value in [str(_) for _ in e[key].keys()]:
102
+ after.append(e)
103
+
104
+ self.result = after
105
+ else:
106
+ self.result = [e for e in self.result if value in e[key]]
107
+ return self
108
+
109
+ def value_contains_token(self, key, value, fuzzy=False, thresh=0.75, ignore_case=False):
110
+ self.contains_key(key, ignore_case=ignore_case)
111
+ after = []
112
+ value = str(value)
113
+ for e in self.result:
114
+ if isinstance(e[key], str):
115
+ if fuzzy:
116
+ _, score = match_one(value.lower(),
117
+ e[key].lower().split(" "))
118
+ if score > thresh:
119
+ after.append(e)
120
+ elif ignore_case and value.lower() in e[key].lower().split(" "):
121
+ after.append(e)
122
+ elif value in e[key].split(" "):
123
+ after.append(e)
124
+ elif value in e[key]:
125
+ after.append(e)
126
+ self.result = after
127
+ return self
128
+
129
+ def equal(self, key, value, ignore_case=False):
130
+ self.contains_key(key, ignore_case=ignore_case)
131
+ if ignore_case and isinstance(value, str):
132
+ self.result = [a for a in self.result
133
+ if a[key].lower() == value.lower()]
134
+ else:
135
+ self.result = [a for a in self.result if a[key] == value]
136
+ return self
137
+
138
+ def below(self, key, value, ignore_case=False):
139
+ self.contains_key(key, ignore_case=ignore_case)
140
+ self.result = [a for a in self.result if a[key] < value]
141
+ return self
142
+
143
+ def above(self, key, value, ignore_case=False):
144
+ self.contains_key(key, ignore_case=ignore_case)
145
+ self.result = [a for a in self.result if a[key] > value]
146
+ return self
147
+
148
+ def below_or_equal(self, key, value, ignore_case=False):
149
+ self.contains_key(key, ignore_case=ignore_case)
150
+ self.result = [a for a in self.result if a[key] <= value]
151
+ return self
152
+
153
+ def above_or_equal(self, key, value, ignore_case=False):
154
+ self.contains_key(key, ignore_case=ignore_case)
155
+ self.result = [a for a in self.result if a[key] >= value]
156
+ return self
157
+
158
+ def in_range(self, key, min_value, max_value, ignore_case=False):
159
+ self.contains_key(key, ignore_case=ignore_case)
160
+ self.result = [a for a in self.result if min_value < a[key] < max_value]
161
+ return self
162
+
163
+ def all(self):
164
+ return self
165
+
166
+ def build(self):
167
+ return self.result
168
+
169
+