Skip to content

Commit 2196c8e

Browse files
authored
Merge pull request #6 from Code0x58/improve-exceptions
Provide better exception messages + better slice support
2 parents beb026b + 4122f97 commit 2196c8e

File tree

4 files changed

+122
-60
lines changed

4 files changed

+122
-60
lines changed

.travis.yml

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ language: python
22

33
jobs:
44
include:
5+
- python: 3.9
56
- python: 3.8
67
- python: 3.7
78
- python: 3.6
@@ -10,7 +11,7 @@ jobs:
1011
- python: 2.7
1112
- python: pypy3
1213
- python: pypy
13-
- name: "Python: 3.7"
14+
- name: "Python: 2.7"
1415
os: osx
1516
osx_image: xcode11.2 # Python 3.7.4 running on macOS 10.14.4
1617
language: shell # 'language: python' is an error on Travis CI macOS
@@ -22,5 +23,7 @@ jobs:
2223
- python -m pip install --upgrade pip
2324
env: PATH=/c/Python38:/c/Python38/Scripts:$PATH
2425

25-
install: python -m pip install . pytest
26-
script: python -m pytest
26+
install: python -m pip install --upgrade . pytest==4.6.1 pytest-cov==2.8.1 codecov==2.1.10
27+
script: python -m pytest -vv --cov=jsonstore --cov-append
28+
after_success:
29+
- codecov

README.rst

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
|PyPi Package| |Build Status| |Codacy Rating|
1+
|PyPi Package| |Build Status| |Codacy Rating| |Coverage Report|
22

33
jsonstore
44
=========
@@ -47,6 +47,8 @@ Basics
4747
assert store['a_list', -1] == 3
4848
# you can use slices in lists
4949
assert len(store['a_list', 1:]) == 2
50+
del store['a_list', :2]
51+
assert store.a_list == [3]
5052
5153
# deep copies are made when assigning values
5254
my_list = ['fun']
@@ -114,3 +116,5 @@ file until all of the transactions have been closed.
114116
:target: https://www.codacy.com/app/evilumbrella-github/python-jsonstore?utm_source=github.com&utm_medium=referral&utm_content=Code0x58/python-jsonstore&utm_campaign=Badge_Grade
115117
.. |PyPi Package| image:: https://badge.fury.io/py/python-jsonstore.svg
116118
:target: https://pypi.org/project/python-jsonstore/
119+
.. |Coverage Report| image:: https://codecov.io/gh/Code0x58/python-jsonstore/branch/master/graph/badge.svg
120+
:target: https://codecov.io/gh/Code0x58/python-jsonstore

jsonstore.py

Lines changed: 74 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,13 @@
1212

1313
__all__ = ["JsonStore"]
1414

15+
STRING_TYPES = (str,)
16+
INT_TYPES = (int,)
17+
if sys.version_info < (3,):
18+
STRING_TYPES += (unicode,)
19+
INT_TYPES += (long,)
20+
VALUE_TYPES = (bool, int, float, type(None)) + INT_TYPES
21+
1522

1623
class JsonStore(object):
1724
"""A class to provide object based access to a JSON file"""
@@ -81,9 +88,10 @@ def __getattr__(self, key):
8188
raise AttributeError(key)
8289

8390
@classmethod
84-
def _valid_object(cls, obj, parents=None):
91+
def _verify_object(cls, obj, parents=None):
8592
"""
86-
Determine if the object can be encoded into JSON
93+
Raise an exception if the object is not suitable for assignment.
94+
8795
"""
8896
# pylint: disable=unicode-builtin,long-builtin
8997
if isinstance(obj, (dict, list)):
@@ -94,85 +102,100 @@ def _valid_object(cls, obj, parents=None):
94102
parents.append(obj)
95103

96104
if isinstance(obj, dict):
97-
return all(
98-
cls._valid_string(k) and cls._valid_object(v, parents)
99-
for k, v in obj.items()
100-
)
105+
for k, v in obj.items():
106+
if not cls._valid_string(k):
107+
# this is necessary because of the JSON serialisation
108+
raise TypeError("a dict has non-string keys")
109+
cls._verify_object(v, parents)
101110
elif isinstance(obj, (list, tuple)):
102-
return all(cls._valid_object(o, parents) for o in obj)
111+
for o in obj:
112+
cls._verify_object(o, parents)
103113
else:
104114
return cls._valid_value(obj)
105115

106116
@classmethod
107117
def _valid_value(cls, value):
108-
if isinstance(value, (bool, int, float, type(None))):
109-
return True
110-
elif sys.version_info < (3,) and isinstance(value, long):
118+
if isinstance(value, VALUE_TYPES):
111119
return True
112120
else:
113121
return cls._valid_string(value)
114122

115123
@classmethod
116124
def _valid_string(cls, value):
117-
if isinstance(value, str):
125+
if isinstance(value, STRING_TYPES):
118126
return True
119-
elif sys.version_info < (3,):
120-
return isinstance(value, unicode)
121127
else:
122128
return False
123129

124-
def __setattr__(self, key, value):
125-
if not self._valid_object(value):
126-
raise AttributeError
127-
self._data[key] = deepcopy(value)
130+
@classmethod
131+
def _canonical_key(cls, key):
132+
"""Convert a set/get/del key into the canonical form."""
133+
if cls._valid_string(key):
134+
return tuple(key.split("."))
135+
136+
if isinstance(key, (tuple, list)):
137+
key = tuple(key)
138+
if not key:
139+
raise TypeError("key must be a string or non-empty tuple/list")
140+
return key
141+
142+
raise TypeError("key must be a string or non-empty tuple/list")
143+
144+
def __setattr__(self, attr, value):
145+
self._verify_object(value)
146+
self._data[attr] = deepcopy(value)
128147
self._do_auto_commit()
129148

130-
def __delattr__(self, key):
131-
del self._data[key]
149+
def __delattr__(self, attr):
150+
del self._data[attr]
132151

133-
def __get_obj(self, full_path):
134-
"""
135-
Returns the object which is under the given path
136-
"""
137-
if isinstance(full_path, (tuple, list)):
138-
steps = full_path
139-
else:
140-
steps = full_path.split(".")
152+
def __get_obj(self, steps):
153+
"""Returns the object which is under the given path."""
141154
path = []
142155
obj = self._data
143-
if not full_path:
144-
return obj
145156
for step in steps:
146-
path.append(step)
157+
if isinstance(obj, dict) and not self._valid_string(step):
158+
# this is necessary because of the JSON serialisation
159+
raise TypeError("%s is a dict and %s is not a string" % (path, step))
147160
try:
148161
obj = obj[step]
149-
except KeyError:
150-
raise KeyError(".".join(path))
162+
except (KeyError, IndexError, TypeError) as e:
163+
raise type(e)("unable to get %s from %s: %s" % (step, path, e))
164+
path.append(step)
151165
return obj
152166

153-
def __setitem__(self, name, value):
154-
path, _, key = name.rpartition(".")
155-
if self._valid_object(value):
156-
dictionary = self.__get_obj(path)
157-
dictionary[key] = deepcopy(value)
158-
self._do_auto_commit()
159-
else:
160-
raise AttributeError
167+
def __setitem__(self, key, value):
168+
steps = self._canonical_key(key)
169+
path, step = steps[:-1], steps[-1]
170+
self._verify_object(value)
171+
container = self.__get_obj(path)
172+
if isinstance(container, dict) and not self._valid_string(step):
173+
raise TypeError("%s is a dict and %s is not a string" % (path, step))
174+
try:
175+
container[step] = deepcopy(value)
176+
except (IndexError, TypeError) as e:
177+
raise type(e)("unable to set %s from %s: %s" % (step, path, e))
178+
self._do_auto_commit()
161179

162180
def __getitem__(self, key):
163-
obj = self.__get_obj(key)
164-
if obj is self._data:
165-
raise KeyError
181+
steps = self._canonical_key(key)
182+
obj = self.__get_obj(steps)
166183
return deepcopy(obj)
167184

168-
def __delitem__(self, name):
169-
if isinstance(name, (tuple, list)):
170-
path = name[:-1]
171-
key = name[-1]
172-
else:
173-
path, _, key = name.rpartition(".")
185+
def __delitem__(self, key):
186+
steps = self._canonical_key(key)
187+
path, step = steps[:-1], steps[-1]
174188
obj = self.__get_obj(path)
175-
del obj[key]
189+
try:
190+
del obj[step]
191+
except (KeyError, IndexError, TypeError) as e:
192+
raise type(e)("unable to delete %s from %s: %s" % (step, path, e))
176193

177194
def __contains__(self, key):
178-
return key in self._data
195+
steps = self._canonical_key(key)
196+
try:
197+
self.__get_obj(steps)
198+
return True
199+
except (KeyError, IndexError, TypeError):
200+
# this is rather permissive as the types are dynamic
201+
return False

test_jsonstore.py

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -105,19 +105,39 @@ def test_assign_valid_types(self):
105105
self.assertRaises(KeyError, self._getitem(name))
106106
self.assertRaises(AttributeError, self._getattr(name))
107107

108+
def test_slices(self):
109+
self.store.list = [1, 2, 3]
110+
self.store["list", :2] = ["a", "b"]
111+
self.assertEqual(self.store["list", 1:], ["b", 3])
112+
del self.store["list", :1]
113+
self.assertEqual(self.store.list, ["b", 3])
114+
108115
def test_assign_invalid_types(self):
109116
for method in (self._setattr, self._setitem):
110117

111118
def assign(value):
112119
return method("key", value)
113120

114-
self.assertRaises(AttributeError, assign(set()))
115-
self.assertRaises(AttributeError, assign(object()))
116-
self.assertRaises(AttributeError, assign(None for i in range(2)))
121+
self.assertRaises(TypeError, assign(set()))
122+
self.assertRaises(TypeError, assign(object()))
123+
self.assertRaises(TypeError, assign(None for i in range(2)))
124+
self.assertRaises(TypeError, assign({1: 1}))
117125

118126
def test_assign_bad_keys(self):
119-
# FIXME: a ValueError would make more sense
120-
self.assertRaises(AttributeError, self._setitem(1, 2))
127+
value = 1
128+
# the root object is a dict, so a string key is needed
129+
self.assertRaises(TypeError, self._setitem(1, value))
130+
self.assertRaises(TypeError, self._setitem((1, "a"), value))
131+
132+
self.store["dict"] = {}
133+
self.store["list"] = []
134+
self.assertRaises(TypeError, self._setitem((), value))
135+
self.assertRaises(TypeError, self._setitem(("dict", 1), value))
136+
self.assertRaises(TypeError, self._setitem(("dict", slice(1)), value))
137+
self.assertRaises(TypeError, self._setitem(("list", "a"), value))
138+
self.assertRaises(TypeError, self._setitem(("list", slice("a")), value))
139+
self.assertRaises(IndexError, self._setitem(("list", 1), value))
140+
121141

122142
def test_retrieve_values(self):
123143
for name, value in self.TEST_DATA:
@@ -177,6 +197,7 @@ def test_nested_getitem(self):
177197
assert self.store[["list", 0, "key", -1]] == "last"
178198
self.assertRaises(TypeError, self._getitem("list.0.key.1"))
179199
assert len(self.store["list", 0, "key", 1:]) == 2
200+
self.assertRaises(IndexError, self._getitem(("list", 1)))
180201

181202
def test_del(self):
182203
self.store.key = None
@@ -187,6 +208,17 @@ def test_del(self):
187208
del self.store["key"]
188209
self.assertRaises(KeyError, self._getitem("key"))
189210

211+
with self.assertRaises(KeyError):
212+
del self.store["missing"]
213+
214+
self.store.list = []
215+
with self.assertRaises(IndexError):
216+
del self.store["list", 1]
217+
218+
self.store.dict = {}
219+
with self.assertRaises(TypeError):
220+
del self.store["dict", slice("a")]
221+
190222
def test_context_and_deserialisation(self):
191223
store_file = mktemp()
192224
for name, value in self.TEST_DATA:

0 commit comments

Comments
 (0)