-
Notifications
You must be signed in to change notification settings - Fork 2
/
structural.py
280 lines (233 loc) · 10.2 KB
/
structural.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
"""
pluckable - object lazy wrapper that supports chained soft get/slice of
items and/or attributes like::
pluckable(obj).users[2:5, 10:15].name.first
pluckable(obj)[::-1].meta["is-admin"][0]
pluckable({"v": namedtuple("Vector", "x y z")(1, 2, 3)}).v.x
"""
# TODO?
# pluckable([{'a': {2: 1}}]).a.2 --> 1
from __future__ import absolute_import
import sys
from .compat import xrange, baseinteger, basestring, unicode
class AttrSelector(unicode):
"""String key with preferrence for attribute plucking (`getattr`)."""
class KeySelector(unicode):
"""String key with preferrence for item plucking (`__getitem__`)."""
class pluckable(object):
def __init__(self, obj=None, default=None, skipmissing=True,
inplace=False, empty=False):
"""Creates a new pluckable object based on `obj`. Default value for the
missing keys is given with `default`.
A two modes of plucking are supported::
(1) the default, document databases alike, where missing keys (and it's
ancestors) will be silently dropped-out -- except for the leaf
nodes -- which always default to the ``default`` when missing.
(2) one-on-one extractor (explict mode), which will include all missing
values as ``default``, ensuring the leaf values exist even when one
(or more) intermediate nodes are missing.
"""
self.obj = obj
self.default = default
self.skipmissing = skipmissing
self.inplace = inplace
self.empty = empty
def rewrap(self, **kwargs):
"""Inplace constructor. Depending on `self.inplace`, rewrap `obj`, or
just update internal vars, possibly including the `obj`.
"""
if self.inplace:
for key, val in kwargs.items():
setattr(self, key, val)
return self
else:
for key in ['obj', 'default', 'skipmissing', 'inplace', 'empty']:
kwargs.setdefault(key, getattr(self, key))
return pluckable(**kwargs)
@property
def value(self):
if self.empty:
return self.default
else:
return self.obj
def _append(self, obj, key, res, skipmissing=None):
if skipmissing is None:
skipmissing = self.skipmissing
# prefer attributes over items
if isinstance(key, AttrSelector):
try:
res.append(getattr(obj, key))
except: # AttributeError et al
try:
res.append(obj[key])
except: # KeyError et al
if not skipmissing:
res.append(self.default)
return res
# by default, prefer itemgetter over attrgetter (key ~ KeySelector)
try:
res.append(obj[key])
except: # KeyError et al
try:
res.append(getattr(obj, key))
except: # AttributeError et al
if not skipmissing:
res.append(self.default)
return res
def _filtered_list(self, selector):
"""Iterate over `self.obj` list, extracting `selector` from each
element. The `selector` can be a simple integer index, or any valid
key (hashable object).
"""
res = []
for elem in self.obj:
self._append(elem, selector, res)
return res
def _sliced_list(self, selector):
"""For slice selectors operating on lists, we need to handle them
differently, depending on ``skipmissing``. In explicit mode, we may have
to expand the list with ``default`` values.
"""
if self.skipmissing:
return self.obj[selector]
# TODO: can be optimized by observing list bounds
keys = xrange(selector.start or 0,
selector.stop or sys.maxint,
selector.step or 1)
res = []
for key in keys:
self._append(self.obj, key, res, skipmissing=False)
return res
def _extract_from_list(self, selector):
if isinstance(selector, baseinteger):
return self._sliced_list(slice(selector, selector+1))
elif isinstance(selector, slice):
return self._sliced_list(selector)
else:
return self._filtered_list(selector)
def _extract_from_object(self, selector):
"""Extracts all values from `self.obj` object addressed with a `selector`.
Selector can be a ``slice``, or a singular value extractor in form of a
valid dictionary key (hashable object).
Object (operated on) can be anything with an itemgetter or attrgetter,
including, but limited to `dict`, and `list`.
Itemgetter is preferred over attrgetter, except when called as `.key`.
If `selector` is a singular value extractor (like a string, integer,
etc), a single value (for a given key) is returned if key exists, an
empty list if not.
If `selector` is a ``slice``, each key from that range is extracted;
failing-back, again, to an empty list.
"""
if isinstance(selector, slice):
# we must expand the slice manually, in order to be able to apply to
# for example, to mapping types, or general objects
# (e.g. slice `4::2` will filter all even numerical keys/attrs >=4)
start = selector.start or 0
step = selector.step or 1
if selector.stop is None:
if hasattr(self.obj, "keys"):
# filter keys by slice
keys = \
[k for k in self.obj.keys() if isinstance(k, baseinteger) \
and k >= start and (k - start) % step == 0]
elif hasattr(self.obj, "__len__"):
# object we slice should have a length (__len__ method),
keys = xrange(start, len(self.obj), step)
else:
# otherwise, we don't know how to slice, so just skip it,
# instead of failing
keys = []
else:
keys = xrange(start, selector.stop, step)
else:
keys = [selector]
res = []
for key in keys:
self._append(self.obj, key, res)
return res
def _get_all(self, *selectors):
res = []
for selector in selectors:
if isinstance(self.obj, list):
res.extend(self._extract_from_list(selector))
else:
res.extend(self._extract_from_object(selector))
# Should we collapse the result list to a singular result?
# We should, if we filter by only one simple selector (index or key),
# except when we filter a list with the key selector.
# (cases: anything[idx], dict["key"]/obj.attr)
#
# We are doing this here, and not in selector extractors above, to make
# combining results from multiple selectors easier.
singular_result = False
if len(selectors) == 1:
is_idx = isinstance(selectors[0], baseinteger)
is_key = isinstance(selectors[0], basestring)
not_list = not isinstance(self.obj, list)
singular_result = is_idx or is_key and not_list
if len(res) == 0:
return self.rewrap(empty=True)
elif len(res) == 1 and singular_result:
return self.rewrap(obj=res[0])
else:
return self.rewrap(obj=res)
def __getattr__(self, name):
"""Handle ``obj.name`` lookups.
obj.key -> similar to obj["key"], but with preferrence on attributes vs items.
If obj is a dict, and "key" is not a valid `dict` attribute, extract
dict value under key "key" (or default val). If obj is a list,
iterate over all elements, extracting "key" from each element
"""
return self._get_all(AttrSelector(name))
def __getitem__(self, key):
"""Handle various ``obj[key]`` lookups, including::
obj[2] -> if obj is list, extract elem with index 2;
if obj is dict, extract value under key 2
obj[1, 2] -> if obj is list, extract elems with indices 1 and 2;
if obj is dict, extract values under keys 1,2 into a new list
obj[1:5] -> the same as obj[1,2,3,4,5]
obj["key"] -> if obj is dict, extract value under key "key" (or default val),
if obj is list, iterate over all elements, extracting "key" from each element
obj[2, 4:5] -> the same as obj[2,4,5]
obj[1:, 0] -> analog to the above, sugar syntax for: obj[1:] + [obj[0]]
obj["x", "y"] -> if obj is dict, extract keys "x" and "y" into a new list;
if obj is list, iterate over all elements, extracting "x" and "y"
from each element into a flat list
obj["x", "y", 3, ::-1] -> similar to above, extracting "x", "y", 3 and all keys in reverse
"""
try:
# accept any iterable for indices
keys = iter(key)
except TypeError:
keys = (key,)
return self._get_all(*keys)
def __str__(self):
return str(self.value)
def __repr__(self):
return repr(self.value)
def __iter__(self):
"""Iterate over values plucked so far.
Example::
for val in pluckable(obj).users.last:
print(val)
"""
if self.empty:
return iter([])
val = self.value
try:
return iter(val)
except Exception as e:
return iter([val])
def items(self):
"""Behave like `dict.items` for mapping types (iterator over (key, value)
pairs), and like `iter` for sequence types (iterator over values).
"""
if self.empty:
return iter([])
val = self.value
if hasattr(val, "iteritems"):
return val.iteritems()
elif hasattr(val, "items"):
return val.items()
else:
return iter(self)