Completed
Push — master ( 2b29c7...0d7886 )
by Satoru
01:33
created

m9dicts.make()   F

Complexity

Conditions 9

Size

Total Lines 26

Duplication

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