make()   B
last analyzed

Complexity

Conditions 5

Size

Total Lines 26

Duplication

Lines 0
Ratio 0 %

Importance

Changes 10
Bugs 3 Features 3
Metric Value
cc 5
c 10
b 3
f 3
dl 0
loc 26
rs 8.0894
1
#
2
# Copyright (C) 2011 - 2015 Red Hat, Inc.
3
# Copyright (C) 2011 - 2017 Satoru SATOH <ssato redhat.com>
4
# License: MIT
5
#
6
"""Functions operate on m9dicts objects.
7
8
.. versionchanged: 0.4.0
9
   Rmove the new API introduced in the previous release to convert dict[s] to
10
   relations because it's not stable enough.
11
12
.. versionchanged: 0.3.0
13
   Extended and changed :fun:`convert_to` API to support conversion from
14
   dict[s] to relations (SQL term, :see:
15
   https://en.wikipedia.org/wiki/Relation_(database))
16
17
.. versionchanged: 0.1.0
18
19
   - splitted / forked from python-anyconfig; old history was available in its
20
     mdict branch:
21
     https://github.com/ssato/python-anyconfig/blob/mdict/anyconfig/mergeabledict.py
22
23
"""
24
from __future__ import absolute_import
25
26
import collections
27
import functools
28
import operator
29
import re
30
31
import m9dicts.compat
32
import m9dicts.globals
33
import m9dicts.dicts
34
import m9dicts.utils
35
36
37
PATH_SEPS = ('/', '.')
38
39
_JSNP_GET_ARRAY_IDX_REG = re.compile(r"(?:0|[1-9][0-9]*)")
40
_JSNP_SET_ARRAY_IDX = re.compile(r"(?:0|[1-9][0-9]*|-)")
41
42
43
def _jsnp_unescape(jsn_s):
44
    """
45
    Parse and decode given encoded JSON Pointer expression, convert ~1 to
46
    / and ~0 to ~.
47
48
    .. note:: JSON Pointer: http://tools.ietf.org/html/rfc6901
49
50
    >>> _jsnp_unescape("/a~1b")
51
    '/a/b'
52
    >>> _jsnp_unescape("~1aaa~1~0bbb")
53
    '/aaa/~bbb'
54
    """
55
    return jsn_s.replace('~1', '/').replace('~0', '~')
56
57
58
def _split_path(path, seps=PATH_SEPS):
59
    """
60
    Parse path expression and return list of path items.
61
62
    :param path: Path expression may contain separator chars.
63
    :param seps: Separator char candidates.
64
    :return: A list of keys to fetch object[s] later.
65
66
    >>> assert _split_path('') == []
67
    >>> assert _split_path('/') == ['']  # JSON Pointer spec expects this.
68
    >>> for p in ('/a', '.a', 'a', 'a.'):
69
    ...     assert _split_path(p) == ['a'], p
70
    >>> assert _split_path('/a/b/c') == _split_path('a.b.c') == ['a', 'b', 'c']
71
    >>> assert _split_path('abc') == ['abc']
72
    """
73
    if not path:
74
        return []
75
76
    for sep in seps:
77
        if sep in path:
78
            if path == sep:  # Special case, '/' or '.' only.
79
                return ['']
80
            return [x for x in path.split(sep) if x]
81
82
    return [path]
83
84
85
def mk_nested_dic(path, val, seps=PATH_SEPS):
86
    """
87
    Make a nested dict iteratively.
88
89
    :param path: Path expression to make a nested dict
90
    :param val: Value to set
91
    :param seps: Separator char candidates
92
93
    >>> mk_nested_dic("a.b.c", 1)
94
    {'a': {'b': {'c': 1}}}
95
    >>> mk_nested_dic("/a/b/c", 1)
96
    {'a': {'b': {'c': 1}}}
97
    """
98
    ret = None
99
    for key in reversed(_split_path(path, seps)):
100
        ret = {key: val if ret is None else ret.copy()}
101
102
    return ret
103
104
105
def get(dic, path, seps=PATH_SEPS, idx_reg=_JSNP_GET_ARRAY_IDX_REG):
106
    """getter for nested dicts.
107
108
    :param dic: a dict[-like] object
109
    :param path: Path expression to point object wanted
110
    :param seps: Separator char candidates
111
    :return: A tuple of (result_object, error_message)
112
113
    >>> d = {'a': {'b': {'c': 0, 'd': [1, 2]}}, '': 3}
114
    >>> assert get(d, '/') == (3, '')  # key becomes '' (empty string).
115
    >>> assert get(d, "/a/b/c") == (0, '')
116
    >>> sorted(get(d, "a.b")[0].items())
117
    [('c', 0), ('d', [1, 2])]
118
    >>> (get(d, "a.b.d"), get(d, "/a/b/d/1"))
119
    (([1, 2], ''), (2, ''))
120
    >>> get(d, "a.b.key_not_exist")  # doctest: +ELLIPSIS
121
    (None, "'...'")
122
    >>> get(d, "/a/b/d/2")
123
    (None, 'list index out of range')
124
    >>> get(d, "/a/b/d/-")  # doctest: +ELLIPSIS
125
    (None, 'list indices must be integers...')
126
    """
127
    items = [_jsnp_unescape(p) for p in _split_path(path, seps)]
128
    if not items:
129
        return (dic, '')
130
    try:
131
        if len(items) == 1:
132
            return (dic[items[0]], '')
133
134
        prnt = functools.reduce(operator.getitem, items[:-1], dic)
135
        arr = m9dicts.utils.is_list_like(prnt) and idx_reg.match(items[-1])
136
        return (prnt[int(items[-1])], '') if arr else (prnt[items[-1]], '')
137
138
    except (TypeError, KeyError, IndexError) as exc:
139
        return (None, str(exc))
140
141
142
def set_(dic, path, val, seps=PATH_SEPS):
143
    """setter for nested dicts.
144
145
    :param dic: a dict[-like] object support recursive merge operations
146
    :param path: Path expression to point object wanted
147
    :param seps: Separator char candidates
148
149
    >>> d = dict(a=1, b=dict(c=2, ))
150
    >>> set_(d, 'a.b.d', 3)
151
    >>> d['a']['b']['d']
152
    3
153
    """
154
    dic.update(mk_nested_dic(path, val, seps))
155
156
157
def check_merge(merge):
158
    """Check if given `merge` is valid and ValueError will be raised if not.
159
    """
160
    if merge not in m9dicts.globals.MERGE_STRATEGIES:
161
        raise ValueError("Wrong merge strategy: %r" % merge)
162
163
164
def _make_from_namedtuple(obj, merge=m9dicts.globals.MS_DICTS,
165
                          _ntpl_cls_key=m9dicts.globals.NTPL_CLS_KEY,
166
                          **options):
167
    """
168
    :param obj: A namedtuple object
169
    :param merge:
170
        Specify strategy from MERGE_STRATEGIES of how to merge results loaded
171
        from multiple configuration files.
172
    :param _ntpl_cls_key:
173
        Special keyword to embedded the class name of namedtuple object to the
174
        MergeableDict object created. It's a hack and not elegant but I don't
175
        think there are another ways to make same namedtuple object from the
176
        MergeableDict object created from it.
177
    """
178
    ocls = m9dicts.dicts.get_mdict_class(merge=merge, ordered=True)
179
    mdict = ocls((k, make(getattr(obj, k), **options)) for k in obj._fields)
180
    mdict[_ntpl_cls_key] = obj.__class__.__name__
181
182
    return mdict
183
184
185
def _make_recur(obj, cls, make_fn, **options):
186
    """
187
    :param obj: An original mapping object
188
    :param cls: Another mapping class to make/convert to
189
    :param make_fn: Function to make/convert to
190
    """
191
    return cls((k, None if v is None else make_fn(v, **options))
192
               for k, v in obj.items())
193
194
195
def _make_iter(obj, make_fn, **options):
196
    """
197
    :param obj: An original mapping object
198
    :param make_fn: Function to make/convert to
199
    """
200
    return type(obj)(make_fn(v, **options) for v in obj)
201
202
203
def make(obj=None, ordered=False, merge=m9dicts.globals.MS_DICTS, **options):
204
    """
205
    Factory function to create a dict-like object[s] supports merge operation
206
    from a mapping or a list of mapping objects such as dict, [dict],
207
    namedtuple, [namedtuple].
208
209
    :param obj: A dict or other object[s] or None
210
    :param ordered:
211
        Choose the class keeps key order if True or `obj` is a namedtuple.
212
    :param merge: see :func:`_make_from_namedtuple` (above).
213
    :return: A dict-like object[s] supports merge operation or `obj` itself
214
    """
215
    check_merge(merge)
216
    cls = m9dicts.dicts.get_mdict_class(merge=merge, ordered=ordered)
217
    if obj is None:
218
        return cls()
219
220
    options.update(ordered=ordered, merge=merge)
221
    if m9dicts.utils.is_dict_like(obj):
222
        return _make_recur(obj, cls, make, **options)
223
    elif m9dicts.utils.is_namedtuple(obj):
224
        return _make_from_namedtuple(obj, **options)
225
    elif m9dicts.utils.is_list_like(obj):
226
        return _make_iter(obj, make, **options)
227
    else:
228
        return obj
229
230
231
def _convert_to_namedtuple(obj, _ntpl_cls_key=m9dicts.globals.NTPL_CLS_KEY,
232
                           **options):
233
    """Convert a dict-like object to a namedtuple.
234
235
    :param obj: A m9dicts objects or other primitive object
236
    :param _ntpl_cls_key: see :func:`_make_from_namedtuple`
237
    """
238
    _name = obj.get(_ntpl_cls_key, "NamedTuple")
239
    _keys = [k for k in obj.keys() if k != _ntpl_cls_key]
240
    _vals = [convert_to(obj[k], **options) for k in _keys]
241
    return collections.namedtuple(_name, _keys)(*_vals)
242
243
244
def convert_to(obj, ordered=False, to_type=None, **options):
245
    """
246
    Convert a dict-like object[s] support merge operation to a dict or
247
    namedtuple object recursively. Borrowed basic idea and implementation from
248
    bunch.unbunchify. (bunch is distributed under MIT license same as this.)
249
250
    .. note::
251
       - If given `obj` does not keep key order, then the order of fields of
252
         result namedtuple object becomes random.
253
       - namedtuple object cannot have fields start with '_', So it'll fail if
254
         to convert dicts has such keys.
255
256
    :param obj: A m9dicts objects or other primitive object
257
    :param ordered: Create an OrderedDict instead of dict to keep the key order
258
    :param to_type:
259
        Convert `obj` to `to_type` object instead of a dict.  Possible choices
260
        are one of TO_TYPES.
261
    :param options:
262
        Optional keyword arguments.
263
264
        - _ntpl_cls_key to specify nametuple class key. see
265
          :func:`_make_from_namedtuple` for more its details.
266
267
    :return:
268
        A dict or namedtuple object if to_type == NAMED_TUPLE_TYPE
269
    """
270
    options.update(ordered=ordered, to_type=to_type)
271
    if m9dicts.utils.is_dict_like(obj):
272
        if to_type == m9dicts.globals.NAMED_TUPLE_TYPE:
273
            return _convert_to_namedtuple(obj, **options)
274
        else:
275
            cls = m9dicts.compat.OrderedDict if ordered else dict
276
            return _make_recur(obj, cls, convert_to, **options)
277
    elif m9dicts.utils.is_list_like(obj):
278
        return _make_iter(obj, convert_to, **options)
279
    else:
280
        return obj
281
282
# vim:sw=4:ts=4:et:
283