Source code for dotwiz.plus

"""Dot Wiz Plus module."""
import itertools
import keyword

from pyheck import snake

from .common import (
    __add_repr__,
    __convert_to_attr_dict__,
    __convert_to_dict__,
    __resolve_value__,
)


# A running cache of special-cased or non-lowercase keys that we've
# transformed before.
__SPECIAL_KEYS = {}


[docs]def make_dot_wiz_plus(*args, **kwargs): """ Helper function to create and return a :class:`DotWizPlus` (dot-access dict) from an optional *iterable* object and *keyword* arguments. Example:: >>> from dotwiz import make_dot_wiz_plus >>> make_dot_wiz_plus([('k1', 11), ('k2', [{'a': 'b'}]), ('k3', 'v3')], y=True) ✪(y=True, k1=11, k2=[✪(a='b')], k3='v3') """ kwargs.update(*args) return DotWizPlus(kwargs)
def __store_in_object__(self, __self_dict, key, value, __set=dict.__setitem__): """ Helper method to store a key-value pair in an object :param:`self` (a ``DotWizPlus`` instance). This implementation stores the key if it's already *lower-cased* and a valid *identifier* name in python, else it mutates it into a (lowercase) *snake case* key name that conforms. The new key-value pair is stored in the object's :attr:`__dict__`, and the original key-value is stored in the underlying ``dict`` store, via :meth:`dict.__setitem__`. """ orig_key = key # in case of other types, like `int` key = str(key) lower_key = key.lower() # if it's a keyword like `for` or `class`, or overlaps with a `dict` # method name such as `items`, add an underscore to key so that # attribute access can then work. if __IS_KEYWORD(lower_key): key = f'{lower_key}_' # handle special cases: if the key is not lowercase, or it's not a # valid identifier in python. # # examples: `ThisIsATest` | `hey, world!` | `hi-there` | `3D` elif not key == lower_key or not key.isidentifier(): if key in __SPECIAL_KEYS: key = __SPECIAL_KEYS[key] else: # transform key to `snake case` and cache the result. lower_snake = snake(key) # I've noticed for keys like `a.b.c` or `a'b'c`, the result isn't # `a_b_c` as we'd want it to be. So for now, do the conversion # ourselves. # See also: https://github.com/kevinheavey/pyheck/issues/10 for ch in ('.', '\''): if ch in lower_snake: lower_snake = lower_snake.replace(ch, '_').replace('__', '_') # note: this hurts performance a little, but in any case we need # to check for words with a leading digit such as `123test` - # since these are not valid identifiers in python, unfortunately. ch = lower_snake[0] if ch.isdigit(): # the key has a leading digit, which is invalid. lower_snake = f'_{ch}{lower_snake[1:]}' __SPECIAL_KEYS[key] = key = lower_snake # note: this logic is the same as `DotWizPlus.__setitem__()` __set(self, orig_key, value) __self_dict[key] = value # noinspection PyDefaultArgument def __upsert_into_dot_wiz_plus__(self, input_dict={}, **kwargs): """ Helper method to generate / update a :class:`DotWizPlus` (dot-access dict) from a Python ``dict`` object, and optional *keyword arguments*. """ __dict = self.__dict__ if kwargs: # avoids the potential pitfall of a "mutable default argument" - # only update or modify `input_dict` if the param is passed in. if input_dict: input_dict.update(kwargs) else: input_dict = kwargs for key in input_dict: # note: this logic is the same as `__resolve_value__()` # # *however*, I decided to inline it because it's actually faster # to eliminate a function call here. value = input_dict[key] t = type(value) if t is dict: value = DotWizPlus(value) elif t is list: value = [__resolve_value__(e, DotWizPlus) for e in value] __store_in_object__(self, __dict, key, value) def __setitem_impl__(self, key, value): """Implementation of `DotWizPlus.__setitem__` to preserve dot access""" value = __resolve_value__(value, DotWizPlus) __store_in_object__(self, self.__dict__, key, value)
[docs]class DotWizPlus(dict, metaclass=__add_repr__, print_char='✪', use_attr_dict=True): # noinspection PyProtectedMember """ :class:`DotWizPlus` - a blazing *fast* ``dict`` subclass that also supports *dot access* notation. This implementation enables you to turn special-cased keys into valid *snake_case* words in Python, as shown below. >>> from dotwiz import DotWizPlus >>> dw = DotWizPlus({'Key 1': [{'3D': {'with': 2}}], 'keyTwo': '5', 'r-2!@d.2?': 3.21}) >>> dw ✪(key_1=[✪(_3d=✪(with_=2))], key_two='5', r_2_d_2=3.21) >>> assert dw.key_1[0]._3d.with_ == 2 >>> assert dw.key_two == '5' >>> assert dw.r_2_d_2 == 3.21 >>> dw.to_dict() {'Key 1': [{'3D': {'with': 2}}], 'keyTwo': '5', 'r-2!@d.2?': 3.21} >>> dw.to_attr_dict() {'key_1': [{'_3d': {'with_': 2}}], 'key_two': '5', 'r_2_d_2': 3.21} **Issues with Invalid Characters** A key name in the scope of the ``DotWizPlus`` implementation must be: * a valid, *lower-* and *snake-* cased `identifier`_ in python. * not a reserved *keyword*, such as ``for`` or ``class``. * not override ``dict`` method declarations, such as ``items``, ``get``, or ``values``. In the case where your key name does not conform, the library will mutate your key to a safe, snake-cased format. Spaces and invalid characters are replaced with ``_``. In the case of a key beginning with an *int*, a leading ``_`` is added. In the case of a *keyword* or a ``dict`` method name, a trailing ``_`` is added. Keys that appear in different cases, such as ``myKey`` or ``My-Key``, will all be converted to a *snake case* variant, ``my_key`` in this example. Finally, check out `this example`_ which brings home all that was discussed above. .. _identifier: https://www.askpython.com/python/python-identifiers-rules-best-practices .. _this example: https://dotwiz.readthedocs.io/en/latest/usage.html#complete-example """ __slots__ = ('__dict__', ) __init__ = update = __upsert_into_dot_wiz_plus__ # __getattr__: Use the default `object.__getattr__` implementation. # __getitem__: Use the default `dict.__getitem__` implementation. __delattr__ = __delitem__ = dict.__delitem__ __setattr__ = __setitem__ = __setitem_impl__ to_attr_dict = __convert_to_attr_dict__ to_attr_dict.__doc__ = 'Recursively convert the :class:`DotWizPlus` instance ' \ 'back to a ``dict``, while preserving the lower-cased ' \ 'keys used for attribute access.' to_dict = __convert_to_dict__ to_dict.__doc__ = 'Recursively convert the :class:`DotWizPlus` instance ' \ 'back to a ``dict``.'
# A list of the public-facing methods in `DotWizPlus` __PUB_METHODS = (m for m in dir(DotWizPlus) if not m.startswith('_') and callable(getattr(DotWizPlus, m))) # A list of *lower-cased* reserved keywords. Note that we first lower-case an # input key name and do a lookup using `__IS_KEYWORD`, so the `contains` check # will only work for similar-cased keywords; any other keywords, such as `None` # or `False`, likely won't match anyway, so we don't include them. __LOWER_KWLIST = (kw for kw in keyword.kwlist if kw.islower()) # Callable used to check if any key names are reserved keywords. __IS_KEYWORD = frozenset(itertools.chain(__LOWER_KWLIST, __PUB_METHODS)).__contains__