"""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__