From 7f3eb54524a54d3663d2086f5bdd22a55a8e6e21 Mon Sep 17 00:00:00 2001 From: Ritvik Nag Date: Sat, 25 Jun 2022 17:44:43 -0400 Subject: [PATCH 01/55] try to not subclass from `dict` (should be a bit faster) --- benchmarks/test_getattr.py | 8 +++ dotwiz/common.py | 130 +++++++++++++++++++++++++++---------- dotwiz/common.pyi | 14 ++-- dotwiz/main.py | 69 ++++++++++++++++---- dotwiz/main.pyi | 17 ++++- dotwiz/plus.py | 18 +---- dotwiz/plus.pyi | 6 ++ 7 files changed, 192 insertions(+), 70 deletions(-) diff --git a/benchmarks/test_getattr.py b/benchmarks/test_getattr.py index 2e23b87..ce3b16b 100644 --- a/benchmarks/test_getattr.py +++ b/benchmarks/test_getattr.py @@ -72,6 +72,14 @@ def test_dotwiz(benchmark, my_data): assert result == 77 +def test_dotwiz_getitem(benchmark, my_data): + o = dotwiz.DotWiz(my_data) + # print(o) + + result = benchmark(lambda: o['c']['bb'][0]['x']) + assert result == 77 + + def test_dotwiz_plus(benchmark, my_data): o = dotwiz.DotWizPlus(my_data) # print(o) diff --git a/dotwiz/common.py b/dotwiz/common.py index 2e045e4..ea5f6a2 100644 --- a/dotwiz/common.py +++ b/dotwiz/common.py @@ -1,60 +1,122 @@ """ Common (shared) helpers and utilities. """ +import json -def __add_repr__(name, bases, cls_dict, *, print_char='*', use_attr_dict=False): +class DotWizEncoder(json.JSONEncoder): """ - Metaclass to generate and add a `__repr__` to a class. + Helper class for encoding of nested DotWiz and DotWizPlus dicts into standard dict """ - # if `use_attr_dict` is true, use attributes defined in the instance's - # `__dict__` instead. - if use_attr_dict: - def __repr__(self: dict): - fields = [f'{k}={v!r}' for k, v in self.__dict__.items()] - return f'{print_char}({", ".join(fields)})' + def default(self, o): + """Return dict data of DotWiz when possible or encode with standard format - else: - def __repr__(self: dict, items_fn=dict.items): - # noinspection PyArgumentList - fields = [f'{k}={v!r}' for k, v in items_fn(self)] - return f'{print_char}({", ".join(fields)})' + :param o: Input object - cls_dict['__repr__'] = __repr__ + :return: Serializable data + """ + try: + return o.__dict__ - return type(name, bases, cls_dict) + except AttributeError: + return json.JSONEncoder.default(self, o) -def __convert_to_attr_dict__(o): +def __add_shared_methods__(name, bases, cls_dict, *, print_char='*', has_attr_dict=False): """ - Recursively convert an object (typically a `dict` subclass) to a - Python `dict` type, while preserving the lower-cased keys used - for attribute access. + Add shared methods to a class, such as :meth:`__repr__` and :meth:`to_json`. """ - if isinstance(o, dict): - return {k: __convert_to_attr_dict__(v) for k, v in o.__dict__.items()} - if isinstance(o, list): - return [__convert_to_attr_dict__(e) for e in o] + # use attributes defined in the instance's `__dict__`. + def __repr__(self: object): + fields = [f'{k}={v!r}' for k, v in self.__dict__.items()] + return f'{print_char}({", ".join(fields)})' + + # add a `__repr__` magic method to the class. + cls_dict['__repr__'] = __repr__ + + # add common methods to the class, such as: + # - `to_dict` + # - `to_json` + # - `to_attr_dict` - optional, only if `has_attr_dict` is specified. + + def __convert_to_dict__(o): + """ + Recursively convert an object (typically a custom `dict` type) to a + Python `dict` type. + """ + __dict = getattr(o, '__dict__', None) + + if __dict: + return {k: __convert_to_dict__(v) for k, v in __dict.items()} + + if isinstance(o, list): + return [__convert_to_dict__(e) for e in o] + + return o + + if has_attr_dict: + def to_dict(o, __items=dict.items): + """ + Recursively convert an object (typically a `dict` subclass) to a + Python `dict` type, while preserving the lower-cased keys used + for attribute access. + """ + if isinstance(o, dict): + # noinspection PyArgumentList + return {k: to_dict(v) for k, v in __items(o)} + + if isinstance(o, list): + return [to_dict(e) for e in o] + + return o - return o + def to_json(o): + return json.dumps(o) + cls_dict['to_json'] = to_json + to_json.__doc__ = f'Serialize the :class:`{name}` instance as a JSON string.' -def __convert_to_dict__(o, __items_fn=dict.items): + cls_dict['to_dict'] = to_dict + to_dict.__doc__ = f'Recursively convert the :class:`{name}` instance ' \ + 'back to a ``dict``.' + + cls_dict['to_attr_dict'] = __convert_to_dict__ + __convert_to_dict__.__name__ = 'to_attr_dict' + __convert_to_dict__.__doc__ = f'Recursively convert the :class:`{name}` ' \ + 'instance back to a ``dict``, while ' \ + 'preserving the lower-cased keys used ' \ + 'for attribute access.' + + else: + def to_json(o): + return json.dumps(o.__dict__, cls=DotWizEncoder) + + cls_dict['to_json'] = to_json + to_json.__doc__ = f'Serialize the :class:`{name}` instance as a JSON string.' + + cls_dict['to_dict'] = __convert_to_dict__ + __convert_to_dict__.__name__ = 'to_dict' + __convert_to_dict__.__doc__ = f'Recursively convert the :class:`{name}` ' \ + 'instance back to a ``dict``.' + + return type(name, bases, cls_dict) + + +def __add_repr__(name, bases, cls_dict, *, print_char='*'): """ - Recursively convert an object (typically a `dict` subclass) to a - Python `dict` type. + Metaclass to generate and add a `__repr__` to a class. """ - if isinstance(o, dict): - # use `dict.items(o)` instead of `o.items()`, to work around this issue: - # https://github.com/rnag/dotwiz/issues/4 - return {k: __convert_to_dict__(v) for k, v in __items_fn(o)} - if isinstance(o, list): - return [__convert_to_dict__(e) for e in o] + # use attributes defined in the instance's __dict__`. + def __repr__(self: object): + fields = [f'{k}={v!r}' for k, v in self.__dict__.items()] + return f'{print_char}({", ".join(fields)})' - return o + cls_dict['__repr__'] = __repr__ + + return type(name, bases, cls_dict) def __resolve_value__(value, dict_type): diff --git a/dotwiz/common.pyi b/dotwiz/common.pyi index 33b1055..a395ecf 100644 --- a/dotwiz/common.pyi +++ b/dotwiz/common.pyi @@ -11,15 +11,19 @@ _VT = TypeVar('_VT') _ItemsFn = Callable[[_D ], ItemsView[_KT, _VT]] +def __add_shared_methods__(name: str, + bases: tuple[type, ...], + cls_dict: dict[str, Any], + *, print_char='*', + has_attr_dict=False): ... + + def __add_repr__(name: str, bases: tuple[type, ...], cls_dict: dict[str, Any], - *, print_char='*', - use_attr_dict=False): ... - -def __convert_to_attr_dict__(o: dict | DotWiz | DotWizPlus | list | _T) -> dict[_KT, _VT] : ... + *, print_char='*'): ... def __convert_to_dict__(o: dict | DotWiz | DotWizPlus | list | _T, *, __items_fn: _ItemsFn = dict.items) -> dict[_KT, _VT] : ... -def __resolve_value__(value: _T, dict_type: type[_D]) -> _T | _D | list[_D]: ... +def __resolve_value__(value: _T, dict_type: type[DotWiz | DotWizPlus]) -> _T | _D | list[_D]: ... diff --git a/dotwiz/main.py b/dotwiz/main.py index 4c9458f..5bb16b7 100644 --- a/dotwiz/main.py +++ b/dotwiz/main.py @@ -1,9 +1,8 @@ """Main module.""" +from typing import ItemsView, ValuesView from .common import ( - __add_repr__, - __convert_to_dict__, - __resolve_value__, + __resolve_value__, __add_shared_methods__, ) @@ -26,7 +25,6 @@ def make_dot_wiz(*args, **kwargs): # noinspection PyDefaultArgument def __upsert_into_dot_wiz__(self, input_dict={}, - __set=dict.__setitem__, **kwargs): """ Helper method to generate / update a :class:`DotWiz` (dot-access dict) @@ -57,21 +55,20 @@ def __upsert_into_dot_wiz__(self, input_dict={}, value = [__resolve_value__(e, DotWiz) for e in value] # note: this logic is the same as `DotWiz.__setitem__()` - __set(self, key, value) __dict[key] = value -def __setitem_impl__(self, key, value, __set=dict.__setitem__): +def __setitem_impl__(self, key, value): """Implementation of `DotWiz.__setitem__` to preserve dot access""" value = __resolve_value__(value, DotWiz) - __set(self, key, value) self.__dict__[key] = value -class DotWiz(dict, metaclass=__add_repr__, print_char='✫'): +class DotWiz(metaclass=__add_shared_methods__, + print_char='✫'): """ - :class:`DotWiz` - a blazing *fast* ``dict`` subclass that also supports + :class:`DotWiz` - a blazing *fast* ``dict`` type that also supports *dot access* notation. Usage:: @@ -87,12 +84,56 @@ class DotWiz(dict, metaclass=__add_repr__, print_char='✫'): __init__ = update = __upsert_into_dot_wiz__ - __delattr__ = __delitem__ = dict.__delitem__ __setattr__ = __setitem__ = __setitem_impl__ def __getitem__(self, key): - return self.__dict__[key] + return getattr(self, key) - to_dict = __convert_to_dict__ - to_dict.__doc__ = 'Recursively convert the :class:`DotWiz` instance ' \ - 'back to a ``dict``.' + def __delitem__(self, key): + return delattr(self, key) + + def __eq__(self, other) -> bool: + return self.__dict__ == other + + def __contains__(self, item): + # TODO: maybe use `hasattr`? + return item in self.__dict__ + + def __iter__(self): + return iter(self.__dict__) + + def __len__(self) -> int: + return len(self.__dict__) + + def items(self) -> ItemsView: + return self.__dict__.items() + + def values(self) -> ValuesView: + return self.__dict__.values() + + def copy(self): + """Returns a shallow copy of dictionary wrapped in DotWiz. + + :return: Dotty instance + """ + return DotWiz(self.__dict__.copy()) + + @staticmethod + def fromkeys(seq, value=None): + """Create a new dictionary with keys from seq and values set to value. + + New created dictionary is wrapped in Dotty. + + :param seq: Sequence of elements which is to be used as keys for the new dictionary + :param value: Value which is set to each element of the dictionary + :return: Dotty instance + """ + return DotWiz(dict.fromkeys(seq, value)) + + def get(self, key, default=None): + """Get value from deep key or default if key does not exist. + """ + try: + return self.__dict__[key] + except KeyError: + return default diff --git a/dotwiz/main.pyi b/dotwiz/main.pyi index e0e6611..01da0a1 100644 --- a/dotwiz/main.pyi +++ b/dotwiz/main.pyi @@ -1,4 +1,5 @@ -from typing import TypeVar, Callable, Protocol, Mapping, MutableMapping, Iterable +from typing import (TypeVar, Callable, Protocol, Mapping, MutableMapping, + Iterable, ItemsView, ValuesView) _T = TypeVar('_T') _KT = TypeVar('_KT') @@ -28,7 +29,7 @@ def __setitem_impl__(self: DotWiz, *, __set: _SetItem = dict.__setitem__) -> None: ... -class DotWiz(dict): +class DotWiz: # noinspection PyDefaultArgument def __init__(self, @@ -50,6 +51,16 @@ class DotWiz(dict): """ ... + def to_json(self) -> str: + """ + Serialize the :class:`DotWiz` instance as a JSON string. + """ + ... + + def items(self) -> ItemsView: ... + + def values(self) -> ValuesView: ... + # noinspection PyDefaultArgument def update(self, __m: MutableMapping[_KT, _VT] = {}, @@ -57,3 +68,5 @@ class DotWiz(dict): **kwargs: _T) -> None: ... def __repr__(self) -> str: ... + + def __len__(self) -> int: ... diff --git a/dotwiz/plus.py b/dotwiz/plus.py index 00f1e10..477f65b 100644 --- a/dotwiz/plus.py +++ b/dotwiz/plus.py @@ -5,10 +5,7 @@ from pyheck import snake from .common import ( - __add_repr__, - __convert_to_attr_dict__, - __convert_to_dict__, - __resolve_value__, + __resolve_value__, __add_shared_methods__, ) @@ -133,9 +130,9 @@ def __setitem_impl__(self, key, value): __store_in_object__(self, self.__dict__, key, value) -class DotWizPlus(dict, metaclass=__add_repr__, +class DotWizPlus(dict, metaclass=__add_shared_methods__, print_char='✪', - use_attr_dict=True): + has_attr_dict=True): # noinspection PyProtectedMember """ :class:`DotWizPlus` - a blazing *fast* ``dict`` subclass that also @@ -190,15 +187,6 @@ class DotWizPlus(dict, metaclass=__add_repr__, __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('_') diff --git a/dotwiz/plus.pyi b/dotwiz/plus.pyi index 16b25f5..68a4fb0 100644 --- a/dotwiz/plus.pyi +++ b/dotwiz/plus.pyi @@ -66,6 +66,12 @@ class DotWizPlus(dict): """ ... + def to_json(self) -> str: + """ + Serialize the :class:`DotWizPlus` instance as a JSON string. + """ + ... + # noinspection PyDefaultArgument def update(self, __m: MutableMapping[_KT, _VT] = {}, From 4874795c63f52f7d053c1514ff6da8b7d54b494a Mon Sep 17 00:00:00 2001 From: Ritvik Nag Date: Sat, 25 Jun 2022 20:19:01 -0400 Subject: [PATCH 02/55] minor refactor --- README.rst | 2 +- dotwiz/common.py | 92 ++++++++++++++++++++++++++--------------------- dotwiz/common.pyi | 15 ++++---- dotwiz/main.py | 14 +++++--- dotwiz/plus.py | 4 +-- dotwiz/plus.pyi | 1 - 6 files changed, 71 insertions(+), 57 deletions(-) diff --git a/README.rst b/README.rst index be8359e..a5d79a8 100644 --- a/README.rst +++ b/README.rst @@ -19,7 +19,7 @@ Dot Wiz :alt: Updates -A `blazing fast`_ ``dict`` subclass that enables *dot access* notation via Python +A `blazing fast`_ ``dict`` type that enables *dot access* notation via Python attribute style. Nested ``dict`` and ``list`` values are automatically transformed as well. diff --git a/dotwiz/common.py b/dotwiz/common.py index ea5f6a2..403918c 100644 --- a/dotwiz/common.py +++ b/dotwiz/common.py @@ -6,15 +6,18 @@ class DotWizEncoder(json.JSONEncoder): """ - Helper class for encoding of nested DotWiz and DotWizPlus dicts into standard dict + Helper class for encoding of (nested) :class:`DotWiz` and + :class:`DotWizPlus` objects into a standard ``dict``. """ def default(self, o): - """Return dict data of DotWiz when possible or encode with standard format + """ + Return the `dict` data of :class:`DotWiz` when possible, or encode + with standard format otherwise. :param o: Input object - :return: Serializable data + """ try: return o.__dict__ @@ -23,12 +26,15 @@ def default(self, o): return json.JSONEncoder.default(self, o) -def __add_shared_methods__(name, bases, cls_dict, *, print_char='*', has_attr_dict=False): +def __add_common_methods__(name, bases, cls_dict, *, + print_char='*', + has_attr_dict=False): """ - Add shared methods to a class, such as :meth:`__repr__` and :meth:`to_json`. + Metaclass to generate and add common or shared methods -- such + as :meth:`__repr__` and :meth:`to_json` -- to a class. """ - # use attributes defined in the instance's `__dict__`. + # __repr__(): use attributes defined in the instance's `__dict__` def __repr__(self: object): fields = [f'{k}={v!r}' for k, v in self.__dict__.items()] return f'{print_char}({", ".join(fields)})' @@ -36,9 +42,9 @@ def __repr__(self: object): # add a `__repr__` magic method to the class. cls_dict['__repr__'] = __repr__ - # add common methods to the class, such as: - # - `to_dict` - # - `to_json` + # add utility or helper methods to the class, such as: + # - `to_dict` - convert an instance to a Python `dict` object. + # - `to_json` - serialize an instance as a JSON string. # - `to_attr_dict` - optional, only if `has_attr_dict` is specified. def __convert_to_dict__(o): @@ -56,8 +62,10 @@ def __convert_to_dict__(o): return o + # we need to add both `to_dict` and `to_attr_dict` in this case. if has_attr_dict: - def to_dict(o, __items=dict.items): + + def __convert_to_dict_preserve_keys__(o, __items=dict.items): """ Recursively convert an object (typically a `dict` subclass) to a Python `dict` type, while preserving the lower-cased keys used @@ -65,57 +73,61 @@ def to_dict(o, __items=dict.items): """ if isinstance(o, dict): # noinspection PyArgumentList - return {k: to_dict(v) for k, v in __items(o)} + return {k: __convert_to_dict_preserve_keys__(v) + for k, v in __items(o)} if isinstance(o, list): - return [to_dict(e) for e in o] + return [__convert_to_dict_preserve_keys__(e) for e in o] return o def to_json(o): return json.dumps(o) + # add a `to_json` method to the class. cls_dict['to_json'] = to_json - to_json.__doc__ = f'Serialize the :class:`{name}` instance as a JSON string.' - - cls_dict['to_dict'] = to_dict - to_dict.__doc__ = f'Recursively convert the :class:`{name}` instance ' \ - 'back to a ``dict``.' - + to_json.__doc__ = ( + f'Serialize the :class:`{name}` instance as a JSON string.' + ) + + # add a `to_dict` method to the class. + cls_dict['to_dict'] = __convert_to_dict_preserve_keys__ + __convert_to_dict_preserve_keys__.__name__ = 'to_dict' + __convert_to_dict_preserve_keys__.__doc__ = ( + f'Recursively convert the :class:`{name}` instance back to ' + 'a ``dict``.' + ) + + # add a `to_attr_dict` method to the class. cls_dict['to_attr_dict'] = __convert_to_dict__ __convert_to_dict__.__name__ = 'to_attr_dict' - __convert_to_dict__.__doc__ = f'Recursively convert the :class:`{name}` ' \ - 'instance back to a ``dict``, while ' \ - 'preserving the lower-cased keys used ' \ - 'for attribute access.' + __convert_to_dict__.__doc__ = ( + f'Recursively convert the :class:`{name}` instance back to ' + 'a ``dict``, while preserving the lower-cased keys used ' + 'for attribute access.' + ) + # we only need to add a `to_dict` method in this case. else: + def to_json(o): return json.dumps(o.__dict__, cls=DotWizEncoder) + # add a `to_json` method to the class. cls_dict['to_json'] = to_json - to_json.__doc__ = f'Serialize the :class:`{name}` instance as a JSON string.' + to_json.__doc__ = ( + f'Serialize the :class:`{name}` instance as a JSON string.' + ) + # add a `to_dict` method to the class. cls_dict['to_dict'] = __convert_to_dict__ __convert_to_dict__.__name__ = 'to_dict' - __convert_to_dict__.__doc__ = f'Recursively convert the :class:`{name}` ' \ - 'instance back to a ``dict``.' - - return type(name, bases, cls_dict) - - -def __add_repr__(name, bases, cls_dict, *, print_char='*'): - """ - Metaclass to generate and add a `__repr__` to a class. - """ - - # use attributes defined in the instance's __dict__`. - def __repr__(self: object): - fields = [f'{k}={v!r}' for k, v in self.__dict__.items()] - return f'{print_char}({", ".join(fields)})' - - cls_dict['__repr__'] = __repr__ + __convert_to_dict__.__doc__ = ( + f'Recursively convert the :class:`{name}` instance back to ' + f'a ``dict``.' + ) + # finally, build and return the new class. return type(name, bases, cls_dict) diff --git a/dotwiz/common.pyi b/dotwiz/common.pyi index a395ecf..06ee288 100644 --- a/dotwiz/common.pyi +++ b/dotwiz/common.pyi @@ -1,3 +1,4 @@ +import json from typing import Any, Callable, ItemsView, TypeVar from dotwiz import DotWiz, DotWizPlus @@ -11,19 +12,15 @@ _VT = TypeVar('_VT') _ItemsFn = Callable[[_D ], ItemsView[_KT, _VT]] -def __add_shared_methods__(name: str, +class DotWizEncoder(json.JSONEncoder): + def default(self, o: Any) -> Any: ... + + +def __add_common_methods__(name: str, bases: tuple[type, ...], cls_dict: dict[str, Any], *, print_char='*', has_attr_dict=False): ... -def __add_repr__(name: str, - bases: tuple[type, ...], - cls_dict: dict[str, Any], - *, print_char='*'): ... - -def __convert_to_dict__(o: dict | DotWiz | DotWizPlus | list | _T, - *, __items_fn: _ItemsFn = dict.items) -> dict[_KT, _VT] : ... - def __resolve_value__(value: _T, dict_type: type[DotWiz | DotWizPlus]) -> _T | _D | list[_D]: ... diff --git a/dotwiz/main.py b/dotwiz/main.py index 5bb16b7..983a0c3 100644 --- a/dotwiz/main.py +++ b/dotwiz/main.py @@ -2,7 +2,7 @@ from typing import ItemsView, ValuesView from .common import ( - __resolve_value__, __add_shared_methods__, + __resolve_value__, __add_common_methods__, ) @@ -65,7 +65,7 @@ def __setitem_impl__(self, key, value): self.__dict__[key] = value -class DotWiz(metaclass=__add_shared_methods__, +class DotWiz(metaclass=__add_common_methods__, print_char='✫'): """ :class:`DotWiz` - a blazing *fast* ``dict`` type that also supports @@ -87,10 +87,16 @@ class DotWiz(metaclass=__add_shared_methods__, __setattr__ = __setitem__ = __setitem_impl__ def __getitem__(self, key): - return getattr(self, key) + try: + return getattr(self, key) + except TypeError: # key is not a `str` + return self.__dict__[key] def __delitem__(self, key): - return delattr(self, key) + try: + delattr(self, key) + except TypeError: # key is not a `str` + del self.__dict__[key] def __eq__(self, other) -> bool: return self.__dict__ == other diff --git a/dotwiz/plus.py b/dotwiz/plus.py index 477f65b..6c08522 100644 --- a/dotwiz/plus.py +++ b/dotwiz/plus.py @@ -5,7 +5,7 @@ from pyheck import snake from .common import ( - __resolve_value__, __add_shared_methods__, + __resolve_value__, __add_common_methods__, ) @@ -130,7 +130,7 @@ def __setitem_impl__(self, key, value): __store_in_object__(self, self.__dict__, key, value) -class DotWizPlus(dict, metaclass=__add_shared_methods__, +class DotWizPlus(dict, metaclass=__add_common_methods__, print_char='✪', has_attr_dict=True): # noinspection PyProtectedMember diff --git a/dotwiz/plus.pyi b/dotwiz/plus.pyi index 68a4fb0..911ab06 100644 --- a/dotwiz/plus.pyi +++ b/dotwiz/plus.pyi @@ -1,4 +1,3 @@ -import keyword from typing import TypeVar, Callable, Protocol, Mapping, MutableMapping, Iterable _T = TypeVar('_T') From bbae7d67dc5d457e271ca670ff6f70d6960f8213 Mon Sep 17 00:00:00 2001 From: Ritvik Nag Date: Sat, 25 Jun 2022 21:47:13 -0400 Subject: [PATCH 03/55] update docs --- README.rst | 2 +- dotwiz/main.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index a5d79a8..8ae0cea 100644 --- a/README.rst +++ b/README.rst @@ -19,7 +19,7 @@ Dot Wiz :alt: Updates -A `blazing fast`_ ``dict`` type that enables *dot access* notation via Python +A `blazing fast`_ ``dict`` wrapper that enables *dot access* notation via Python attribute style. Nested ``dict`` and ``list`` values are automatically transformed as well. diff --git a/dotwiz/main.py b/dotwiz/main.py index 983a0c3..0324fe0 100644 --- a/dotwiz/main.py +++ b/dotwiz/main.py @@ -68,7 +68,7 @@ def __setitem_impl__(self, key, value): class DotWiz(metaclass=__add_common_methods__, print_char='✫'): """ - :class:`DotWiz` - a blazing *fast* ``dict`` type that also supports + :class:`DotWiz` - a blazing *fast* ``dict`` wrapper that also supports *dot access* notation. Usage:: From a227a71b5e924a5c78c6d0c74f09aad50ac4b405 Mon Sep 17 00:00:00 2001 From: Ritvik Nag Date: Sat, 25 Jun 2022 23:43:22 -0400 Subject: [PATCH 04/55] finish adding ``dict`` methods --- dotwiz/main.py | 95 +++++++++++++++++++++++++++++++++---------------- dotwiz/main.pyi | 41 ++++++++++++++++++--- 2 files changed, 101 insertions(+), 35 deletions(-) diff --git a/dotwiz/main.py b/dotwiz/main.py index 0324fe0..9c49718 100644 --- a/dotwiz/main.py +++ b/dotwiz/main.py @@ -1,5 +1,4 @@ """Main module.""" -from typing import ItemsView, ValuesView from .common import ( __resolve_value__, __add_common_methods__, @@ -84,62 +83,98 @@ class DotWiz(metaclass=__add_common_methods__, __init__ = update = __upsert_into_dot_wiz__ - __setattr__ = __setitem__ = __setitem_impl__ + def __bool__(self): + return True if self.__dict__ else False - def __getitem__(self, key): + def __contains__(self, item): + # assuming that item is usually a `str`, this is actually faster + # than simply: `item in self.__dict__` try: - return getattr(self, key) - except TypeError: # key is not a `str` - return self.__dict__[key] + _ = getattr(self, item) + return True + except AttributeError: + return False + except TypeError: # item is not a `str` + return item in self.__dict__ + + def __eq__(self, other): + return self.__dict__ == other + + def __ne__(self, other): + return self.__dict__ != other def __delitem__(self, key): + # in general, this is little faster than simply: `self.__dict__[key]` try: delattr(self, key) except TypeError: # key is not a `str` del self.__dict__[key] - def __eq__(self, other) -> bool: - return self.__dict__ == other + def __getitem__(self, key): + # in general, this is little faster than simply: `self.__dict__[key]` + try: + return getattr(self, key) + except TypeError: # key is not a `str` + return self.__dict__[key] - def __contains__(self, item): - # TODO: maybe use `hasattr`? - return item in self.__dict__ + __setattr__ = __setitem__ = __setitem_impl__ def __iter__(self): return iter(self.__dict__) - def __len__(self) -> int: + def __len__(self): return len(self.__dict__) - def items(self) -> ItemsView: - return self.__dict__.items() - - def values(self) -> ValuesView: - return self.__dict__.values() + def clear(self): + return self.__dict__.clear() def copy(self): - """Returns a shallow copy of dictionary wrapped in DotWiz. + """ + Returns a shallow copy of the `dict` wrapped in :class:`DotWiz`. - :return: Dotty instance + :return: DotWiz instance """ return DotWiz(self.__dict__.copy()) - @staticmethod - def fromkeys(seq, value=None): - """Create a new dictionary with keys from seq and values set to value. + # noinspection PyIncorrectDocstring + @classmethod + def fromkeys(cls, seq, value=None, __from_keys=dict.fromkeys): + """ + Create a new dictionary with keys from `seq` and values set to `value`. - New created dictionary is wrapped in Dotty. + New created dictionary is wrapped in :class:`DotWiz`. - :param seq: Sequence of elements which is to be used as keys for the new dictionary - :param value: Value which is set to each element of the dictionary - :return: Dotty instance + :param seq: Sequence of elements which is to be used as keys for + the new dictionary. + :param value: Value which is set to each element of the dictionary. + :return: DotWiz instance """ - return DotWiz(dict.fromkeys(seq, value)) + return cls(__from_keys(seq, value)) - def get(self, key, default=None): - """Get value from deep key or default if key does not exist. + def get(self, k, default=None): + """ + Get value from :class:`DotWiz` instance, or default if the key + does not exist. """ try: - return self.__dict__[key] + return self.__dict__[k] except KeyError: return default + + def keys(self): + return self.__dict__.keys() + + def items(self): + return self.__dict__.items() + + def pop(self, k): + return self.__dict__.pop(k) + + def popitem(self): + return self.__dict__.popitem() + + def setdefault(self, k, default=None): + return self.__dict__.setdefault(k, default) + + def values(self): + return self.__dict__.values() diff --git a/dotwiz/main.pyi b/dotwiz/main.pyi index 01da0a1..298f606 100644 --- a/dotwiz/main.pyi +++ b/dotwiz/main.pyi @@ -1,5 +1,9 @@ -from typing import (TypeVar, Callable, Protocol, Mapping, MutableMapping, - Iterable, ItemsView, ValuesView) +from typing import ( + Callable, Protocol, TypeVar, + Iterable, Iterator, + KeysView, ItemsView, ValuesView, + Mapping, MutableMapping, +) _T = TypeVar('_T') _KT = TypeVar('_KT') @@ -36,6 +40,12 @@ class DotWiz: input_dict: MutableMapping[_KT, _VT] = {}, **kwargs: _T) -> None: ... + def __bool__(self) -> bool: ... + def __contains__(self, item: _KT) -> bool: ... + + def __eq__(self, other: object) -> bool: ... + def __ne__(self, other: object) -> bool: ... + def __delattr__(self, item: str) -> None: ... def __delitem__(self, v: _KT) -> None: ... @@ -45,6 +55,9 @@ class DotWiz: def __setattr__(self, item: str, value: _VT) -> None: ... def __setitem__(self, k: _KT, v: _VT) -> None: ... + def __iter__(self) -> Iterator: ... + def __len__(self) -> int: ... + def to_dict(self) -> dict[_KT, _VT]: """ Recursively convert the :class:`DotWiz` instance back to a ``dict``. @@ -57,9 +70,27 @@ class DotWiz: """ ... + def clear(self) -> None: ... + + def copy(self) -> DotWiz: ... + + @classmethod + def fromkeys(cls: type[DotWiz], + seq: Iterable, + value: Iterable | None = None, + *, __from_keys=dict.fromkeys): ... + + def get(self, k: _KT, default=None) -> _VT | None: ... + + def keys(self) -> KeysView: ... + def items(self) -> ItemsView: ... - def values(self) -> ValuesView: ... + def pop(self, k: _KT) -> _VT: ... + + def popitem(self) -> tuple[_KT, _VT]: ... + + def setdefault(self, k: _KT, default=None) -> _VT: ... # noinspection PyDefaultArgument def update(self, @@ -67,6 +98,6 @@ class DotWiz: *, __set: _SetItem = dict.__setitem__, **kwargs: _T) -> None: ... - def __repr__(self) -> str: ... + def values(self) -> ValuesView: ... - def __len__(self) -> int: ... + def __repr__(self) -> str: ... From e10fc7e02e4ca819f8fa3cdd298c8f9755dafd29 Mon Sep 17 00:00:00 2001 From: Ritvik Nag Date: Sun, 26 Jun 2022 00:09:38 -0400 Subject: [PATCH 05/55] add `__dir__()` method for `DotWizPlus` --- dotwiz/plus.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/dotwiz/plus.py b/dotwiz/plus.py index 6c08522..1454e88 100644 --- a/dotwiz/plus.py +++ b/dotwiz/plus.py @@ -187,6 +187,19 @@ class DotWizPlus(dict, metaclass=__add_common_methods__, __delattr__ = __delitem__ = dict.__delitem__ __setattr__ = __setitem__ = __setitem_impl__ + def __dir__(self): + """ + Add a ``__dir__()`` method, so that tab auto-completion and + attribute suggestions work as expected in IPython and Jupyter. + + For more info, check out `this post`_. + + .. _this post: https://stackoverflow.com/q/51917470/10237506 + """ + super_dir = super().__dir__() + string_keys = [k for k in self.__dict__ if type(k) is str] + return super_dir + [k for k in string_keys if k not in super_dir] + # A list of the public-facing methods in `DotWizPlus` __PUB_METHODS = (m for m in dir(DotWizPlus) if not m.startswith('_') From 6d1cca5d540837a9b1464fd1608da810698c3826 Mon Sep 17 00:00:00 2001 From: Ritvik Nag Date: Sun, 26 Jun 2022 00:15:33 -0400 Subject: [PATCH 06/55] add `__dir__()` method for `DotWizPlus` --- dotwiz/plus.pyi | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/dotwiz/plus.pyi b/dotwiz/plus.pyi index 911ab06..aa715a0 100644 --- a/dotwiz/plus.pyi +++ b/dotwiz/plus.pyi @@ -1,4 +1,6 @@ -from typing import TypeVar, Callable, Protocol, Mapping, MutableMapping, Iterable +from typing import ( + Callable, Iterable, Mapping, MutableMapping, Protocol, TypeVar, +) _T = TypeVar('_T') _KT = TypeVar('_KT') @@ -76,4 +78,6 @@ class DotWizPlus(dict): __m: MutableMapping[_KT, _VT] = {}, **kwargs: _T) -> None: ... + def __dir__(self) -> Iterable[str]: ... + def __repr__(self) -> str: ... From ec17b202846fcb4dcf473e038aa965142fe910d9 Mon Sep 17 00:00:00 2001 From: Ritvik Nag Date: Sun, 26 Jun 2022 00:40:18 -0400 Subject: [PATCH 07/55] add param `check_lists` to `DotWiz.__init__` --- benchmarks/test_create.py | 11 +++++++++++ benchmarks/test_create_special_keys.py | 7 +++++++ dotwiz/main.py | 7 ++++--- dotwiz/main.pyi | 4 +++- 4 files changed, 25 insertions(+), 4 deletions(-) diff --git a/benchmarks/test_create.py b/benchmarks/test_create.py index 3cd3bd9..ceb75f5 100644 --- a/benchmarks/test_create.py +++ b/benchmarks/test_create.py @@ -77,6 +77,17 @@ def test_dotwiz(benchmark, my_data): assert result.c.bb[0].x == 77 +def test_dotwiz_without_check_lists(benchmark, my_data): + result = benchmark(dotwiz.DotWiz, my_data, check_lists=False) + # print(result) + + # now similar to `dict2dot`, `dict`s nested within `lists` won't work + # assert result.c.bb[0].x == 77 + + # instead, dict access should work fine: + assert result.c.bb[0]['x'] == 77 + + def test_make_dot_wiz(benchmark, my_data): result = benchmark(dotwiz.make_dot_wiz, my_data) # print(result) diff --git a/benchmarks/test_create_special_keys.py b/benchmarks/test_create_special_keys.py index 2758ddc..0e60340 100644 --- a/benchmarks/test_create_special_keys.py +++ b/benchmarks/test_create_special_keys.py @@ -161,6 +161,13 @@ def test_dotwiz(benchmark, my_data): assert_eq2(result) +def test_dotwiz_without_check_lists(benchmark, my_data): + result = benchmark(dotwiz.DotWiz, my_data, check_lists=False) + # print(result) + + assert_eq2(result) + + def test_make_dot_wiz(benchmark, my_data): result = benchmark(dotwiz.make_dot_wiz, my_data) # print(result) diff --git a/dotwiz/main.py b/dotwiz/main.py index 9c49718..8fdc003 100644 --- a/dotwiz/main.py +++ b/dotwiz/main.py @@ -24,7 +24,7 @@ def make_dot_wiz(*args, **kwargs): # noinspection PyDefaultArgument def __upsert_into_dot_wiz__(self, input_dict={}, - **kwargs): + check_lists=True, **kwargs): """ Helper method to generate / update a :class:`DotWiz` (dot-access dict) from a Python ``dict`` object, and optional *keyword arguments*. @@ -49,8 +49,9 @@ def __upsert_into_dot_wiz__(self, input_dict={}, t = type(value) if t is dict: - value = DotWiz(value) - elif t is list: + # noinspection PyArgumentList + value = DotWiz(value, check_lists) + elif check_lists and t is list: value = [__resolve_value__(e, DotWiz) for e in value] # note: this logic is the same as `DotWiz.__setitem__()` diff --git a/dotwiz/main.pyi b/dotwiz/main.pyi index 298f606..54f446e 100644 --- a/dotwiz/main.pyi +++ b/dotwiz/main.pyi @@ -38,6 +38,7 @@ class DotWiz: # noinspection PyDefaultArgument def __init__(self, input_dict: MutableMapping[_KT, _VT] = {}, + *, check_lists=True, **kwargs: _T) -> None: ... def __bool__(self) -> bool: ... @@ -95,7 +96,8 @@ class DotWiz: # noinspection PyDefaultArgument def update(self, __m: MutableMapping[_KT, _VT] = {}, - *, __set: _SetItem = dict.__setitem__, + *, check_lists=True, + __set: _SetItem = dict.__setitem__, **kwargs: _T) -> None: ... def values(self) -> ValuesView: ... From 277de267d7f75403253c4d7cf162215ac2c33766 Mon Sep 17 00:00:00 2001 From: Ritvik Nag Date: Tue, 28 Jun 2022 11:05:29 -0400 Subject: [PATCH 08/55] add `attrdict` and `SimpleNamespace` to benchmarks --- benchmarks/test_create.py | 15 +++++++++++++ benchmarks/test_create_special_keys.py | 30 ++++++++++++++++++++++++++ benchmarks/test_getattr.py | 17 +++++++++++++++ requirements-dev.txt | 1 + 4 files changed, 63 insertions(+) diff --git a/benchmarks/test_create.py b/benchmarks/test_create.py index 3cd3bd9..8a22a0d 100644 --- a/benchmarks/test_create.py +++ b/benchmarks/test_create.py @@ -1,6 +1,7 @@ import dataclasses import addict +import attrdict import box import dict2dot import dotmap @@ -152,6 +153,13 @@ def test_addict(benchmark, my_data): assert result.c.bb[0].x == 77 +def test_attrdict(benchmark, my_data): + result = benchmark(attrdict.AttrDict, my_data) + # print(result) + + assert result.c.bb[0].x == 77 + + def test_metadict(benchmark, my_data): result = benchmark(metadict.MetaDict, my_data) # print(result) @@ -183,3 +191,10 @@ def test_scalpl(benchmark, my_data): # print(result) assert result['c.bb[0].x'] == 77 + + +def test_simple_namespace(benchmark, my_data, parse_to_ns): + result = benchmark(parse_to_ns, my_data) + # print(result) + + assert result.c.bb[0].x == 77 diff --git a/benchmarks/test_create_special_keys.py b/benchmarks/test_create_special_keys.py index 2758ddc..3b3eabb 100644 --- a/benchmarks/test_create_special_keys.py +++ b/benchmarks/test_create_special_keys.py @@ -1,6 +1,7 @@ import dataclasses import addict +import attrdict import box import dict2dot import dotmap @@ -124,6 +125,21 @@ def assert_eq6(result: MyClassSpecialCased): assert result.some_random_key_here == 'T' +def assert_eq7(result, ns_to_dict): + """For testing with a `types.SimpleNamespace` object, primarily""" + assert result.camelCase == 1 + assert result.Snake_Case == 2 + assert result.PascalCase == 3 + + result_dict = ns_to_dict(result) + + assert result_dict['spinal-case3'] == 4 + assert result_dict['Hello, how\'s it going?'] == 5 + assert result_dict['3D'] == 6 + assert result_dict['for']['1nfinity'][0]['and']['Beyond!'] == 8 + assert result_dict['Some r@ndom#$(*#@ Key##$# here !!!'] == 'T' + + @pytest.mark.xfail(reason='some key names are not valid identifiers') def test_make_dataclass(benchmark, my_data): # noinspection PyPep8Naming @@ -234,6 +250,13 @@ def test_addict(benchmark, my_data): assert_eq2(result) +def test_attrdict(benchmark, my_data): + result = benchmark(attrdict.AttrDict, my_data) + # print(result) + + assert_eq2(result) + + def test_metadict(benchmark, my_data): result = benchmark(metadict.MetaDict, my_data) # print(result) @@ -259,3 +282,10 @@ def test_scalpl(benchmark, my_data): # print(result) assert_eq5(result, subscript_list=True) + + +def test_simple_namespace(benchmark, my_data, parse_to_ns, ns_to_dict): + result = benchmark(parse_to_ns, my_data) + # print(result) + + assert_eq7(result, ns_to_dict) diff --git a/benchmarks/test_getattr.py b/benchmarks/test_getattr.py index ce3b16b..8c50e70 100644 --- a/benchmarks/test_getattr.py +++ b/benchmarks/test_getattr.py @@ -1,6 +1,7 @@ import dataclasses import addict +import attrdict import box import dict2dot import dotmap @@ -149,6 +150,14 @@ def test_addict(benchmark, my_data): assert result == 77 +def test_attrdict(benchmark, my_data): + o = attrdict.AttrDict(my_data) + # print(o) + + result = benchmark(lambda: o.c.bb[0].x) + assert result == 77 + + def test_glom(benchmark, my_data): o = my_data # print(o) @@ -187,3 +196,11 @@ def test_scalpl(benchmark, my_data): result = benchmark(lambda: o['c.bb[0].x']) assert result == 77 + + +def test_simple_namespace(benchmark, my_data, parse_to_ns): + o = parse_to_ns(my_data) + # print(o) + + result = benchmark(lambda: o.c.bb[0].x) + assert result == 77 diff --git a/requirements-dev.txt b/requirements-dev.txt index c7a80af..cf627cd 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -20,6 +20,7 @@ dotsi==0.0.3 dotted-dict==1.1.3 dotty-dict==1.3.0 addict==2.4.0 +attrdict3==2.0.2 metadict==0.1.2 prodict==0.8.18 python-box==6.0.2 From 3f550ec9b74014928dae9a5011c4d61a202cc583 Mon Sep 17 00:00:00 2001 From: Ritvik Nag Date: Tue, 28 Jun 2022 11:06:59 -0400 Subject: [PATCH 09/55] I realized I didn't add `conftest.py` --- benchmarks/conftest.py | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 benchmarks/conftest.py diff --git a/benchmarks/conftest.py b/benchmarks/conftest.py new file mode 100644 index 0000000..98c1301 --- /dev/null +++ b/benchmarks/conftest.py @@ -0,0 +1,37 @@ +from types import SimpleNamespace + +import pytest + + +@pytest.fixture +def parse_to_ns(): + """ + Return a helper function to parse a (nested) `dict` object + and return a `SimpleNamespace` object. + """ + + def parse(d): + ns = SimpleNamespace() + + for k, v in d.items(): + setattr(ns, k, + parse(v) if isinstance(v, dict) + else [parse(e) for e in v] if isinstance(v, list) + else v) + + return ns + + return parse + + +@pytest.fixture +def ns_to_dict(): + + def to_dict(ns): + """Recursively converts a `SimpleNamespace` object to a `dict`.""" + return {k: to_dict(v) if isinstance(v, SimpleNamespace) + else [to_dict(e) for e in v] if isinstance(v, list) + else v + for k, v in vars(ns).items()} + + return to_dict From 5f0e9ca14aa990fd05b3a4b4b84191fd93b36e4a Mon Sep 17 00:00:00 2001 From: Ritvik Nag Date: Tue, 28 Jun 2022 12:05:19 -0400 Subject: [PATCH 10/55] add tests for `to_json` and `__dir__` --- dotwiz/common.py | 9 +++++---- dotwiz/main.pyi | 22 ++++++++++++++++++-- dotwiz/plus.pyi | 23 +++++++++++++++++++-- tests/unit/test_dotwiz.py | 33 ++++++++++++++++++++++++++++++ tests/unit/test_dotwiz_plus.py | 37 ++++++++++++++++++++++++++++++++++ 5 files changed, 116 insertions(+), 8 deletions(-) diff --git a/dotwiz/common.py b/dotwiz/common.py index 403918c..2251b2f 100644 --- a/dotwiz/common.py +++ b/dotwiz/common.py @@ -81,8 +81,8 @@ def __convert_to_dict_preserve_keys__(o, __items=dict.items): return o - def to_json(o): - return json.dumps(o) + def to_json(o, encoder=json.dumps, **encoder_kwargs): + return encoder(o, **encoder_kwargs) # add a `to_json` method to the class. cls_dict['to_json'] = to_json @@ -110,8 +110,9 @@ def to_json(o): # we only need to add a `to_dict` method in this case. else: - def to_json(o): - return json.dumps(o.__dict__, cls=DotWizEncoder) + def to_json(o, encoder=json.dumps, **encoder_kwargs): + cls = encoder_kwargs.pop('cls', DotWizEncoder) + return encoder(o.__dict__, cls=cls, **encoder_kwargs) # add a `to_json` method to the class. cls_dict['to_json'] = to_json diff --git a/dotwiz/main.pyi b/dotwiz/main.pyi index 54f446e..ec72992 100644 --- a/dotwiz/main.pyi +++ b/dotwiz/main.pyi @@ -1,22 +1,38 @@ +import json from typing import ( Callable, Protocol, TypeVar, Iterable, Iterator, KeysView, ItemsView, ValuesView, - Mapping, MutableMapping, + Mapping, MutableMapping, AnyStr, Any, ) _T = TypeVar('_T') _KT = TypeVar('_KT') _VT = TypeVar('_VT') +# Valid collection types in JSON. +_JSONList = list[Any] +_JSONObject = dict[str, Any] + _SetItem = Callable[[dict, _KT, _VT], None] + # Ref: https://stackoverflow.com/a/68392079/10237506 class _Update(Protocol): def __call__(self, instance: dict, __m: Mapping[_KT, _VT] | None = None, **kwargs: _T) -> None: ... +class Encoder(Protocol): + """ + Represents an encoder for Python object -> JSON, e.g. analogous to + `json.dumps` + """ + + def __call__(self, obj: _JSONObject | _JSONList, + **kwargs) -> AnyStr: + ... + def make_dot_wiz(*args: Iterable[_KT, _VT], **kwargs: _T) -> DotWiz: ... @@ -65,7 +81,9 @@ class DotWiz: """ ... - def to_json(self) -> str: + def to_json(self, *, + encoder: Encoder = json.dumps, + **encoder_kwargs) -> AnyStr: """ Serialize the :class:`DotWiz` instance as a JSON string. """ diff --git a/dotwiz/plus.pyi b/dotwiz/plus.pyi index aa715a0..7217a4f 100644 --- a/dotwiz/plus.pyi +++ b/dotwiz/plus.pyi @@ -1,11 +1,18 @@ +import json from typing import ( - Callable, Iterable, Mapping, MutableMapping, Protocol, TypeVar, + AnyStr, Any, Callable, Iterable, + Mapping, MutableMapping, + Protocol, TypeVar, ) _T = TypeVar('_T') _KT = TypeVar('_KT') _VT = TypeVar('_VT') +# Valid collection types in JSON. +_JSONList = list[Any] +_JSONObject = dict[str, Any] + _SetItem = Callable[[dict, _KT, _VT], None] # Ref: https://stackoverflow.com/a/68392079/10237506 @@ -14,6 +21,16 @@ class _Update(Protocol): __m: Mapping[_KT, _VT] | None = None, **kwargs: _T) -> None: ... +class Encoder(Protocol): + """ + Represents an encoder for Python object -> JSON, e.g. analogous to + `json.dumps` + """ + + def __call__(self, obj: _JSONObject | _JSONList, + **kwargs) -> AnyStr: + ... + __SPECIAL_KEYS: dict[str, str] = ... __IS_KEYWORD: Callable[[object], bool] = ... @@ -67,7 +84,9 @@ class DotWizPlus(dict): """ ... - def to_json(self) -> str: + def to_json(self, *, + encoder: Encoder = json.dumps, + **encoder_kwargs) -> AnyStr: """ Serialize the :class:`DotWizPlus` instance as a JSON string. """ diff --git a/tests/unit/test_dotwiz.py b/tests/unit/test_dotwiz.py index 41f130c..85c3160 100644 --- a/tests/unit/test_dotwiz.py +++ b/tests/unit/test_dotwiz.py @@ -1,4 +1,5 @@ """Tests for `dotwiz` package.""" +from datetime import datetime import pytest @@ -173,3 +174,35 @@ def test_dotwiz_to_dict(): } ] } + + +def test_dotwiz_to_json(): + """Confirm intended functionality of `DotWiz.to_json`""" + dw = DotWiz(hello=[{"key": "value", "another-key": {"a": "b"}}]) + + assert dw.to_json(indent=4) == """\ +{ + "hello": [ + { + "key": "value", + "another-key": { + "a": "b" + } + } + ] +}""" + + +def test_dotwiz_to_json_with_non_serializable_type(): + """ + Confirm intended functionality of `DotWiz.to_json` when an object + doesn't define a `__dict__`, so the default `JSONEncoder.default` + implementation is called. + """ + + dw = DotWiz(string='val', dt=datetime.min) + # print(dw) + + # TypeError: Object of type `datetime` is not JSON serializable + with pytest.raises(TypeError): + _ = dw.to_json() diff --git a/tests/unit/test_dotwiz_plus.py b/tests/unit/test_dotwiz_plus.py index 8ea7741..7efa306 100644 --- a/tests/unit/test_dotwiz_plus.py +++ b/tests/unit/test_dotwiz_plus.py @@ -186,6 +186,27 @@ def test_dotwiz_plus_to_dict(): } +def test_dotwiz_to_json(): + """Confirm intended functionality of `DotWizPlus.to_json`""" + dw = DotWizPlus(hello=[{"Key": "value", "Another-KEY": {"a": "b"}}], + camelCased={r"th@#$%is.is.!@#$%^&*()a{}\:/~`.T'e'\"st": True}) + + assert dw.to_json(indent=4) == r""" +{ + "hello": [ + { + "Key": "value", + "Another-KEY": { + "a": "b" + } + } + ], + "camelCased": { + "th@#$%is.is.!@#$%^&*()a{}\\:/~`.T'e'\\\"st": true + } +}""".lstrip() + + def test_dotwiz_plus_to_attr_dict(): """Confirm intended functionality of `DotWizPlus.to_dict`""" dw = DotWizPlus(hello=[{"Key": "value", "Another-KEY": {"a": "b"}}], @@ -213,3 +234,19 @@ def test_key_in_special_keys(): dw = DotWizPlus({'3D': True}) assert dw._3d + + +def test_dir(): + """"Confirm intended functionality of `DotWizPlus.__dir__`""" + dw = DotWizPlus({'1string': 'value', 'lambda': 42}) + + obj_dir = dir(dw) + + assert 'keys' in obj_dir + assert 'to_attr_dict' in obj_dir + + assert '_1string' in obj_dir + assert 'lambda_' in obj_dir + + assert '1string' not in obj_dir + assert 'lambda' not in obj_dir From cb8dd1d25c3fd230f6bab9d5da4b1d3600a7123d Mon Sep 17 00:00:00 2001 From: Ritvik Nag Date: Wed, 29 Jun 2022 21:16:50 -0400 Subject: [PATCH 11/55] add tests for better test coverage --- dotwiz/main.py | 4 +- dotwiz/main.pyi | 9 +- dotwiz/plus.pyi | 3 + tests/unit/test_dotwiz.py | 277 ++++++++++++++++++++++++++++++++++++-- 4 files changed, 278 insertions(+), 15 deletions(-) diff --git a/dotwiz/main.py b/dotwiz/main.py index 8fdc003..54d7465 100644 --- a/dotwiz/main.py +++ b/dotwiz/main.py @@ -168,8 +168,8 @@ def keys(self): def items(self): return self.__dict__.items() - def pop(self, k): - return self.__dict__.pop(k) + def pop(self, key: str, *args): + return self.__dict__.pop(key, *args) def popitem(self): return self.__dict__.popitem() diff --git a/dotwiz/main.pyi b/dotwiz/main.pyi index ec72992..a5d7289 100644 --- a/dotwiz/main.pyi +++ b/dotwiz/main.pyi @@ -3,7 +3,7 @@ from typing import ( Callable, Protocol, TypeVar, Iterable, Iterator, KeysView, ItemsView, ValuesView, - Mapping, MutableMapping, AnyStr, Any, + Mapping, MutableMapping, AnyStr, Any, overload, ) _T = TypeVar('_T') @@ -86,6 +86,9 @@ class DotWiz: **encoder_kwargs) -> AnyStr: """ Serialize the :class:`DotWiz` instance as a JSON string. + + :param encoder: The encoder to serialize with, defaults to `json.dumps`. + :param encoder_kwargs: The keyword arguments to pass in to the encoder. """ ... @@ -105,8 +108,12 @@ class DotWiz: def items(self) -> ItemsView: ... + @overload def pop(self, k: _KT) -> _VT: ... + @overload + def pop(self, k: _KT, default: _VT | _T) -> _VT | _T: ... + def popitem(self) -> tuple[_KT, _VT]: ... def setdefault(self, k: _KT, default=None) -> _VT: ... diff --git a/dotwiz/plus.pyi b/dotwiz/plus.pyi index 7217a4f..dad0724 100644 --- a/dotwiz/plus.pyi +++ b/dotwiz/plus.pyi @@ -89,6 +89,9 @@ class DotWizPlus(dict): **encoder_kwargs) -> AnyStr: """ Serialize the :class:`DotWizPlus` instance as a JSON string. + + :param encoder: The encoder to serialize with, defaults to `json.dumps`. + :param encoder_kwargs: The keyword arguments to pass in to the encoder. """ ... diff --git a/tests/unit/test_dotwiz.py b/tests/unit/test_dotwiz.py index 85c3160..ae8f7c2 100644 --- a/tests/unit/test_dotwiz.py +++ b/tests/unit/test_dotwiz.py @@ -1,4 +1,6 @@ """Tests for `dotwiz` package.""" +from collections import OrderedDict, defaultdict +from copy import deepcopy from datetime import datetime import pytest @@ -6,7 +8,7 @@ from dotwiz import DotWiz, make_dot_wiz -def test_dot_wiz_with_basic_usage(): +def test_basic_usage(): """Confirm intended functionality of `DotWiz`""" dw = DotWiz({'key_1': [{'k': 'v'}], 'keyTwo': '5', @@ -33,7 +35,7 @@ def test_make_dot_wiz(): assert dd.b == [1, 2, 3] -def test_dotwiz_init(): +def test_init(): """Confirm intended functionality of `DotWiz.__init__`""" dd = DotWiz({ 1: 'test', @@ -54,7 +56,7 @@ def test_dotwiz_init(): assert dd.b == [1, 2, 3] -def test_dotwiz_del_attr(): +def test_del_attr(): dd = DotWiz( a=1, b={'one': [1], @@ -80,7 +82,7 @@ def test_dotwiz_del_attr(): assert 'b' not in dd -def test_dotwiz_get_attr(): +def test_get_attr(): """Confirm intended functionality of `DotWiz.__getattr__`""" dd = DotWiz() dd.a = [{'one': 1, 'two': {'key': 'value'}}] @@ -94,7 +96,7 @@ def test_dotwiz_get_attr(): assert item.two.key == 'value' -def test_dotwiz_get_item(): +def test_get_item(): """Confirm intended functionality of `DotWiz.__getitem__`""" dd = DotWiz() dd.a = [{'one': 1, 'two': {'key': 'value'}}] @@ -106,7 +108,7 @@ def test_dotwiz_get_item(): assert item['two']['key'] == 'value' -def test_dotwiz_set_attr(): +def test_set_attr(): """Confirm intended functionality of `DotWiz.__setattr__`""" dd = DotWiz() dd.a = [{'one': 1, 'two': 2}] @@ -117,7 +119,7 @@ def test_dotwiz_set_attr(): assert item.two == 2 -def test_dotwiz_set_item(): +def test_set_item(): """Confirm intended functionality of `DotWiz.__setitem__`""" dd = DotWiz() dd['a'] = [{'one': 1, 'two': 2}] @@ -128,7 +130,193 @@ def test_dotwiz_set_item(): assert item.two == 2 -def test_dotwiz_update(): +@pytest.mark.parametrize("data,result", [({"a": 42}, True), ({}, False)]) +def test_bool(data, result): + dw = DotWiz(data) + assert bool(dw) is result + + +def test_clear(): + dw = DotWiz({"a": 42}) + dw.clear() + assert len(dw) == 0 + + +def test_copy(): + data = {"a": 42} + dw = DotWiz(data) + assert dw.copy() == data + + +class TestEquals: + + def test_against_another_dot_wiz(self): + data = {"a": 42} + dw = DotWiz(data) + assert dw == DotWiz(data) + + def test_against_another_dict(self): + data = {"a": 42} + dw = DotWiz(data) + assert dw == dict(data) + + def test_against_another_ordered_dict(self): + data = {"a": 42} + dw = DotWiz(data) + assert dw == OrderedDict(data) + + def test_against_another_default_dict(self): + data = {"a": 42} + dw = DotWiz(data) + assert dw == defaultdict(None, data) + + +class TestNotEquals: + + def test_against_another_dot_wiz(self): + data = {"a": 42} + dw = DotWiz(a=41) + assert dw != DotWiz(data) + + def test_against_another_dict(self): + data = {"a": 42} + dw = DotWiz(a=41) + assert dw != dict(data) + + def test_against_another_ordered_dict(self): + data = {"a": 42} + dw = DotWiz(a=41) + assert dw != OrderedDict(data) + + def test_against_another_default_dict(self): + data = {"a": 42} + dw = DotWiz(a=41) + assert dw != defaultdict(None, data) + + +class TestFromkeys: + def test_fromkeys(self): + assert DotWiz.fromkeys(["Bulbasaur", "Charmander", "Squirtle"]) == DotWiz( + {"Bulbasaur": None, "Charmander": None, "Squirtle": None} + ) + + def test_fromkeys_with_default_value(self): + assert DotWiz.fromkeys(["Bulbasaur", "Charmander", "Squirtle"], "captured") == DotWiz( + {"Bulbasaur": "captured", "Charmander": "captured", "Squirtle": "captured"} + ) + + +def test_items(): + dw = DotWiz({"a": 1, "b": 2, "c": 3}) + assert sorted(dw.items()) == [("a", 1), ("b", 2), ("c", 3)] + + +def test_iter(): + dw = DotWiz({"a": 1, "b": 2, "c": 3}) + assert sorted([key for key in dw]) == ["a", "b", "c"] + + +def test_keys(): + dw = DotWiz({"a": 1, "b": 2, "c": 3}) + assert sorted(dw.keys()) == ["a", "b", "c"] + + +def test_values(): + dw = DotWiz({"a": 1, "b": 2, "c": 3}) + assert sorted(dw.values()) == [1, 2, 3] + + +def test_len(): + dw = DotWiz({"a": 1, "b": 2, "c": 3}) + assert len(dw) == 3 + + +def test_popitem(): + dw = DotWiz({"a": 1, "b": 2, "c": 3}) + dw.popitem() + assert len(dw) == 2 + + +@pytest.mark.parametrize( + "data,key,result", + [ + ({"a": 42}, "a", 42), + ({"a": 42}, "b", None), + # TODO: enable once we set up dot-style access + # ({"a": {"b": 42}}, "a.b", 42), + # ({"a": {"b": {"c": 42}}}, "a.b.c", 42), + # ({"a": [42]}, "a[0]", 42), + # ({"a": [{"b": 42}]}, "a[0].b", 42), + # ({"a": [42]}, "a[1]", None), + # ({"a": [{"b": 42}]}, "a[1].b", None), + # ({"a": {"b": 42}}, "a.c", None), + # ({"a": {"b": {"c": 42}}}, "a.b.d", None), + ], +) +def test_get(data, key, result): + dw = DotWiz(data) + assert dw.get(key) == result + + +@pytest.mark.parametrize( + "data,key,default", + [ + ({}, "b", None), + ({"a": 42}, "b", "default"), + ], +) +def test_with_default(data, key, default): + dw = DotWiz(data) + assert dw.get(key, default) == default + + +class TestDelitem: + @pytest.mark.parametrize( + "data,key", + [ + ({"a": 42}, "a"), + ({"a": 1, "b": 2}, "b"), + ], + ) + def test_delitem(self, data, key): + dw = DotWiz(deepcopy(data)) + del dw[key] + assert key not in dw + + def test_key_error(self): + dw = DotWiz({"a": 1, "c": 3}) + # raises `AttributeError` currently, might want to return a `KeyError` instead though + with pytest.raises(AttributeError): + del dw["b"] + + @pytest.mark.parametrize( + "data,key", + [ + ({False: "a"}, False), + ({1: "a", 2: "b"}, 2), + ], + ) + def test_type_error(self, data, key): + dw = DotWiz(deepcopy(data)) + # raises `TypeError` internally, but delete is still successful + del dw[key] + assert key not in dw + + +class TestContains: + @pytest.mark.parametrize( + "data,key,result", + [ + ({"a": 42}, "a", True), + ({"a": 42}, "b", False), + ], + ) + def test_contains(self, data, key, result): + dw = DotWiz(data) + assert (key in dw) is result + + +def test_update(): """Confirm intended functionality of `DotWiz.update`""" dd = DotWiz(a=1, b={'one': [1]}) assert isinstance(dd.b, DotWiz) @@ -151,7 +339,7 @@ def test_dotwiz_update(): assert item.five == '5' -def test_dotwiz_update_with_no_args(): +def test_update_with_no_args(): """Add for full branch coverage.""" dd = DotWiz(a=1, b={'one': [1]}) @@ -162,7 +350,72 @@ def test_dotwiz_update_with_no_args(): assert dd.a == 2 -def test_dotwiz_to_dict(): +class TestPop: + + @pytest.mark.parametrize( + "data,key,result", + [ + ({"a": 42}, "a", 42), + ({"a": 1, "b": 2}, "b", 2), + ], + ) + def test_pop(self, data, key, result): + dw = DotWiz(deepcopy(data)) + assert dw.pop(key) == result + assert key not in dw + + @pytest.mark.parametrize( + "data,key,default", + [ + ({}, "b", None), + ({"a": 1}, "b", 42), + ], + ) + def test_with_default(self, data, key, default): + dw = DotWiz(deepcopy(data)) + assert dw.pop(key, default) == default + + +class TestSetdefault: + + @pytest.mark.parametrize( + "data,key,result", + [ + ({"a": 42}, "a", 42), + ({"a": 1}, "b", None), + # ({"a": {"b": 42}}, "a.b", 42), + # ({"a": {"b": {"c": 42}}}, "a.b.c", 42), + # ({"a": [42]}, "a[0]", 42), + # ({"a": [{"b": 42}]}, "a[0].b", 42), + # ({"a": {"b": 1}}, "a.c", None), + # ({"a": {"b": {"c": 1}}}, "a.b.d", None), + # ({"a": [{"b": 1}]}, "a[0].c", None), + # ({"a": {"b": {"c": 42}}}, "a.d.e.f", None), + ], + ) + def test_setdefault(self, data, key, result): + dw = DotWiz(deepcopy(data)) + assert dw.setdefault(key) == result + assert dw[key] == result + + @pytest.mark.parametrize( + "data,key,default", + [ + ({}, "b", None), + ({"a": 1}, "b", "default"), + # ({"a": {"b": 1}}, "a.c", "default"), + # ({"a": {"b": {"c": 1}}}, "a.b.d", "default"), + # ({"a": [{"b": 1}]}, "a[0].c", "default"), + # ({"a": {"b": {"c": 42}}}, "a.d.e.f", "default"), + ], + ) + def test_with_default(self, data, key, default): + dw = DotWiz(deepcopy(data)) + assert dw.setdefault(key, default) == default + assert dw[key] == default + + +def test_to_dict(): """Confirm intended functionality of `DotWiz.to_dict`""" dw = DotWiz(hello=[{"key": "value", "another-key": {"a": "b"}}]) @@ -176,7 +429,7 @@ def test_dotwiz_to_dict(): } -def test_dotwiz_to_json(): +def test_to_json(): """Confirm intended functionality of `DotWiz.to_json`""" dw = DotWiz(hello=[{"key": "value", "another-key": {"a": "b"}}]) @@ -193,7 +446,7 @@ def test_dotwiz_to_json(): }""" -def test_dotwiz_to_json_with_non_serializable_type(): +def test_to_json_with_non_serializable_type(): """ Confirm intended functionality of `DotWiz.to_json` when an object doesn't define a `__dict__`, so the default `JSONEncoder.default` From 5b942d3ce75767e602c4b3ba5b007a363a4bd9aa Mon Sep 17 00:00:00 2001 From: Ritvik Nag Date: Wed, 29 Jun 2022 21:21:25 -0400 Subject: [PATCH 12/55] minor changes --- dotwiz/main.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dotwiz/main.py b/dotwiz/main.py index 54d7465..d33ed3e 100644 --- a/dotwiz/main.py +++ b/dotwiz/main.py @@ -148,6 +148,7 @@ def fromkeys(cls, seq, value=None, __from_keys=dict.fromkeys): :param seq: Sequence of elements which is to be used as keys for the new dictionary. :param value: Value which is set to each element of the dictionary. + :return: DotWiz instance """ return cls(__from_keys(seq, value)) @@ -168,7 +169,7 @@ def keys(self): def items(self): return self.__dict__.items() - def pop(self, key: str, *args): + def pop(self, key, *args): return self.__dict__.pop(key, *args) def popitem(self): From 0cb38111d8c700419a0cf5689414169587a2d265 Mon Sep 17 00:00:00 2001 From: Ritvik Nag Date: Wed, 29 Jun 2022 21:34:44 -0400 Subject: [PATCH 13/55] add __reversed__ --- dotwiz/main.py | 3 +++ dotwiz/main.pyi | 6 ++++-- tests/unit/test_dotwiz.py | 5 +++++ 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/dotwiz/main.py b/dotwiz/main.py index d33ed3e..59c7d3b 100644 --- a/dotwiz/main.py +++ b/dotwiz/main.py @@ -126,6 +126,9 @@ def __iter__(self): def __len__(self): return len(self.__dict__) + def __reversed__(self): + return reversed(self.__dict__) + def clear(self): return self.__dict__.clear() diff --git a/dotwiz/main.pyi b/dotwiz/main.pyi index a5d7289..74e3721 100644 --- a/dotwiz/main.pyi +++ b/dotwiz/main.pyi @@ -1,9 +1,10 @@ import json from typing import ( Callable, Protocol, TypeVar, - Iterable, Iterator, + Iterable, Iterator, Reversible, KeysView, ItemsView, ValuesView, - Mapping, MutableMapping, AnyStr, Any, overload, + Mapping, MutableMapping, AnyStr, Any, + overload, ) _T = TypeVar('_T') @@ -74,6 +75,7 @@ class DotWiz: def __iter__(self) -> Iterator: ... def __len__(self) -> int: ... + def __reversed__(self) -> Reversible: ... def to_dict(self) -> dict[_KT, _VT]: """ diff --git a/tests/unit/test_dotwiz.py b/tests/unit/test_dotwiz.py index ae8f7c2..999a11b 100644 --- a/tests/unit/test_dotwiz.py +++ b/tests/unit/test_dotwiz.py @@ -231,6 +231,11 @@ def test_len(): assert len(dw) == 3 +def test_reversed(): + dw = DotWiz({"a": 1, "b": 2, "c": 3}) + assert list(reversed(dw)) == ["c", "b", "a"] + + def test_popitem(): dw = DotWiz({"a": 1, "b": 2, "c": 3}) dw.popitem() From 3b8263c65ca90e34c64e7916c068c26b7374648f Mon Sep 17 00:00:00 2001 From: Ritvik Nag Date: Wed, 29 Jun 2022 21:41:03 -0400 Subject: [PATCH 14/55] that should say "wrapper" --- dotwiz/__init__.py | 2 +- dotwiz/__version__.py | 4 ++-- dotwiz/main.py | 2 ++ 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/dotwiz/__init__.py b/dotwiz/__init__.py index ba71e16..bcab87c 100644 --- a/dotwiz/__init__.py +++ b/dotwiz/__init__.py @@ -2,7 +2,7 @@ ``dotwiz`` ~~~~~~~~~~ -DotWiz is a ``dict`` subclass that enables accessing (nested) keys +DotWiz is a ``dict`` wrapper that enables accessing (nested) keys in dot notation. Sample Usage:: diff --git a/dotwiz/__version__.py b/dotwiz/__version__.py index 931a7ac..40f1fdb 100644 --- a/dotwiz/__version__.py +++ b/dotwiz/__version__.py @@ -1,9 +1,9 @@ """ -`dotwiz` - A dict subclass that supports dot access notation +`dotwiz` - A dict wrapper that supports dot access notation """ __title__ = 'dotwiz' -__description__ = 'DotWiz is a blazing fast dict subclass that enables ' \ +__description__ = 'DotWiz is a blazing fast dict wrapper that enables ' \ 'accessing (nested) keys in dot notation.' __url__ = 'https://github.com/rnag/dotwiz' __version__ = '0.3.1' diff --git a/dotwiz/main.py b/dotwiz/main.py index 59c7d3b..1fd4bb4 100644 --- a/dotwiz/main.py +++ b/dotwiz/main.py @@ -78,6 +78,8 @@ class DotWiz(metaclass=__add_common_methods__, >>> assert dw.key_1[0].k == 'v' >>> assert dw.keyTwo == '5' >>> assert dw['key-3'] == 3.21 + >>> dw.to_json() + '{"key_1": [{"k": "v"}], "keyTwo": "5", "key-3": 3.21}' """ __slots__ = ('__dict__', ) From b9304f75baafe53035d9dd02fbe1bd2fa89ad448 Mon Sep 17 00:00:00 2001 From: Ritvik Nag Date: Thu, 30 Jun 2022 00:46:47 -0400 Subject: [PATCH 15/55] add __or__ methods --- dotwiz/common.py | 6 +++--- dotwiz/common.pyi | 4 +++- dotwiz/main.py | 36 ++++++++++++++++++++++++++++++++++-- dotwiz/main.pyi | 19 +++++++++++++++---- tests/unit/test_dotwiz.py | 28 ++++++++++++++++++++++++++++ 5 files changed, 83 insertions(+), 10 deletions(-) diff --git a/dotwiz/common.py b/dotwiz/common.py index 2251b2f..ac3776e 100644 --- a/dotwiz/common.py +++ b/dotwiz/common.py @@ -132,14 +132,14 @@ def to_json(o, encoder=json.dumps, **encoder_kwargs): return type(name, bases, cls_dict) -def __resolve_value__(value, dict_type): +def __resolve_value__(value, dict_type, check_lists=True): """Resolve `value`, which can be a complex type like `dict` or `list`""" t = type(value) if t is dict: value = dict_type(value) - elif t is list: - value = [__resolve_value__(e, dict_type) for e in value] + elif check_lists and t is list: + value = [__resolve_value__(e, dict_type, check_lists) for e in value] return value diff --git a/dotwiz/common.pyi b/dotwiz/common.pyi index 06ee288..8db4626 100644 --- a/dotwiz/common.pyi +++ b/dotwiz/common.pyi @@ -23,4 +23,6 @@ def __add_common_methods__(name: str, has_attr_dict=False): ... -def __resolve_value__(value: _T, dict_type: type[DotWiz | DotWizPlus]) -> _T | _D | list[_D]: ... +def __resolve_value__(value: _T, + dict_type: type[DotWiz | DotWizPlus], + check_lists=True) -> _T | _D | list[_D]: ... diff --git a/dotwiz/main.py b/dotwiz/main.py index 1fd4bb4..200ad48 100644 --- a/dotwiz/main.py +++ b/dotwiz/main.py @@ -58,13 +58,41 @@ def __upsert_into_dot_wiz__(self, input_dict={}, __dict[key] = value -def __setitem_impl__(self, key, value): +def __setitem_impl__(self, key, value, check_lists=True): """Implementation of `DotWiz.__setitem__` to preserve dot access""" - value = __resolve_value__(value, DotWiz) + value = __resolve_value__(value, DotWiz, check_lists) self.__dict__[key] = value +def __merge_impl_fn__(op, __set=object.__setattr__): + """Implementation of `__or__` and `__ror__`, to merge `DotWiz` and `dict` objects.""" + def __merge_impl__(self, other): + __other_dict = getattr(other, '__dict__', None) or { + k: __resolve_value__(other[k], DotWiz) + for k in other + } + __merged_dict = op(self.__dict__, __other_dict) + + __merged = DotWiz() + __set(__merged, '__dict__', __merged_dict) + + return __merged + + return __merge_impl__ + + +def __imerge_impl__(self, other, __update=dict.update): + """Implementation of `__ior__` to incrementally update a `DotWiz` instance.""" + __other_dict = getattr(other, '__dict__', None) or { + k: __resolve_value__(other[k], DotWiz) + for k in other + } + __update(self.__dict__, __other_dict) + + return self + + class DotWiz(metaclass=__add_common_methods__, print_char='✫'): """ @@ -128,6 +156,10 @@ def __iter__(self): def __len__(self): return len(self.__dict__) + __or__ = __merge_impl_fn__(dict.__or__) + __ior__ = __imerge_impl__ + __ror__ = __merge_impl_fn__(dict.__ror__) + def __reversed__(self): return reversed(self.__dict__) diff --git a/dotwiz/main.pyi b/dotwiz/main.pyi index 74e3721..a5e7948 100644 --- a/dotwiz/main.pyi +++ b/dotwiz/main.pyi @@ -15,7 +15,7 @@ _VT = TypeVar('_VT') _JSONList = list[Any] _JSONObject = dict[str, Any] -_SetItem = Callable[[dict, _KT, _VT], None] +_SetAttribute = Callable[[DotWiz, str, Any], None] # Ref: https://stackoverflow.com/a/68392079/10237506 @@ -41,13 +41,21 @@ def make_dot_wiz(*args: Iterable[_KT, _VT], # noinspection PyDefaultArgument def __upsert_into_dot_wiz__(self: DotWiz, input_dict: MutableMapping[_KT, _VT] = {}, - *, __set: _SetItem =dict.__setitem__, + *, check_lists=True, **kwargs: _T) -> None: ... def __setitem_impl__(self: DotWiz, key: _KT, value: _VT, - *, __set: _SetItem = dict.__setitem__) -> None: ... + *, check_lists=True) -> None: ... + +def __merge_impl_fn__(op: Callable[[dict, dict], dict], + __set: _SetAttribute = object.__setattr__ + ) -> Callable[[DotWiz, DotWiz | dict], DotWiz]: ... + +def __imerge_impl__(self: DotWiz, + other: DotWiz | dict, + *, __update: _Update = dict.update): ... class DotWiz: @@ -77,6 +85,10 @@ class DotWiz: def __len__(self) -> int: ... def __reversed__(self) -> Reversible: ... + def __or__(self, other: DotWiz | dict) -> DotWiz: ... + def __ior__(self, other: DotWiz | dict) -> DotWiz: ... + def __ror__(self, other: DotWiz | dict) -> DotWiz: ... + def to_dict(self) -> dict[_KT, _VT]: """ Recursively convert the :class:`DotWiz` instance back to a ``dict``. @@ -124,7 +136,6 @@ class DotWiz: def update(self, __m: MutableMapping[_KT, _VT] = {}, *, check_lists=True, - __set: _SetItem = dict.__setitem__, **kwargs: _T) -> None: ... def values(self) -> ValuesView: ... diff --git a/tests/unit/test_dotwiz.py b/tests/unit/test_dotwiz.py index 999a11b..826d396 100644 --- a/tests/unit/test_dotwiz.py +++ b/tests/unit/test_dotwiz.py @@ -236,6 +236,34 @@ def test_reversed(): assert list(reversed(dw)) == ["c", "b", "a"] +@pytest.mark.parametrize( + "op1,op2,result", + [ + (DotWiz(a=1, b=2), DotWiz(b=1.5, c=3), DotWiz({'a': 1, 'b': 1.5, 'c': 3})), + (DotWiz(a=1, b=2), dict(b=1.5, c=3), DotWiz({'a': 1, 'b': 1.5, 'c': 3})), + ], +) +def test_or(op1, op2, result): + actual = op1 | op2 + + assert type(actual) == type(result) + assert op1 | op2 == result + + +def test_ror(): + op1 = {'a': 1, 'b': 2} + op2 = DotWiz(b=1.5, c=3) + + assert op1 | op2 == DotWiz({'a': 1, 'b': 1.5, 'c': 3}) + + +def test_ior(): + op1 = DotWiz(a=1, b=2) + op1 |= {'b': 1.5, 'c': 3} + + assert op1 == DotWiz(a=1, b=1.5, c=3) + + def test_popitem(): dw = DotWiz({"a": 1, "b": 2, "c": 3}) dw.popitem() From 994a7071e87b203e302ae5a4686547d7cbe7ccf9 Mon Sep 17 00:00:00 2001 From: Ritvik Nag Date: Thu, 30 Jun 2022 11:11:16 -0400 Subject: [PATCH 16/55] update with `check_lists` --- dotwiz/main.py | 8 ++++---- dotwiz/main.pyi | 4 +++- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/dotwiz/main.py b/dotwiz/main.py index 200ad48..7735505 100644 --- a/dotwiz/main.py +++ b/dotwiz/main.py @@ -65,11 +65,11 @@ def __setitem_impl__(self, key, value, check_lists=True): self.__dict__[key] = value -def __merge_impl_fn__(op, __set=object.__setattr__): +def __merge_impl_fn__(op, check_lists=True, __set=object.__setattr__): """Implementation of `__or__` and `__ror__`, to merge `DotWiz` and `dict` objects.""" def __merge_impl__(self, other): __other_dict = getattr(other, '__dict__', None) or { - k: __resolve_value__(other[k], DotWiz) + k: __resolve_value__(other[k], DotWiz, check_lists) for k in other } __merged_dict = op(self.__dict__, __other_dict) @@ -82,10 +82,10 @@ def __merge_impl__(self, other): return __merge_impl__ -def __imerge_impl__(self, other, __update=dict.update): +def __imerge_impl__(self, other, check_lists=True, __update=dict.update): """Implementation of `__ior__` to incrementally update a `DotWiz` instance.""" __other_dict = getattr(other, '__dict__', None) or { - k: __resolve_value__(other[k], DotWiz) + k: __resolve_value__(other[k], DotWiz, check_lists) for k in other } __update(self.__dict__, __other_dict) diff --git a/dotwiz/main.pyi b/dotwiz/main.pyi index a5e7948..85b0675 100644 --- a/dotwiz/main.pyi +++ b/dotwiz/main.pyi @@ -50,12 +50,14 @@ def __setitem_impl__(self: DotWiz, *, check_lists=True) -> None: ... def __merge_impl_fn__(op: Callable[[dict, dict], dict], + *, check_lists=True, __set: _SetAttribute = object.__setattr__ ) -> Callable[[DotWiz, DotWiz | dict], DotWiz]: ... def __imerge_impl__(self: DotWiz, other: DotWiz | dict, - *, __update: _Update = dict.update): ... + *, check_lists=True, + __update: _Update = dict.update): ... class DotWiz: From 897a1a1cf7ff61727acf6b39ca4fbc95c2eaf2f3 Mon Sep 17 00:00:00 2001 From: Ritvik Nag Date: Thu, 30 Jun 2022 12:54:45 -0400 Subject: [PATCH 17/55] fix tests to pass for Python 3.7 and 3.8 --- dotwiz/constants.py | 18 ++++++++++++ dotwiz/constants.pyi | 4 +++ dotwiz/main.py | 66 ++++++++++++++++++++++++++++++++++++++------ dotwiz/main.pyi | 10 +++++++ 4 files changed, 89 insertions(+), 9 deletions(-) create mode 100644 dotwiz/constants.py create mode 100644 dotwiz/constants.pyi diff --git a/dotwiz/constants.py b/dotwiz/constants.py new file mode 100644 index 0000000..15e9d06 --- /dev/null +++ b/dotwiz/constants.py @@ -0,0 +1,18 @@ +"""Project-specific constant values""" +__all__ = [ + '__PY_VERSION__', + '__PY_38_OR_ABOVE__', + '__PY_39_OR_ABOVE__', +] + +import sys + + +# Current system Python version +__PY_VERSION__ = sys.version_info[:2] + +# Check if currently running Python 3.8 or higher +__PY_38_OR_ABOVE__ = __PY_VERSION__ >= (3, 8) + +# Check if currently running Python 3.9 or higher +__PY_39_OR_ABOVE__ = __PY_VERSION__ >= (3, 9) diff --git a/dotwiz/constants.pyi b/dotwiz/constants.pyi new file mode 100644 index 0000000..716e174 --- /dev/null +++ b/dotwiz/constants.pyi @@ -0,0 +1,4 @@ + +__PY_VERSION__: tuple[int, int] +__PY_38_OR_ABOVE__: bool +__PY_39_OR_ABOVE__: bool diff --git a/dotwiz/main.py b/dotwiz/main.py index 7735505..30fd690 100644 --- a/dotwiz/main.py +++ b/dotwiz/main.py @@ -3,6 +3,7 @@ from .common import ( __resolve_value__, __add_common_methods__, ) +from .constants import __PY_38_OR_ABOVE__, __PY_39_OR_ABOVE__ def make_dot_wiz(*args, **kwargs): @@ -65,21 +66,69 @@ def __setitem_impl__(self, key, value, check_lists=True): self.__dict__[key] = value -def __merge_impl_fn__(op, check_lists=True, __set=object.__setattr__): - """Implementation of `__or__` and `__ror__`, to merge `DotWiz` and `dict` objects.""" - def __merge_impl__(self, other): +if __PY_38_OR_ABOVE__: # pragma: no cover, Python >= 3.8 + def __reversed_impl__(self): + """Implementation of `__reversed__`, to reverse the keys in a `DotWiz` instance.""" + return reversed(self.__dict__) + +else: # Python < 3.8 + # Note: in Python 3.7, `dict` objects are not reversible by default. + + def __reversed_impl__(self): + """Implementation of `__reversed__`, to reverse the keys in a `DotWiz` instance.""" + return reversed(list(self.__dict__)) + + +if __PY_39_OR_ABOVE__: # pragma: no cover, Python >= 3.9 + def __merge_impl_fn__(op, check_lists=True, __set=object.__setattr__): + """Implementation of `__or__` and `__ror__`, to merge `DotWiz` and `dict` objects.""" + + def __merge_impl__(self, other): + __other_dict = getattr(other, '__dict__', None) or { + k: __resolve_value__(other[k], DotWiz, check_lists) + for k in other + } + __merged_dict = op(self.__dict__, __other_dict) + + __merged = DotWiz() + __set(__merged, '__dict__', __merged_dict) + + return __merged + + return __merge_impl__ + + __or_impl__ = __merge_impl_fn__(dict.__or__) + __ror_impl__ = __merge_impl_fn__(dict.__ror__) + +else: # Python < 3.9 + # Note: this is *before* Union operators were introduced to `dict`, + # in https://peps.python.org/pep-0584/ + + def __or_impl__(self, other, check_lists=True, __set=object.__setattr__): + """Implementation of `__or__` to merge `DotWiz` and `dict` objects.""" __other_dict = getattr(other, '__dict__', None) or { k: __resolve_value__(other[k], DotWiz, check_lists) for k in other } - __merged_dict = op(self.__dict__, __other_dict) + __merged_dict = {**self.__dict__, **__other_dict} __merged = DotWiz() __set(__merged, '__dict__', __merged_dict) return __merged - return __merge_impl__ + def __ror_impl__(self, other, check_lists=True, __set=object.__setattr__): + """Implementation of `__ror__` to merge `DotWiz` and `dict` objects.""" + __other_dict = getattr(other, '__dict__', None) or { + k: __resolve_value__(other[k], DotWiz, check_lists) + for k in other + } + __merged_dict = {**__other_dict, **self.__dict__} + + __merged = DotWiz() + __set(__merged, '__dict__', __merged_dict) + + return __merged def __imerge_impl__(self, other, check_lists=True, __update=dict.update): @@ -156,12 +205,11 @@ def __iter__(self): def __len__(self): return len(self.__dict__) - __or__ = __merge_impl_fn__(dict.__or__) + __or__ = __or_impl__ __ior__ = __imerge_impl__ - __ror__ = __merge_impl_fn__(dict.__ror__) + __ror__ = __ror_impl__ - def __reversed__(self): - return reversed(self.__dict__) + __reversed__ = __reversed_impl__ def clear(self): return self.__dict__.clear() diff --git a/dotwiz/main.pyi b/dotwiz/main.pyi index 85b0675..6103056 100644 --- a/dotwiz/main.pyi +++ b/dotwiz/main.pyi @@ -54,6 +54,16 @@ def __merge_impl_fn__(op: Callable[[dict, dict], dict], __set: _SetAttribute = object.__setattr__ ) -> Callable[[DotWiz, DotWiz | dict], DotWiz]: ... +def __or_impl__(self: DotWiz, + other: DotWiz | dict, + *, check_lists=True, + __set: _SetAttribute = object.__setattr__) -> DotWiz: ... + +def __ror_impl__(self: DotWiz, + other: DotWiz | dict, + *, check_lists=True, + __set: _SetAttribute = object.__setattr__) -> DotWiz: ... + def __imerge_impl__(self: DotWiz, other: DotWiz | dict, *, check_lists=True, From 6a77b1947d7f940cc06b7a179d28a3a7fed9cb0f Mon Sep 17 00:00:00 2001 From: Ritvik Nag Date: Thu, 30 Jun 2022 12:56:57 -0400 Subject: [PATCH 18/55] minor fix --- tests/unit/test_dotwiz.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/test_dotwiz.py b/tests/unit/test_dotwiz.py index 826d396..1f4e8fe 100644 --- a/tests/unit/test_dotwiz.py +++ b/tests/unit/test_dotwiz.py @@ -247,7 +247,7 @@ def test_or(op1, op2, result): actual = op1 | op2 assert type(actual) == type(result) - assert op1 | op2 == result + assert actual == result def test_ror(): From 1222f220b87b7ac2ba9e5c0d7624db6ea0ee1d17 Mon Sep 17 00:00:00 2001 From: Ritvik Nag Date: Thu, 30 Jun 2022 19:11:01 -0400 Subject: [PATCH 19/55] update `DotWizPlus` to be a `dict` wrapper instead --- dotwiz/common.py | 29 +++++- dotwiz/common.pyi | 3 + dotwiz/main.py | 2 +- dotwiz/plus.py | 230 +++++++++++++++++++++++++++++++++++++++++----- dotwiz/plus.pyi | 15 +-- 5 files changed, 248 insertions(+), 31 deletions(-) diff --git a/dotwiz/common.py b/dotwiz/common.py index ac3776e..12e1636 100644 --- a/dotwiz/common.py +++ b/dotwiz/common.py @@ -6,8 +6,8 @@ class DotWizEncoder(json.JSONEncoder): """ - Helper class for encoding of (nested) :class:`DotWiz` and - :class:`DotWizPlus` objects into a standard ``dict``. + Helper class for encoding of (nested) :class:`DotWiz` objects + into a standard ``dict``. """ def default(self, o): @@ -26,6 +26,28 @@ def default(self, o): return json.JSONEncoder.default(self, o) +class DotWizPlusEncoder(json.JSONEncoder): + """ + Helper class for encoding of (nested) :class:`DotWizPlus` objects + into a standard ``dict``. + """ + + def default(self, o): + """ + Return the `dict` data of :class:`DotWizPlus` when possible, or encode + with standard format otherwise. + + :param o: Input object + :return: Serializable data + + """ + try: + return o.__orig_dict__ + + except AttributeError: + return json.JSONEncoder.default(self, o) + + def __add_common_methods__(name, bases, cls_dict, *, print_char='*', has_attr_dict=False): @@ -82,7 +104,8 @@ def __convert_to_dict_preserve_keys__(o, __items=dict.items): return o def to_json(o, encoder=json.dumps, **encoder_kwargs): - return encoder(o, **encoder_kwargs) + cls = encoder_kwargs.pop('cls', DotWizPlusEncoder) + return encoder(o.__orig_dict__, cls=cls, **encoder_kwargs) # add a `to_json` method to the class. cls_dict['to_json'] = to_json diff --git a/dotwiz/common.pyi b/dotwiz/common.pyi index 8db4626..25ad36d 100644 --- a/dotwiz/common.pyi +++ b/dotwiz/common.pyi @@ -15,6 +15,9 @@ _ItemsFn = Callable[[_D ], ItemsView[_KT, _VT]] class DotWizEncoder(json.JSONEncoder): def default(self, o: Any) -> Any: ... +class DotWizPlusEncoder(json.JSONEncoder): + def default(self, o: Any) -> Any: ... + def __add_common_methods__(name: str, bases: tuple[type, ...], diff --git a/dotwiz/main.py b/dotwiz/main.py index 30fd690..32a1960 100644 --- a/dotwiz/main.py +++ b/dotwiz/main.py @@ -220,7 +220,7 @@ def copy(self): :return: DotWiz instance """ - return DotWiz(self.__dict__.copy()) + return DotWiz(self.__dict__.copy(), check_lists=False) # noinspection PyIncorrectDocstring @classmethod diff --git a/dotwiz/plus.py b/dotwiz/plus.py index 1454e88..96bd7fb 100644 --- a/dotwiz/plus.py +++ b/dotwiz/plus.py @@ -7,6 +7,7 @@ from .common import ( __resolve_value__, __add_common_methods__, ) +from .constants import __PY_38_OR_ABOVE__, __PY_39_OR_ABOVE__ # A running cache of special-cased or non-lowercase keys that we've @@ -31,8 +32,7 @@ def make_dot_wiz_plus(*args, **kwargs): return DotWizPlus(kwargs) -def __store_in_object__(self, __self_dict, key, value, - __set=dict.__setitem__): +def __store_in_object__(__self_dict, __self_orig_dict, key, value): """ Helper method to store a key-value pair in an object :param:`self` (a ``DotWizPlus`` instance). This implementation stores the key if it's @@ -40,8 +40,7 @@ def __store_in_object__(self, __self_dict, key, value, 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__`. + the original key-value is stored in the object's :attr:`__orig_dict__`. """ orig_key = key @@ -87,12 +86,13 @@ def __store_in_object__(self, __self_dict, key, value, __SPECIAL_KEYS[key] = key = lower_snake # note: this logic is the same as `DotWizPlus.__setitem__()` - __set(self, orig_key, value) + __self_orig_dict[orig_key] = value __self_dict[key] = value # noinspection PyDefaultArgument -def __upsert_into_dot_wiz_plus__(self, input_dict={}, **kwargs): +def __upsert_into_dot_wiz_plus__(self, input_dict={}, check_lists=True, + __set=object.__setattr__, **kwargs): """ Helper method to generate / update a :class:`DotWizPlus` (dot-access dict) from a Python ``dict`` object, and optional *keyword arguments*. @@ -100,6 +100,9 @@ def __upsert_into_dot_wiz_plus__(self, input_dict={}, **kwargs): """ __dict = self.__dict__ + __orig_dict = {} + __set(self, '__orig_dict__', __orig_dict) + if kwargs: # avoids the potential pitfall of a "mutable default argument" - # only update or modify `input_dict` if the param is passed in. @@ -117,25 +120,102 @@ def __upsert_into_dot_wiz_plus__(self, input_dict={}, **kwargs): t = type(value) if t is dict: - value = DotWizPlus(value) - elif t is list: + # noinspection PyArgumentList + value = DotWizPlus(value, check_lists) + elif check_lists and t is list: value = [__resolve_value__(e, DotWizPlus) for e in value] - __store_in_object__(self, __dict, key, value) + __store_in_object__(__dict, __orig_dict, key, value) -def __setitem_impl__(self, key, value): +def __setitem_impl__(self, key, value, check_lists=True, __set=object.__setattr__): """Implementation of `DotWizPlus.__setitem__` to preserve dot access""" - value = __resolve_value__(value, DotWizPlus) - __store_in_object__(self, self.__dict__, key, value) + value = __resolve_value__(value, DotWizPlus, check_lists) + __store_in_object__(self.__dict__, self.__orig_dict__, key, value) + + +if __PY_38_OR_ABOVE__: # pragma: no cover, Python >= 3.8 + def __reversed_impl__(self): + """Implementation of `__reversed__`, to reverse the keys in a `DotWizPlus` instance.""" + return reversed(self.__orig_dict__) + +else: # Python < 3.8 + # Note: in Python 3.7, `dict` objects are not reversible by default. + + def __reversed_impl__(self): + """Implementation of `__reversed__`, to reverse the keys in a `DotWizPlus` instance.""" + return reversed(list(self.__orig_dict__)) + + +if __PY_39_OR_ABOVE__: # pragma: no cover, Python >= 3.9 + def __merge_impl_fn__(op, check_lists=True, __set=object.__setattr__): + """Implementation of `__or__` and `__ror__`, to merge `DotWizPlus` and `dict` objects.""" + + def __merge_impl__(self, other): + __other_dict = getattr(other, '__dict__', None) or { + k: __resolve_value__(other[k], DotWizPlus, check_lists) + for k in other + } + __merged_dict = op(self.__dict__, __other_dict) + + __merged = DotWizPlus() + __set(__merged, '__dict__', __merged_dict) + + return __merged + + return __merge_impl__ + + __or_impl__ = __merge_impl_fn__(dict.__or__) + __ror_impl__ = __merge_impl_fn__(dict.__ror__) + +else: # Python < 3.9 + # Note: this is *before* Union operators were introduced to `dict`, + # in https://peps.python.org/pep-0584/ + + def __or_impl__(self, other, check_lists=True, __set=object.__setattr__): + """Implementation of `__or__` to merge `DotWizPlus` and `dict` objects.""" + __other_dict = getattr(other, '__dict__', None) or { + k: __resolve_value__(other[k], DotWizPlus, check_lists) + for k in other + } + __merged_dict = {**self.__dict__, **__other_dict} + + __merged = DotWizPlus() + __set(__merged, '__dict__', __merged_dict) + + return __merged + def __ror_impl__(self, other, check_lists=True, __set=object.__setattr__): + """Implementation of `__ror__` to merge `DotWizPlus` and `dict` objects.""" + __other_dict = getattr(other, '__dict__', None) or { + k: __resolve_value__(other[k], DotWizPlus, check_lists) + for k in other + } + __merged_dict = {**__other_dict, **self.__dict__} -class DotWizPlus(dict, metaclass=__add_common_methods__, + __merged = DotWizPlus() + __set(__merged, '__dict__', __merged_dict) + + return __merged + + +def __imerge_impl__(self, other, check_lists=True, __update=dict.update): + """Implementation of `__ior__` to incrementally update a `DotWizPlus` instance.""" + __other_dict = getattr(other, '__dict__', None) or { + k: __resolve_value__(other[k], DotWizPlus, check_lists) + for k in other + } + __update(self.__dict__, __other_dict) + + return self + + +class DotWizPlus(metaclass=__add_common_methods__, print_char='✪', has_attr_dict=True): # noinspection PyProtectedMember """ - :class:`DotWizPlus` - a blazing *fast* ``dict`` subclass that also + :class:`DotWizPlus` - a blazing *fast* ``dict`` wrapper 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. @@ -177,16 +257,13 @@ class DotWizPlus(dict, metaclass=__add_common_methods__, .. _this example: https://dotwiz.readthedocs.io/en/latest/usage.html#complete-example """ - __slots__ = ('__dict__', ) + __slots__ = ( + '__dict__', + '__orig_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__ - def __dir__(self): """ Add a ``__dir__()`` method, so that tab auto-completion and @@ -200,6 +277,117 @@ def __dir__(self): string_keys = [k for k in self.__dict__ if type(k) is str] return super_dir + [k for k in string_keys if k not in super_dir] + def __bool__(self): + return True if self.__dict__ else False + + def __contains__(self, item): + return item in self.__orig_dict__ + + def __eq__(self, other): + return self.__orig_dict__ == other + + def __ne__(self, other): + return self.__orig_dict__ != other + + def __delattr__(self, item): + del self.__dict__[item] + # TODO + del self.__orig_dict__[item] + + def __delitem__(self, key): + del self.__orig_dict__[key] + try: + del self.__dict__[key] + except KeyError: + # 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}_' + else: + key = __SPECIAL_KEYS[key] + + del self.__dict__[key] + + # __getattr__: Use the default `object.__getattr__` implementation. + + def __getitem__(self, key): + return self.__orig_dict__[key] + + __setattr__ = __setitem__ = __setitem_impl__ + + def __iter__(self): + return iter(self.__orig_dict__) + + def __len__(self): + return len(self.__orig_dict__) + + __or__ = __or_impl__ + __ior__ = __imerge_impl__ + __ror__ = __ror_impl__ + + __reversed__ = __reversed_impl__ + + def clear(self, __clear=dict.clear): + __clear(self.__orig_dict__) + return __clear(self.__dict__) + + def copy(self): + """ + Returns a shallow copy of the `dict` wrapped in :class:`DotWizPlus`. + + :return: DotWizPlus instance + """ + return DotWizPlus(self.__dict__.copy(), check_lists=False) + + # noinspection PyIncorrectDocstring + @classmethod + def fromkeys(cls, seq, value=None, __from_keys=dict.fromkeys): + """ + Create a new dictionary with keys from `seq` and values set to `value`. + + New created dictionary is wrapped in :class:`DotWizPlus`. + + :param seq: Sequence of elements which is to be used as keys for + the new dictionary. + :param value: Value which is set to each element of the dictionary. + + :return: DotWizPlus instance + """ + return cls(__from_keys(seq, value)) + + def get(self, k, default=None): + """ + Get value from :class:`DotWizPlus` instance, or default if the key + does not exist. + """ + try: + return self.__orig_dict__[k] + except KeyError: + return default + + def keys(self): + return self.__orig_dict__.keys() + + def items(self): + return self.__orig_dict__.items() + + def pop(self, key, *args): + return self.__orig_dict__.pop(key, *args) + + def popitem(self): + return self.__orig_dict__.popitem() + + def setdefault(self, k, default=None): + return self.__orig_dict__.setdefault(k, default) + + def values(self): + return self.__orig_dict__.values() + # A list of the public-facing methods in `DotWizPlus` __PUB_METHODS = (m for m in dir(DotWizPlus) if not m.startswith('_') diff --git a/dotwiz/plus.pyi b/dotwiz/plus.pyi index dad0724..b35427c 100644 --- a/dotwiz/plus.pyi +++ b/dotwiz/plus.pyi @@ -39,27 +39,29 @@ __IS_KEYWORD: Callable[[object], bool] = ... def make_dot_wiz_plus(*args: Iterable[_KT, _VT], **kwargs: _T) -> DotWizPlus: ... -def __store_in_object__(self: DotWizPlus, - __self_dict: MutableMapping[_KT, _VT], +def __store_in_object__(__self_dict: MutableMapping[_KT, _VT], + __self_orig_dict: MutableMapping[_KT, _VT], key: _KT, - value: _VT, - *, __set: _SetItem = dict.__setitem__) -> None: ... + value: _VT) -> None: ... # noinspection PyDefaultArgument def __upsert_into_dot_wiz_plus__(self: DotWizPlus, input_dict: MutableMapping[_KT, _VT] = {}, + *, check_lists=True, **kwargs: _T) -> None: ... def __setitem_impl__(self: DotWizPlus, key: _KT, - value: _VT) -> None: ... + value: _VT, + *, check_lists=True) -> None: ... -class DotWizPlus(dict): +class DotWizPlus: # noinspection PyDefaultArgument def __init__(self, input_dict: MutableMapping[_KT, _VT] = {}, + *, check_lists=True, **kwargs: _T) -> None: ... def __delattr__(self, item: str) -> None: ... @@ -98,6 +100,7 @@ class DotWizPlus(dict): # noinspection PyDefaultArgument def update(self, __m: MutableMapping[_KT, _VT] = {}, + *, check_lists=True, **kwargs: _T) -> None: ... def __dir__(self) -> Iterable[str]: ... From 3dc4163dbaeaa5b6ee9b09ea7066e5ee3b4c98b1 Mon Sep 17 00:00:00 2001 From: Ritvik Nag Date: Fri, 1 Jul 2022 12:09:40 -0400 Subject: [PATCH 20/55] update `DotWizPlus` to perform a little better * update `DotWizPlus` so creating an instance is a little faster. * update `DotWizPlus.__setattr__` so it passes through the key, and performs better overall. --- dotwiz/plus.py | 73 ++++++++++++++++++++++++++++++++++++------------- dotwiz/plus.pyi | 8 +++++- 2 files changed, 61 insertions(+), 20 deletions(-) diff --git a/dotwiz/plus.py b/dotwiz/plus.py index 96bd7fb..46b33a7 100644 --- a/dotwiz/plus.py +++ b/dotwiz/plus.py @@ -44,26 +44,28 @@ def __store_in_object__(__self_dict, __self_orig_dict, key, value): """ orig_key = key - # in case of other types, like `int` - key = str(key) - lower_key = key.lower() + if orig_key in __SPECIAL_KEYS: + key = __SPECIAL_KEYS[orig_key] - # 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}_' + else: + # in case of other types, like `int` + key = str(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(): + 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): + __SPECIAL_KEYS[orig_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) @@ -83,7 +85,7 @@ def __store_in_object__(__self_dict, __self_orig_dict, key, value): 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 + __SPECIAL_KEYS[orig_key] = key = lower_snake # note: this logic is the same as `DotWizPlus.__setitem__()` __self_orig_dict[orig_key] = value @@ -128,9 +130,21 @@ def __upsert_into_dot_wiz_plus__(self, input_dict={}, check_lists=True, __store_in_object__(__dict, __orig_dict, key, value) -def __setitem_impl__(self, key, value, check_lists=True, __set=object.__setattr__): +def __setattr_impl__(self, item, value, check_lists=True): + """ + Implementation of `DotWizPlus.__setattr__`, which bypasses mutation of + the key name and passes through the original key. + """ + value = __resolve_value__(value, DotWizPlus, check_lists) + + self.__dict__[item] = value + self.__orig_dict__[item] = value + + +def __setitem_impl__(self, key, value, check_lists=True): """Implementation of `DotWizPlus.__setitem__` to preserve dot access""" value = __resolve_value__(value, DotWizPlus, check_lists) + __store_in_object__(self.__dict__, self.__orig_dict__, key, value) @@ -318,7 +332,8 @@ def __delitem__(self, key): def __getitem__(self, key): return self.__orig_dict__[key] - __setattr__ = __setitem__ = __setitem_impl__ + __setattr__ = __setattr_impl__ + __setitem__ = __setitem_impl__ def __iter__(self): return iter(self.__orig_dict__) @@ -389,6 +404,26 @@ def values(self): return self.__orig_dict__.values() +def to_blah(o): + """ + Recursively convert an object (typically a custom `dict` type) to a + Python `dict` type. + """ + __dict = getattr(o, '__dict__', None) + + if __dict: + __orig_dict = o.__orig_dict__ + return {__orig_dict[k]: to_blah(__dict[k]) for k in __dict} + + if isinstance(o, list): + return [to_blah(e) for e in o] + + return o + + +DotWizPlus.to_blah = to_blah + + # 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))) diff --git a/dotwiz/plus.pyi b/dotwiz/plus.pyi index b35427c..3a75171 100644 --- a/dotwiz/plus.pyi +++ b/dotwiz/plus.pyi @@ -13,7 +13,7 @@ _VT = TypeVar('_VT') _JSONList = list[Any] _JSONObject = dict[str, Any] -_SetItem = Callable[[dict, _KT, _VT], None] +_SetAttribute = Callable[[DotWizPlus, str, Any], None] # Ref: https://stackoverflow.com/a/68392079/10237506 class _Update(Protocol): @@ -48,8 +48,14 @@ def __store_in_object__(__self_dict: MutableMapping[_KT, _VT], def __upsert_into_dot_wiz_plus__(self: DotWizPlus, input_dict: MutableMapping[_KT, _VT] = {}, *, check_lists=True, + __set: _SetAttribute = object.__setattr__, **kwargs: _T) -> None: ... +def __setattr_impl__(self: DotWizPlus, + item: str, + value: _VT, + *, check_lists=True) -> None: ... + def __setitem_impl__(self: DotWizPlus, key: _KT, value: _VT, From e66fb2657872ea24b391c8885d3f73fc21773b33 Mon Sep 17 00:00:00 2001 From: Ritvik Nag Date: Fri, 1 Jul 2022 13:02:10 -0400 Subject: [PATCH 21/55] update `__or__` and `__ror__` implementations for `DotWizPlus` --- dotwiz/plus.py | 54 +++++++++++++++++++++++++++++++------------------ dotwiz/plus.pyi | 23 ++++++++++++++++++++- 2 files changed, 56 insertions(+), 21 deletions(-) diff --git a/dotwiz/plus.py b/dotwiz/plus.py index 46b33a7..402d6fc 100644 --- a/dotwiz/plus.py +++ b/dotwiz/plus.py @@ -93,26 +93,28 @@ def __store_in_object__(__self_dict, __self_orig_dict, key, value): # noinspection PyDefaultArgument -def __upsert_into_dot_wiz_plus__(self, input_dict={}, check_lists=True, +def __upsert_into_dot_wiz_plus__(self, input_dict=None, check_lists=True, __set=object.__setattr__, **kwargs): """ Helper method to generate / update a :class:`DotWizPlus` (dot-access dict) from a Python ``dict`` object, and optional *keyword arguments*. """ - __dict = self.__dict__ - - __orig_dict = {} - __set(self, '__orig_dict__', __orig_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 + elif not input_dict: # nothing to do. + return None + + __dict = self.__dict__ + + # create `__orig_dict__` attribute on the instance + __orig_dict = {} + __set(self, '__orig_dict__', __orig_dict) + for key in input_dict: # note: this logic is the same as `__resolve_value__()` # @@ -166,14 +168,18 @@ def __merge_impl_fn__(op, check_lists=True, __set=object.__setattr__): """Implementation of `__or__` and `__ror__`, to merge `DotWizPlus` and `dict` objects.""" def __merge_impl__(self, other): - __other_dict = getattr(other, '__dict__', None) or { - k: __resolve_value__(other[k], DotWizPlus, check_lists) - for k in other - } + __other_dict = getattr(other, '__dict__', None) + + if __other_dict is None: # other is not a `DotWizPlus` instance + other = DotWizPlus(other, check_lists=check_lists) + __other_dict = other.__dict__ + __merged_dict = op(self.__dict__, __other_dict) + __merged_orig_dict = op(self.__orig_dict__, other.__orig_dict__) __merged = DotWizPlus() __set(__merged, '__dict__', __merged_dict) + __set(__merged, '__orig_dict__', __merged_orig_dict) return __merged @@ -188,27 +194,35 @@ def __merge_impl__(self, other): def __or_impl__(self, other, check_lists=True, __set=object.__setattr__): """Implementation of `__or__` to merge `DotWizPlus` and `dict` objects.""" - __other_dict = getattr(other, '__dict__', None) or { - k: __resolve_value__(other[k], DotWizPlus, check_lists) - for k in other - } + __other_dict = getattr(other, '__dict__', None) + + if __other_dict is None: # other is not a `DotWizPlus` instance + other = DotWizPlus(other, check_lists=check_lists) + __other_dict = other.__dict__ + __merged_dict = {**self.__dict__, **__other_dict} + __merged_orig_dict = {**self.__orig_dict__, **other.__orig_dict__} __merged = DotWizPlus() __set(__merged, '__dict__', __merged_dict) + __set(__merged, '__orig_dict__', __merged_orig_dict) return __merged def __ror_impl__(self, other, check_lists=True, __set=object.__setattr__): """Implementation of `__ror__` to merge `DotWizPlus` and `dict` objects.""" - __other_dict = getattr(other, '__dict__', None) or { - k: __resolve_value__(other[k], DotWizPlus, check_lists) - for k in other - } + __other_dict = getattr(other, '__dict__', None) + + if __other_dict is None: # other is not a `DotWizPlus` instance + other = DotWizPlus(other, check_lists=check_lists) + __other_dict = other.__dict__ + __merged_dict = {**__other_dict, **self.__dict__} + __merged_orig_dict = {**other.__orig_dict__, **self.__orig_dict__} __merged = DotWizPlus() __set(__merged, '__dict__', __merged_dict) + __set(__merged, '__orig_dict__', __merged_orig_dict) return __merged diff --git a/dotwiz/plus.pyi b/dotwiz/plus.pyi index 3a75171..6bc1a63 100644 --- a/dotwiz/plus.pyi +++ b/dotwiz/plus.pyi @@ -15,6 +15,7 @@ _JSONObject = dict[str, Any] _SetAttribute = Callable[[DotWizPlus, str, Any], None] + # Ref: https://stackoverflow.com/a/68392079/10237506 class _Update(Protocol): def __call__(self, instance: dict, @@ -46,7 +47,7 @@ def __store_in_object__(__self_dict: MutableMapping[_KT, _VT], # noinspection PyDefaultArgument def __upsert_into_dot_wiz_plus__(self: DotWizPlus, - input_dict: MutableMapping[_KT, _VT] = {}, + input_dict: MutableMapping[_KT, _VT] = None, *, check_lists=True, __set: _SetAttribute = object.__setattr__, **kwargs: _T) -> None: ... @@ -61,6 +62,26 @@ def __setitem_impl__(self: DotWizPlus, value: _VT, *, check_lists=True) -> None: ... +def __merge_impl_fn__(op: Callable[[dict, dict], dict], + *, check_lists=True, + __set: _SetAttribute = object.__setattr__ + ) -> Callable[[DotWizPlus, DotWizPlus | dict], DotWizPlus]: ... + +def __or_impl__(self: DotWizPlus, + other: DotWizPlus | dict, + *, check_lists=True, + __set: _SetAttribute = object.__setattr__) -> DotWizPlus: ... + +def __ror_impl__(self: DotWizPlus, + other: DotWizPlus | dict, + *, check_lists=True, + __set: _SetAttribute = object.__setattr__) -> DotWizPlus: ... + +def __imerge_impl__(self: DotWizPlus, + other: DotWizPlus | dict, + *, check_lists=True, + __update: _Update = dict.update): ... + class DotWizPlus: From 5c145472970356791504ddacb1df87d88a93fbb6 Mon Sep 17 00:00:00 2001 From: Ritvik Nag Date: Fri, 1 Jul 2022 13:26:45 -0400 Subject: [PATCH 22/55] update `__ior__` implementations for `DotWizPlus` --- dotwiz/plus.py | 18 +++++++++++++----- dotwiz/plus.pyi | 3 +++ 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/dotwiz/plus.py b/dotwiz/plus.py index 402d6fc..e2e55e1 100644 --- a/dotwiz/plus.py +++ b/dotwiz/plus.py @@ -229,11 +229,19 @@ def __ror_impl__(self, other, check_lists=True, __set=object.__setattr__): def __imerge_impl__(self, other, check_lists=True, __update=dict.update): """Implementation of `__ior__` to incrementally update a `DotWizPlus` instance.""" - __other_dict = getattr(other, '__dict__', None) or { - k: __resolve_value__(other[k], DotWizPlus, check_lists) - for k in other - } - __update(self.__dict__, __other_dict) + __other_dict = getattr(other, '__dict__', None) + + if __other_dict is not None: # other is a `DotWizPlus` instance + __update(self.__dict__, __other_dict) + __update(self.__orig_dict__, other.__orig_dict__) + + else: # other is a `dict` instance + __dict = self.__dict__ + __orig_dict = self.__orig_dict__ + + for key in other: + value = __resolve_value__(other[key], DotWizPlus, check_lists) + __store_in_object__(__dict, __orig_dict, key, value) return self diff --git a/dotwiz/plus.pyi b/dotwiz/plus.pyi index 6bc1a63..461571c 100644 --- a/dotwiz/plus.pyi +++ b/dotwiz/plus.pyi @@ -85,6 +85,9 @@ def __imerge_impl__(self: DotWizPlus, class DotWizPlus: + __dict__: dict[_KT, _VT] + __orig_dict__: dict[_KT, _VT] + # noinspection PyDefaultArgument def __init__(self, input_dict: MutableMapping[_KT, _VT] = {}, From 34b15ed6cc88ac91d10bb677aa7bfaf9842822aa Mon Sep 17 00:00:00 2001 From: Ritvik Nag Date: Fri, 1 Jul 2022 13:29:06 -0400 Subject: [PATCH 23/55] update `__ior__` implementations for `DotWizPlus` --- dotwiz/plus.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/dotwiz/plus.py b/dotwiz/plus.py index e2e55e1..977177a 100644 --- a/dotwiz/plus.py +++ b/dotwiz/plus.py @@ -229,16 +229,16 @@ def __ror_impl__(self, other, check_lists=True, __set=object.__setattr__): def __imerge_impl__(self, other, check_lists=True, __update=dict.update): """Implementation of `__ior__` to incrementally update a `DotWizPlus` instance.""" + __dict = self.__dict__ + __orig_dict = self.__orig_dict__ + __other_dict = getattr(other, '__dict__', None) if __other_dict is not None: # other is a `DotWizPlus` instance - __update(self.__dict__, __other_dict) - __update(self.__orig_dict__, other.__orig_dict__) + __update(__dict, __other_dict) + __update(__orig_dict, other.__orig_dict__) else: # other is a `dict` instance - __dict = self.__dict__ - __orig_dict = self.__orig_dict__ - for key in other: value = __resolve_value__(other[key], DotWizPlus, check_lists) __store_in_object__(__dict, __orig_dict, key, value) From d510d26d02c48a2d0d1b0a68f909e7ae8940800f Mon Sep 17 00:00:00 2001 From: Ritvik Nag Date: Fri, 1 Jul 2022 16:52:22 -0400 Subject: [PATCH 24/55] minor updates --- dotwiz/main.py | 28 ++++++++-- dotwiz/main.pyi | 17 ++++-- dotwiz/plus.py | 143 +++++++++++++++++++++++++++++------------------- dotwiz/plus.pyi | 25 +++++++-- 4 files changed, 138 insertions(+), 75 deletions(-) diff --git a/dotwiz/main.py b/dotwiz/main.py index 32a1960..9baac2a 100644 --- a/dotwiz/main.py +++ b/dotwiz/main.py @@ -131,7 +131,7 @@ def __ror_impl__(self, other, check_lists=True, __set=object.__setattr__): return __merged -def __imerge_impl__(self, other, check_lists=True, __update=dict.update): +def __ior_impl__(self, other, check_lists=True, __update=dict.update): """Implementation of `__ior__` to incrementally update a `DotWiz` instance.""" __other_dict = getattr(other, '__dict__', None) or { k: __resolve_value__(other[k], DotWiz, check_lists) @@ -206,7 +206,7 @@ def __len__(self): return len(self.__dict__) __or__ = __or_impl__ - __ior__ = __imerge_impl__ + __ior__ = __ior_impl__ __ror__ = __ror_impl__ __reversed__ = __reversed_impl__ @@ -214,13 +214,16 @@ def __len__(self): def clear(self): return self.__dict__.clear() - def copy(self): + def copy(self, __copy=dict.copy, __set=object.__setattr__): """ Returns a shallow copy of the `dict` wrapped in :class:`DotWiz`. :return: DotWiz instance """ - return DotWiz(self.__dict__.copy(), check_lists=False) + dw = DotWiz() + __set(dw, '__dict__', __copy(self.__dict__)) + + return dw # noinspection PyIncorrectDocstring @classmethod @@ -260,8 +263,21 @@ def pop(self, key, *args): def popitem(self): return self.__dict__.popitem() - def setdefault(self, k, default=None): - return self.__dict__.setdefault(k, default) + def setdefault(self, k, default=None, check_lists=True, __get=dict.get): + """ + Insert key with a value of default if key is not in the dictionary. + + Return the value for key if key is in the dictionary, else default. + """ + __dict = self.__dict__ + result = __get(__dict, k) + + if result is not None: + return result + + __dict[k] = default = __resolve_value__(default, DotWiz, check_lists) + + return default def values(self): return self.__dict__.values() diff --git a/dotwiz/main.pyi b/dotwiz/main.pyi index 6103056..e9363ff 100644 --- a/dotwiz/main.pyi +++ b/dotwiz/main.pyi @@ -15,6 +15,7 @@ _VT = TypeVar('_VT') _JSONList = list[Any] _JSONObject = dict[str, Any] +_Copy = Callable[[dict[_KT, _VT]], dict[_KT, _VT]] _SetAttribute = Callable[[DotWiz, str, Any], None] @@ -64,10 +65,10 @@ def __ror_impl__(self: DotWiz, *, check_lists=True, __set: _SetAttribute = object.__setattr__) -> DotWiz: ... -def __imerge_impl__(self: DotWiz, - other: DotWiz | dict, - *, check_lists=True, - __update: _Update = dict.update): ... +def __ior_impl__(self: DotWiz, + other: DotWiz | dict, + *, check_lists=True, + __update: _Update = dict.update): ... class DotWiz: @@ -120,7 +121,9 @@ class DotWiz: def clear(self) -> None: ... - def copy(self) -> DotWiz: ... + def copy(self, + *, __copy: _Copy = dict.copy, + __set: _SetAttribute = object.__setattr__) -> DotWiz: ... @classmethod def fromkeys(cls: type[DotWiz], @@ -142,7 +145,9 @@ class DotWiz: def popitem(self) -> tuple[_KT, _VT]: ... - def setdefault(self, k: _KT, default=None) -> _VT: ... + def setdefault(self, k: _KT, default=None, + *, check_lists=True, + __get=dict.get) -> _VT: ... # noinspection PyDefaultArgument def update(self, diff --git a/dotwiz/plus.py b/dotwiz/plus.py index 977177a..5fb6473 100644 --- a/dotwiz/plus.py +++ b/dotwiz/plus.py @@ -14,6 +14,8 @@ # transformed before. __SPECIAL_KEYS = {} +__GET_SPECIAL_KEY__ = __SPECIAL_KEYS.get + def make_dot_wiz_plus(*args, **kwargs): """ @@ -32,7 +34,8 @@ def make_dot_wiz_plus(*args, **kwargs): return DotWizPlus(kwargs) -def __store_in_object__(__self_dict, __self_orig_dict, key, value): +def __store_in_object__(__self_dict, __self_orig_dict, __self_orig_keys, + key, value): """ Helper method to store a key-value pair in an object :param:`self` (a ``DotWizPlus`` instance). This implementation stores the key if it's @@ -47,6 +50,7 @@ def __store_in_object__(__self_dict, __self_orig_dict, key, value): if orig_key in __SPECIAL_KEYS: key = __SPECIAL_KEYS[orig_key] + __self_orig_keys[key] = orig_key else: # in case of other types, like `int` @@ -59,6 +63,7 @@ def __store_in_object__(__self_dict, __self_orig_dict, key, value): # attribute access can then work. if __IS_KEYWORD(lower_key): __SPECIAL_KEYS[orig_key] = key = f'{lower_key}_' + __self_orig_keys[key] = orig_key # handle special cases: if the key is not lowercase, or it's not a # valid identifier in python. @@ -86,6 +91,7 @@ def __store_in_object__(__self_dict, __self_orig_dict, key, value): lower_snake = f'_{ch}{lower_snake[1:]}' __SPECIAL_KEYS[orig_key] = key = lower_snake + __self_orig_keys[key] = orig_key # note: this logic is the same as `DotWizPlus.__setitem__()` __self_orig_dict[orig_key] = value @@ -93,28 +99,35 @@ def __store_in_object__(__self_dict, __self_orig_dict, key, value): # noinspection PyDefaultArgument -def __upsert_into_dot_wiz_plus__(self, input_dict=None, check_lists=True, - __set=object.__setattr__, **kwargs): +def __upsert_into_dot_wiz_plus__(self, input_dict={}, + check_lists=True, + __skip_init=False, + __set=object.__setattr__, + **kwargs): """ Helper method to generate / update a :class:`DotWizPlus` (dot-access dict) from a Python ``dict`` object, and optional *keyword arguments*. """ + if __skip_init: + return None + + __dict = self.__dict__ + if kwargs: if input_dict: input_dict.update(kwargs) else: input_dict = kwargs - elif not input_dict: # nothing to do. - return None - - __dict = self.__dict__ - - # create `__orig_dict__` attribute on the instance + # create the instance attribute `__orig_dict__` __orig_dict = {} __set(self, '__orig_dict__', __orig_dict) + # create the instance attribute `__orig_keys__` + __orig_keys = {} + __set(self, '__orig_keys__', __orig_keys) + for key in input_dict: # note: this logic is the same as `__resolve_value__()` # @@ -129,7 +142,7 @@ def __upsert_into_dot_wiz_plus__(self, input_dict=None, check_lists=True, elif check_lists and t is list: value = [__resolve_value__(e, DotWizPlus) for e in value] - __store_in_object__(__dict, __orig_dict, key, value) + __store_in_object__(__dict, __orig_dict, __orig_keys, key, value) def __setattr_impl__(self, item, value, check_lists=True): @@ -147,7 +160,8 @@ def __setitem_impl__(self, key, value, check_lists=True): """Implementation of `DotWizPlus.__setitem__` to preserve dot access""" value = __resolve_value__(value, DotWizPlus, check_lists) - __store_in_object__(self.__dict__, self.__orig_dict__, key, value) + __store_in_object__(self.__dict__, self.__orig_dict__, self.__orig_keys__, + key, value) if __PY_38_OR_ABOVE__: # pragma: no cover, Python >= 3.8 @@ -176,10 +190,12 @@ def __merge_impl__(self, other): __merged_dict = op(self.__dict__, __other_dict) __merged_orig_dict = op(self.__orig_dict__, other.__orig_dict__) + __merged_orig_keys = op(self.__orig_keys__, other.__orig_keys__) - __merged = DotWizPlus() + __merged = DotWizPlus(__skip_init=True) __set(__merged, '__dict__', __merged_dict) __set(__merged, '__orig_dict__', __merged_orig_dict) + __set(__merged, '__orig_keys__', __merged_orig_keys) return __merged @@ -202,10 +218,12 @@ def __or_impl__(self, other, check_lists=True, __set=object.__setattr__): __merged_dict = {**self.__dict__, **__other_dict} __merged_orig_dict = {**self.__orig_dict__, **other.__orig_dict__} + __merged_orig_keys = {**self.__orig_keys__, **other.__orig_keys__} - __merged = DotWizPlus() + __merged = DotWizPlus(__skip_init=True) __set(__merged, '__dict__', __merged_dict) __set(__merged, '__orig_dict__', __merged_orig_dict) + __set(__merged, '__orig_keys__', __merged_orig_keys) return __merged @@ -219,29 +237,33 @@ def __ror_impl__(self, other, check_lists=True, __set=object.__setattr__): __merged_dict = {**__other_dict, **self.__dict__} __merged_orig_dict = {**other.__orig_dict__, **self.__orig_dict__} + __merged_orig_keys = {**other.__orig_keys__, **self.__orig_keys__} - __merged = DotWizPlus() + __merged = DotWizPlus(__skip_init=True) __set(__merged, '__dict__', __merged_dict) __set(__merged, '__orig_dict__', __merged_orig_dict) + __set(__merged, '__orig_keys__', __merged_orig_keys) return __merged -def __imerge_impl__(self, other, check_lists=True, __update=dict.update): +def __ior_impl__(self, other, check_lists=True, __update=dict.update): """Implementation of `__ior__` to incrementally update a `DotWizPlus` instance.""" __dict = self.__dict__ __orig_dict = self.__orig_dict__ + __orig_keys = self.__orig_keys__ __other_dict = getattr(other, '__dict__', None) if __other_dict is not None: # other is a `DotWizPlus` instance __update(__dict, __other_dict) __update(__orig_dict, other.__orig_dict__) + __update(__orig_keys, other.__orig_keys__) else: # other is a `dict` instance for key in other: value = __resolve_value__(other[key], DotWizPlus, check_lists) - __store_in_object__(__dict, __orig_dict, key, value) + __store_in_object__(__dict, __orig_dict, __orig_keys, key, value) return self @@ -296,6 +318,7 @@ class DotWizPlus(metaclass=__add_common_methods__, __slots__ = ( '__dict__', '__orig_dict__', + '__orig_keys__', ) __init__ = update = __upsert_into_dot_wiz_plus__ @@ -327,27 +350,19 @@ def __ne__(self, other): def __delattr__(self, item): del self.__dict__[item] - # TODO - del self.__orig_dict__[item] - def __delitem__(self, key): - del self.__orig_dict__[key] - try: - del self.__dict__[key] - except KeyError: - # in case of other types, like `int` - key = str(key) - lower_key = key.lower() + __orig_key = self.__orig_keys__.pop(item, item) + del self.__orig_dict__[__orig_key] - # 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}_' - else: - key = __SPECIAL_KEYS[key] + def __delitem__(self, key): + __dict_key = __GET_SPECIAL_KEY__(key) + if __dict_key: + del self.__orig_keys__[__dict_key] + else: + __dict_key = key - del self.__dict__[key] + del self.__dict__[__dict_key] + del self.__orig_dict__[key] # __getattr__: Use the default `object.__getattr__` implementation. @@ -364,22 +379,29 @@ def __len__(self): return len(self.__orig_dict__) __or__ = __or_impl__ - __ior__ = __imerge_impl__ + __ior__ = __ior_impl__ __ror__ = __ror_impl__ __reversed__ = __reversed_impl__ def clear(self, __clear=dict.clear): __clear(self.__orig_dict__) + __clear(self.__orig_keys__) + return __clear(self.__dict__) - def copy(self): + def copy(self, __copy=dict.copy, __set=object.__setattr__): """ Returns a shallow copy of the `dict` wrapped in :class:`DotWizPlus`. :return: DotWizPlus instance """ - return DotWizPlus(self.__dict__.copy(), check_lists=False) + dw = DotWizPlus(__skip_init=True) + __set(dw, '__dict__', __copy(self.__dict__)) + __set(dw, '__orig_dict__', __copy(self.__orig_dict__)) + __set(dw, '__orig_keys__', __copy(self.__orig_keys__)) + + return dw # noinspection PyIncorrectDocstring @classmethod @@ -414,36 +436,43 @@ def items(self): return self.__orig_dict__.items() def pop(self, key, *args): - return self.__orig_dict__.pop(key, *args) + result = self.__orig_dict__.pop(key, *args) - def popitem(self): - return self.__orig_dict__.popitem() + __dict_key = __GET_SPECIAL_KEY__(key) + if __dict_key: + del self.__orig_keys__[__dict_key] + else: + __dict_key = key - def setdefault(self, k, default=None): - return self.__orig_dict__.setdefault(k, default) + _ = self.__dict__.pop(__dict_key, None) + return result - def values(self): - return self.__orig_dict__.values() + def popitem(self): + key, _ = self.__dict__.popitem() + self.__orig_keys__.pop(key, None) + return self.__orig_dict__.popitem() -def to_blah(o): - """ - Recursively convert an object (typically a custom `dict` type) to a - Python `dict` type. - """ - __dict = getattr(o, '__dict__', None) + def setdefault(self, k, default=None, check_lists=True, __get=dict.get, ): + """ + Insert key with a value of default if key is not in the dictionary. - if __dict: - __orig_dict = o.__orig_dict__ - return {__orig_dict[k]: to_blah(__dict[k]) for k in __dict} + Return the value for key if key is in the dictionary, else default. + """ + result = __get(self.__orig_dict__, k) - if isinstance(o, list): - return [to_blah(e) for e in o] + if result is not None: + return result - return o + default = __resolve_value__(default, DotWizPlus, check_lists) + __store_in_object__( + self.__dict__, self.__orig_dict__, self.__orig_keys__, k, default + ) + return default -DotWizPlus.to_blah = to_blah + def values(self): + return self.__orig_dict__.values() # A list of the public-facing methods in `DotWizPlus` diff --git a/dotwiz/plus.pyi b/dotwiz/plus.pyi index 461571c..f8c481e 100644 --- a/dotwiz/plus.pyi +++ b/dotwiz/plus.pyi @@ -2,7 +2,7 @@ import json from typing import ( AnyStr, Any, Callable, Iterable, Mapping, MutableMapping, - Protocol, TypeVar, + Protocol, TypeVar, overload, ) _T = TypeVar('_T') @@ -33,7 +33,15 @@ class Encoder(Protocol): ... +class DictGet(Protocol): + @overload + def __call__(self, key: _KT) -> _VT | None: ... + @overload + def __call__(self, key: _KT, default: _VT | _T) -> _VT | _T: ... + + __SPECIAL_KEYS: dict[str, str] = ... +__GET_SPECIAL_KEY__: DictGet = ... __IS_KEYWORD: Callable[[object], bool] = ... @@ -42,13 +50,15 @@ def make_dot_wiz_plus(*args: Iterable[_KT, _VT], def __store_in_object__(__self_dict: MutableMapping[_KT, _VT], __self_orig_dict: MutableMapping[_KT, _VT], + __self_orig_keys: MutableMapping[str, _KT], key: _KT, value: _VT) -> None: ... # noinspection PyDefaultArgument def __upsert_into_dot_wiz_plus__(self: DotWizPlus, - input_dict: MutableMapping[_KT, _VT] = None, + input_dict: MutableMapping[_KT, _VT] = {}, *, check_lists=True, + __skip_init=False, __set: _SetAttribute = object.__setattr__, **kwargs: _T) -> None: ... @@ -77,21 +87,23 @@ def __ror_impl__(self: DotWizPlus, *, check_lists=True, __set: _SetAttribute = object.__setattr__) -> DotWizPlus: ... -def __imerge_impl__(self: DotWizPlus, - other: DotWizPlus | dict, - *, check_lists=True, - __update: _Update = dict.update): ... +def __ior_impl__(self: DotWizPlus, + other: DotWizPlus | dict, + *, check_lists=True, + __update: _Update = dict.update): ... class DotWizPlus: __dict__: dict[_KT, _VT] __orig_dict__: dict[_KT, _VT] + __orig_keys__: dict[str, _KT] # noinspection PyDefaultArgument def __init__(self, input_dict: MutableMapping[_KT, _VT] = {}, *, check_lists=True, + __skip_init=False, **kwargs: _T) -> None: ... def __delattr__(self, item: str) -> None: ... @@ -131,6 +143,7 @@ class DotWizPlus: def update(self, __m: MutableMapping[_KT, _VT] = {}, *, check_lists=True, + __skip_init=False, **kwargs: _T) -> None: ... def __dir__(self) -> Iterable[str]: ... From fcad4b30ae7b0e3efdb3d7294923589c5764cae8 Mon Sep 17 00:00:00 2001 From: Ritvik Nag Date: Fri, 1 Jul 2022 17:28:27 -0400 Subject: [PATCH 25/55] update .pyi files to resolve warnings --- dotwiz/main.py | 7 +--- dotwiz/main.pyi | 26 +++++++++---- dotwiz/plus.py | 8 ++-- dotwiz/plus.pyi | 97 ++++++++++++++++++++++++++++++++++++++++--------- 4 files changed, 103 insertions(+), 35 deletions(-) diff --git a/dotwiz/main.py b/dotwiz/main.py index 9baac2a..f9fe6ca 100644 --- a/dotwiz/main.py +++ b/dotwiz/main.py @@ -241,15 +241,12 @@ def fromkeys(cls, seq, value=None, __from_keys=dict.fromkeys): """ return cls(__from_keys(seq, value)) - def get(self, k, default=None): + def get(self, k, default=None, __get=dict.get): """ Get value from :class:`DotWiz` instance, or default if the key does not exist. """ - try: - return self.__dict__[k] - except KeyError: - return default + return __get(self.__dict__, k, default) def keys(self): return self.__dict__.keys() diff --git a/dotwiz/main.pyi b/dotwiz/main.pyi index e9363ff..6d8a0d4 100644 --- a/dotwiz/main.pyi +++ b/dotwiz/main.pyi @@ -19,12 +19,6 @@ _Copy = Callable[[dict[_KT, _VT]], dict[_KT, _VT]] _SetAttribute = Callable[[DotWiz, str, Any], None] -# Ref: https://stackoverflow.com/a/68392079/10237506 -class _Update(Protocol): - def __call__(self, instance: dict, - __m: Mapping[_KT, _VT] | None = None, - **kwargs: _T) -> None: ... - class Encoder(Protocol): """ Represents an encoder for Python object -> JSON, e.g. analogous to @@ -35,6 +29,18 @@ class Encoder(Protocol): **kwargs) -> AnyStr: ... +# Ref: https://stackoverflow.com/a/68392079/10237506 +class _Update(Protocol): + def __call__(self, instance: dict, + __m: Mapping[_KT, _VT] | None = None, + **kwargs: _T) -> None: ... + +class _RawDictGet(Protocol): + @overload + def __call__(self, obj: dict, key: _KT) -> _VT | None: ... + @overload + def __call__(self, obj: dict, key: _KT, default: _VT | _T) -> _VT | _T: ... + def make_dot_wiz(*args: Iterable[_KT, _VT], **kwargs: _T) -> DotWiz: ... @@ -125,13 +131,19 @@ class DotWiz: *, __copy: _Copy = dict.copy, __set: _SetAttribute = object.__setattr__) -> DotWiz: ... + # noinspection PyUnresolvedReferences @classmethod def fromkeys(cls: type[DotWiz], seq: Iterable, value: Iterable | None = None, *, __from_keys=dict.fromkeys): ... - def get(self, k: _KT, default=None) -> _VT | None: ... + @overload + def get(self, k: _KT, + *, __get: _RawDictGet = dict.get) -> _VT | None: ... + @overload + def get(self, k: _KT, default: _VT | _T, + *, __get: _RawDictGet = dict.get) -> _VT | _T: ... def keys(self) -> KeysView: ... diff --git a/dotwiz/plus.py b/dotwiz/plus.py index 5fb6473..d68c2c7 100644 --- a/dotwiz/plus.py +++ b/dotwiz/plus.py @@ -334,6 +334,7 @@ def __dir__(self): """ super_dir = super().__dir__() string_keys = [k for k in self.__dict__ if type(k) is str] + # noinspection PyUnresolvedReferences return super_dir + [k for k in string_keys if k not in super_dir] def __bool__(self): @@ -419,15 +420,12 @@ def fromkeys(cls, seq, value=None, __from_keys=dict.fromkeys): """ return cls(__from_keys(seq, value)) - def get(self, k, default=None): + def get(self, k, default=None, __get=dict.get): """ Get value from :class:`DotWizPlus` instance, or default if the key does not exist. """ - try: - return self.__orig_dict__[k] - except KeyError: - return default + return __get(self.__orig_dict__, k, default) def keys(self): return self.__orig_dict__.keys() diff --git a/dotwiz/plus.pyi b/dotwiz/plus.pyi index f8c481e..b2cbcfd 100644 --- a/dotwiz/plus.pyi +++ b/dotwiz/plus.pyi @@ -1,8 +1,10 @@ import json from typing import ( - AnyStr, Any, Callable, Iterable, - Mapping, MutableMapping, - Protocol, TypeVar, overload, + Callable, Protocol, TypeVar, + Iterable, Iterator, Reversible, + KeysView, ItemsView, ValuesView, + Mapping, MutableMapping, AnyStr, Any, + overload, ) _T = TypeVar('_T') @@ -13,15 +15,11 @@ _VT = TypeVar('_VT') _JSONList = list[Any] _JSONObject = dict[str, Any] +_Clear = Callable[[dict[_KT, _VT]], None] +_Copy = Callable[[dict[_KT, _VT]], dict[_KT, _VT]] _SetAttribute = Callable[[DotWizPlus, str, Any], None] -# Ref: https://stackoverflow.com/a/68392079/10237506 -class _Update(Protocol): - def __call__(self, instance: dict, - __m: Mapping[_KT, _VT] | None = None, - **kwargs: _T) -> None: ... - class Encoder(Protocol): """ Represents an encoder for Python object -> JSON, e.g. analogous to @@ -32,16 +30,27 @@ class Encoder(Protocol): **kwargs) -> AnyStr: ... - -class DictGet(Protocol): +# Ref: https://stackoverflow.com/a/68392079/10237506 +class _DictGet(Protocol): @overload def __call__(self, key: _KT) -> _VT | None: ... @overload def __call__(self, key: _KT, default: _VT | _T) -> _VT | _T: ... +class _Update(Protocol): + def __call__(self, instance: dict, + __m: Mapping[_KT, _VT] | None = None, + **kwargs: _T) -> None: ... + +class _RawDictGet(Protocol): + @overload + def __call__(self, obj: dict, key: _KT) -> _VT | None: ... + @overload + def __call__(self, obj: dict, key: _KT, default: _VT | _T) -> _VT | _T: ... + __SPECIAL_KEYS: dict[str, str] = ... -__GET_SPECIAL_KEY__: DictGet = ... +__GET_SPECIAL_KEY__: _DictGet = ... __IS_KEYWORD: Callable[[object], bool] = ... @@ -106,6 +115,12 @@ class DotWizPlus: __skip_init=False, **kwargs: _T) -> None: ... + def __bool__(self) -> bool: ... + def __contains__(self, item: _KT) -> bool: ... + + def __eq__(self, other: object) -> bool: ... + def __ne__(self, other: object) -> bool: ... + def __delattr__(self, item: str) -> None: ... def __delitem__(self, v: _KT) -> None: ... @@ -115,16 +130,24 @@ class DotWizPlus: def __setattr__(self, item: str, value: _VT) -> None: ... def __setitem__(self, k: _KT, v: _VT) -> None: ... - def to_attr_dict(self) -> dict[_KT, _VT]: + def __iter__(self) -> Iterator: ... + def __len__(self) -> int: ... + def __reversed__(self) -> Reversible: ... + + def __or__(self, other: DotWizPlus | dict) -> DotWizPlus: ... + def __ior__(self, other: DotWizPlus | dict) -> DotWizPlus: ... + def __ror__(self, other: DotWizPlus | dict) -> DotWizPlus: ... + + def to_dict(self) -> dict[_KT, _VT]: """ - Recursively convert the :class:`DotWizPlus` instance back to a ``dict``, - while preserving the lower-cased keys used for attribute access. + Recursively convert the :class:`DotWizPlus` instance back to a ``dict``. """ ... - def to_dict(self) -> dict[_KT, _VT]: + def to_attr_dict(self) -> dict[_KT, _VT]: """ - Recursively convert the :class:`DotWizPlus` instance back to a ``dict``. + Recursively convert the :class:`DotWizPlus` instance back to a ``dict``, + while preserving the lower-cased keys used for attribute access. """ ... @@ -139,6 +162,43 @@ class DotWizPlus: """ ... + def clear(self, + *, __clear: _Clear = dict.clear) -> None: ... + + def copy(self, + *, __copy: _Copy = dict.copy, + __set: _SetAttribute = object.__setattr__) -> DotWizPlus: ... + + # noinspection PyUnresolvedReferences + @classmethod + def fromkeys(cls: type[DotWizPlus], + seq: Iterable, + value: Iterable | None = None, + *, __from_keys=dict.fromkeys): ... + + @overload + def get(self, k: _KT, + *, __get: _RawDictGet = dict.get) -> _VT | None: ... + @overload + def get(self, k: _KT, default: _VT | _T, + *, __get: _RawDictGet = dict.get) -> _VT | _T: ... + + def keys(self) -> KeysView: ... + + def items(self) -> ItemsView: ... + + @overload + def pop(self, k: _KT) -> _VT: ... + + @overload + def pop(self, k: _KT, default: _VT | _T) -> _VT | _T: ... + + def popitem(self) -> tuple[_KT, _VT]: ... + + def setdefault(self, k: _KT, default=None, + *, check_lists=True, + __get=dict.get) -> _VT: ... + # noinspection PyDefaultArgument def update(self, __m: MutableMapping[_KT, _VT] = {}, @@ -146,6 +206,7 @@ class DotWizPlus: __skip_init=False, **kwargs: _T) -> None: ... - def __dir__(self) -> Iterable[str]: ... + def values(self) -> ValuesView: ... + def __dir__(self) -> Iterable[str]: ... def __repr__(self) -> str: ... From d1797fb656a3416b6ddc048008b211ce584397ab Mon Sep 17 00:00:00 2001 From: Ritvik Nag Date: Fri, 1 Jul 2022 17:41:58 -0400 Subject: [PATCH 26/55] add *pragma: no cover* --- dotwiz/main.py | 9 ++++----- dotwiz/plus.py | 8 ++++---- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/dotwiz/main.py b/dotwiz/main.py index f9fe6ca..6fdf0f9 100644 --- a/dotwiz/main.py +++ b/dotwiz/main.py @@ -66,12 +66,11 @@ def __setitem_impl__(self, key, value, check_lists=True): self.__dict__[key] = value -if __PY_38_OR_ABOVE__: # pragma: no cover, Python >= 3.8 +if __PY_38_OR_ABOVE__: # Python >= 3.8, pragma: no cover def __reversed_impl__(self): """Implementation of `__reversed__`, to reverse the keys in a `DotWiz` instance.""" return reversed(self.__dict__) - -else: # Python < 3.8 +else: # Python < 3.8, pragma: no cover # Note: in Python 3.7, `dict` objects are not reversible by default. def __reversed_impl__(self): @@ -79,7 +78,7 @@ def __reversed_impl__(self): return reversed(list(self.__dict__)) -if __PY_39_OR_ABOVE__: # pragma: no cover, Python >= 3.9 +if __PY_39_OR_ABOVE__: # Python >= 3.9, pragma: no cover def __merge_impl_fn__(op, check_lists=True, __set=object.__setattr__): """Implementation of `__or__` and `__ror__`, to merge `DotWiz` and `dict` objects.""" @@ -100,7 +99,7 @@ def __merge_impl__(self, other): __or_impl__ = __merge_impl_fn__(dict.__or__) __ror_impl__ = __merge_impl_fn__(dict.__ror__) -else: # Python < 3.9 +else: # Python < 3.9, pragma: no cover # Note: this is *before* Union operators were introduced to `dict`, # in https://peps.python.org/pep-0584/ diff --git a/dotwiz/plus.py b/dotwiz/plus.py index d68c2c7..47d3978 100644 --- a/dotwiz/plus.py +++ b/dotwiz/plus.py @@ -164,12 +164,12 @@ def __setitem_impl__(self, key, value, check_lists=True): key, value) -if __PY_38_OR_ABOVE__: # pragma: no cover, Python >= 3.8 +if __PY_38_OR_ABOVE__: # Python >= 3.8, pragma: no cover def __reversed_impl__(self): """Implementation of `__reversed__`, to reverse the keys in a `DotWizPlus` instance.""" return reversed(self.__orig_dict__) -else: # Python < 3.8 +else: # Python < 3.8, pragma: no cover # Note: in Python 3.7, `dict` objects are not reversible by default. def __reversed_impl__(self): @@ -177,7 +177,7 @@ def __reversed_impl__(self): return reversed(list(self.__orig_dict__)) -if __PY_39_OR_ABOVE__: # pragma: no cover, Python >= 3.9 +if __PY_39_OR_ABOVE__: # Python >= 3.9, pragma: no cover def __merge_impl_fn__(op, check_lists=True, __set=object.__setattr__): """Implementation of `__or__` and `__ror__`, to merge `DotWizPlus` and `dict` objects.""" @@ -204,7 +204,7 @@ def __merge_impl__(self, other): __or_impl__ = __merge_impl_fn__(dict.__or__) __ror_impl__ = __merge_impl_fn__(dict.__ror__) -else: # Python < 3.9 +else: # Python < 3.9, pragma: no cover # Note: this is *before* Union operators were introduced to `dict`, # in https://peps.python.org/pep-0584/ From 9a03465ff9d67343a2f203ff7e2af9d6057d25ef Mon Sep 17 00:00:00 2001 From: Ritvik Nag Date: Fri, 1 Jul 2022 17:58:19 -0400 Subject: [PATCH 27/55] fix `DotWizPlus.to_dict` to working --- dotwiz/common.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/dotwiz/common.py b/dotwiz/common.py index 12e1636..9f113b6 100644 --- a/dotwiz/common.py +++ b/dotwiz/common.py @@ -87,16 +87,17 @@ def __convert_to_dict__(o): # we need to add both `to_dict` and `to_attr_dict` in this case. if has_attr_dict: - def __convert_to_dict_preserve_keys__(o, __items=dict.items): + def __convert_to_dict_preserve_keys__(o): """ Recursively convert an object (typically a `dict` subclass) to a Python `dict` type, while preserving the lower-cased keys used for attribute access. """ - if isinstance(o, dict): - # noinspection PyArgumentList + __dict = getattr(o, '__orig_dict__', None) + + if __dict: return {k: __convert_to_dict_preserve_keys__(v) - for k, v in __items(o)} + for k, v in __dict.items()} if isinstance(o, list): return [__convert_to_dict_preserve_keys__(e) for e in o] From 17fc994762ad9350460e8fb1d6bad5efd280bafa Mon Sep 17 00:00:00 2001 From: Ritvik Nag Date: Fri, 1 Jul 2022 19:10:21 -0400 Subject: [PATCH 28/55] fix `DotWizPlus.to_dict` to work as expected * update `to_json` to add a `filename` parameter for writing to a file * update `DotWizPlus.to_json` to add `attr` and `snake` parameters * Update `DotWizPlus.to_dict` to add the `snake` parameter fore returning snake-cased keys --- dotwiz/common.py | 116 +++++++++++++++++++++++++++++++++++++++++------ dotwiz/main.pyi | 11 +++++ dotwiz/plus.pyi | 22 ++++++++- 3 files changed, 135 insertions(+), 14 deletions(-) diff --git a/dotwiz/common.py b/dotwiz/common.py index 9f113b6..26126a6 100644 --- a/dotwiz/common.py +++ b/dotwiz/common.py @@ -48,6 +48,29 @@ def default(self, o): return json.JSONEncoder.default(self, o) +class DotWizPlusSnakeEncoder(json.JSONEncoder): + """ + Helper class for encoding of (nested) :class:`DotWizPlus` objects + into a standard ``dict``. + """ + + def default(self, o): + """ + Return the snake-cased `dict` data of :class:`DotWizPlus` when + possible, or encode with standard format otherwise. + + :param o: Input object + :return: Serializable data + + """ + try: + __dict = o.__dict__ + return {k.strip('_'): __dict[k] for k in __dict} + + except AttributeError: + return json.JSONEncoder.default(self, o) + + def __add_common_methods__(name, bases, cls_dict, *, print_char='*', has_attr_dict=False): @@ -87,12 +110,31 @@ def __convert_to_dict__(o): # we need to add both `to_dict` and `to_attr_dict` in this case. if has_attr_dict: - def __convert_to_dict_preserve_keys__(o): + def __convert_to_dict_snake_cased__(o, __strip=str.strip): + """ + Recursively convert an object (typically a custom `dict` type) to + a Python `dict` type, while preserving snake-cased keys. """ - Recursively convert an object (typically a `dict` subclass) to a + __dict = getattr(o, '__dict__', None) + + if __dict: + return {k.strip('_'): __convert_to_dict_snake_cased__(v) + for k, v in __dict.items()} + + if isinstance(o, list): + return [__convert_to_dict_snake_cased__(e) for e in o] + + return o + + def __convert_to_dict_preserve_keys__(o, snake=False): + """ + Recursively convert an object (typically a custom `dict` type) to a Python `dict` type, while preserving the lower-cased keys used for attribute access. """ + if snake: + return __convert_to_dict_snake_cased__(o) + __dict = getattr(o, '__orig_dict__', None) if __dict: @@ -104,15 +146,46 @@ def __convert_to_dict_preserve_keys__(o): return o - def to_json(o, encoder=json.dumps, **encoder_kwargs): - cls = encoder_kwargs.pop('cls', DotWizPlusEncoder) - return encoder(o.__orig_dict__, cls=cls, **encoder_kwargs) + def to_json(o, attr=False, snake=False, + filename=None, encoding='utf-8', errors='strict', + file_encoder=json.dump, + encoder=json.dumps, **encoder_kwargs): + if attr: + __default_encoder = DotWizEncoder + __initial_dict = o.__dict__ + elif snake: + __default_encoder = DotWizPlusSnakeEncoder + __initial_dict = o.__dict__ + else: + __default_encoder = DotWizPlusEncoder + __initial_dict = o.__orig_dict__ + + cls = encoder_kwargs.pop('cls', __default_encoder) + + if filename: + with open(filename, 'w', encoding=encoding, errors=errors) as f: + file_encoder(__initial_dict, f, cls=cls, **encoder_kwargs) + else: + return encoder(__initial_dict, cls=cls, **encoder_kwargs) # add a `to_json` method to the class. cls_dict['to_json'] = to_json - to_json.__doc__ = ( - f'Serialize the :class:`{name}` instance as a JSON string.' - ) + to_json.__doc__ = f""" +Serialize the :class:`{name}` instance as a JSON string. + +:param attr: True to return the lower-cased keys used for attribute + access. +:param snake: True to return the `snake_case` variant of keys, + i.e. with leading and trailing underscores (_) stripped out. +:param filename: If provided, will save to a file. +:param encoding: File encoding. +:param errors: How to handle encoding errors. +:param file_encoder: The encoder to use, when `filename` is passed. +:param encoder: The encoder to serialize with, defaults to `json.dumps`. +:param encoder_kwargs: The keyword arguments to pass in to the encoder. + +:return: a string in JSON format (if no filename is provided) +""" # add a `to_dict` method to the class. cls_dict['to_dict'] = __convert_to_dict_preserve_keys__ @@ -134,15 +207,32 @@ def to_json(o, encoder=json.dumps, **encoder_kwargs): # we only need to add a `to_dict` method in this case. else: - def to_json(o, encoder=json.dumps, **encoder_kwargs): + def to_json(o, filename=None, encoding='utf-8', errors='strict', + file_encoder=json.dump, + encoder=json.dumps, **encoder_kwargs): + cls = encoder_kwargs.pop('cls', DotWizEncoder) - return encoder(o.__dict__, cls=cls, **encoder_kwargs) + + if filename: + with open(filename, 'w', encoding=encoding, errors=errors) as f: + file_encoder(o.__dict__, f, cls=cls, **encoder_kwargs) + else: + return encoder(o.__dict__, cls=cls, **encoder_kwargs) # add a `to_json` method to the class. cls_dict['to_json'] = to_json - to_json.__doc__ = ( - f'Serialize the :class:`{name}` instance as a JSON string.' - ) + to_json.__doc__ = f""" +Serialize the :class:`{name}` instance as a JSON string. + +:param filename: If provided, will save to a file. +:param encoding: File encoding. +:param errors: How to handle encoding errors. +:param file_encoder: The encoder to use, when `filename` is passed. +:param encoder: The encoder to serialize with, defaults to `json.dumps`. +:param encoder_kwargs: The keyword arguments to pass in to the encoder. + +:return: a string in JSON format (if no filename is provided) +""" # add a `to_dict` method to the class. cls_dict['to_dict'] = __convert_to_dict__ diff --git a/dotwiz/main.pyi b/dotwiz/main.pyi index 6d8a0d4..90d095b 100644 --- a/dotwiz/main.pyi +++ b/dotwiz/main.pyi @@ -1,4 +1,5 @@ import json +from os import PathLike from typing import ( Callable, Protocol, TypeVar, Iterable, Iterator, Reversible, @@ -115,13 +116,23 @@ class DotWiz: ... def to_json(self, *, + filename: str | PathLike = ..., + encoding: str = ..., + errors: str = ..., + file_encoder=json.dump, encoder: Encoder = json.dumps, **encoder_kwargs) -> AnyStr: """ Serialize the :class:`DotWiz` instance as a JSON string. + :param filename: If provided, will save to a file. + :param encoding: File encoding. + :param errors: How to handle encoding errors. + :param file_encoder: The encoder to use, when `filename` is passed. :param encoder: The encoder to serialize with, defaults to `json.dumps`. :param encoder_kwargs: The keyword arguments to pass in to the encoder. + + :return: a string in JSON format (if no filename is provided) """ ... diff --git a/dotwiz/plus.pyi b/dotwiz/plus.pyi index b2cbcfd..2151975 100644 --- a/dotwiz/plus.pyi +++ b/dotwiz/plus.pyi @@ -1,4 +1,5 @@ import json +from os import PathLike from typing import ( Callable, Protocol, TypeVar, Iterable, Iterator, Reversible, @@ -138,9 +139,12 @@ class DotWizPlus: def __ior__(self, other: DotWizPlus | dict) -> DotWizPlus: ... def __ror__(self, other: DotWizPlus | dict) -> DotWizPlus: ... - def to_dict(self) -> dict[_KT, _VT]: + def to_dict(self, *, snake=False) -> dict[_KT, _VT]: """ Recursively convert the :class:`DotWizPlus` instance back to a ``dict``. + + :param snake: True to return the `snake_case` variant of keys, + i.e. with leading and trailing underscores (_) stripped out. """ ... @@ -152,13 +156,29 @@ class DotWizPlus: ... def to_json(self, *, + attr=False, + snake=False, + filename: str | PathLike = ..., + encoding: str = ..., + errors: str = ..., + file_encoder=json.dump, encoder: Encoder = json.dumps, **encoder_kwargs) -> AnyStr: """ Serialize the :class:`DotWizPlus` instance as a JSON string. + :param attr: True to return the lower-cased keys used for attribute + access. + :param snake: True to return the `snake_case` variant of keys, + i.e. with leading and trailing underscores (_) stripped out. + :param filename: If provided, will save to a file. + :param encoding: File encoding. + :param errors: How to handle encoding errors. + :param file_encoder: The encoder to use, when `filename` is passed. :param encoder: The encoder to serialize with, defaults to `json.dumps`. :param encoder_kwargs: The keyword arguments to pass in to the encoder. + + :return: a string in JSON format (if no filename is provided) """ ... From a70a960a16396d5654c0d4e48f875882ec8df11d Mon Sep 17 00:00:00 2001 From: Ritvik Nag Date: Fri, 1 Jul 2022 19:17:05 -0400 Subject: [PATCH 29/55] add tests for `DotWizPlus` --- benchmarks/test_create.py | 11 +++++++++++ benchmarks/test_create_special_keys.py | 14 ++++++++++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/benchmarks/test_create.py b/benchmarks/test_create.py index 2187872..6c5952c 100644 --- a/benchmarks/test_create.py +++ b/benchmarks/test_create.py @@ -103,6 +103,17 @@ def test_dotwiz_plus(benchmark, my_data): assert result.c.bb[0].x == 77 +def test_dotwiz_plus_without_check_lists(benchmark, my_data): + result = benchmark(dotwiz.DotWizPlus, my_data, check_lists=False) + # print(result) + + # now similar to `dict2dot`, `dict`s nested within `lists` won't work + # assert result.c.bb[0].x == 77 + + # instead, dict access should work fine: + assert result.c.bb[0]['x'] == 77 + + def test_make_dot_wiz_plus(benchmark, my_data): result = benchmark(dotwiz.make_dot_wiz_plus, my_data) # print(result) diff --git a/benchmarks/test_create_special_keys.py b/benchmarks/test_create_special_keys.py index c2b9199..b68a0cf 100644 --- a/benchmarks/test_create_special_keys.py +++ b/benchmarks/test_create_special_keys.py @@ -79,14 +79,17 @@ def assert_eq2(result): assert result['Some r@ndom#$(*#@ Key##$# here !!!'] == 'T' -def assert_eq3(result): +def assert_eq3(result, nested_in_list=True): assert result.camel_case == 1 assert result.snake_case == 2 assert result.pascal_case == 3 assert result.spinal_case3 == 4 assert result.hello_how_s_it_going == 5 assert result._3d == 6 - assert result.for_._1nfinity[0].and_.beyond == 8 + if nested_in_list: + assert result.for_._1nfinity[0].and_.beyond == 8 + else: + assert result.for_._1nfinity[0]['and']['Beyond!'] == 8 assert result.some_r_ndom_key_here == 'T' @@ -198,6 +201,13 @@ def test_dotwiz_plus(benchmark, my_data): assert_eq3(result) +def test_dotwiz_plus_without_check_lists(benchmark, my_data): + result = benchmark(dotwiz.DotWizPlus, my_data, check_lists=False) + # print(result) + + assert_eq3(result, nested_in_list=False) + + def test_make_dot_wiz_plus(benchmark, my_data): result = benchmark(dotwiz.make_dot_wiz_plus, my_data) # print(result) From 26ee0aedac75343b79ca78347f20565fff21cac1 Mon Sep 17 00:00:00 2001 From: Ritvik Nag Date: Fri, 1 Jul 2022 20:50:17 -0400 Subject: [PATCH 30/55] move out `encoders` --- dotwiz/common.py | 69 ++----------------------------------------- dotwiz/common.pyi | 8 ----- dotwiz/constants.py | 4 ++- dotwiz/encoders.py | 71 +++++++++++++++++++++++++++++++++++++++++++++ dotwiz/encoders.pyi | 12 ++++++++ 5 files changed, 89 insertions(+), 75 deletions(-) create mode 100644 dotwiz/encoders.py create mode 100644 dotwiz/encoders.pyi diff --git a/dotwiz/common.py b/dotwiz/common.py index 26126a6..a36e3c2 100644 --- a/dotwiz/common.py +++ b/dotwiz/common.py @@ -3,72 +3,9 @@ """ import json - -class DotWizEncoder(json.JSONEncoder): - """ - Helper class for encoding of (nested) :class:`DotWiz` objects - into a standard ``dict``. - """ - - def default(self, o): - """ - Return the `dict` data of :class:`DotWiz` when possible, or encode - with standard format otherwise. - - :param o: Input object - :return: Serializable data - - """ - try: - return o.__dict__ - - except AttributeError: - return json.JSONEncoder.default(self, o) - - -class DotWizPlusEncoder(json.JSONEncoder): - """ - Helper class for encoding of (nested) :class:`DotWizPlus` objects - into a standard ``dict``. - """ - - def default(self, o): - """ - Return the `dict` data of :class:`DotWizPlus` when possible, or encode - with standard format otherwise. - - :param o: Input object - :return: Serializable data - - """ - try: - return o.__orig_dict__ - - except AttributeError: - return json.JSONEncoder.default(self, o) - - -class DotWizPlusSnakeEncoder(json.JSONEncoder): - """ - Helper class for encoding of (nested) :class:`DotWizPlus` objects - into a standard ``dict``. - """ - - def default(self, o): - """ - Return the snake-cased `dict` data of :class:`DotWizPlus` when - possible, or encode with standard format otherwise. - - :param o: Input object - :return: Serializable data - - """ - try: - __dict = o.__dict__ - return {k.strip('_'): __dict[k] for k in __dict} - - except AttributeError: - return json.JSONEncoder.default(self, o) +from dotwiz.encoders import ( + DotWizEncoder, DotWizPlusEncoder, DotWizPlusSnakeEncoder +) def __add_common_methods__(name, bases, cls_dict, *, diff --git a/dotwiz/common.pyi b/dotwiz/common.pyi index 25ad36d..0407cb0 100644 --- a/dotwiz/common.pyi +++ b/dotwiz/common.pyi @@ -1,4 +1,3 @@ -import json from typing import Any, Callable, ItemsView, TypeVar from dotwiz import DotWiz, DotWizPlus @@ -12,13 +11,6 @@ _VT = TypeVar('_VT') _ItemsFn = Callable[[_D ], ItemsView[_KT, _VT]] -class DotWizEncoder(json.JSONEncoder): - def default(self, o: Any) -> Any: ... - -class DotWizPlusEncoder(json.JSONEncoder): - def default(self, o: Any) -> Any: ... - - def __add_common_methods__(name: str, bases: tuple[type, ...], cls_dict: dict[str, Any], diff --git a/dotwiz/constants.py b/dotwiz/constants.py index 15e9d06..579b652 100644 --- a/dotwiz/constants.py +++ b/dotwiz/constants.py @@ -1,4 +1,6 @@ -"""Project-specific constant values""" +""" +Project-specific constant values +""" __all__ = [ '__PY_VERSION__', '__PY_38_OR_ABOVE__', diff --git a/dotwiz/encoders.py b/dotwiz/encoders.py new file mode 100644 index 0000000..d7be710 --- /dev/null +++ b/dotwiz/encoders.py @@ -0,0 +1,71 @@ +""" +Custom JSON encoders. +""" +import json + + +class DotWizEncoder(json.JSONEncoder): + """ + Helper class for encoding of (nested) :class:`DotWiz` objects + into a standard ``dict``. + """ + + def default(self, o): + """ + Return the `dict` data of :class:`DotWiz` when possible, or encode + with standard format otherwise. + + :param o: Input object + :return: Serializable data + + """ + try: + return o.__dict__ + + except AttributeError: + return json.JSONEncoder.default(self, o) + + +class DotWizPlusEncoder(json.JSONEncoder): + """ + Helper class for encoding of (nested) :class:`DotWizPlus` objects + into a standard ``dict``. + """ + + def default(self, o): + """ + Return the `dict` data of :class:`DotWizPlus` when possible, or encode + with standard format otherwise. + + :param o: Input object + :return: Serializable data + + """ + try: + return o.__orig_dict__ + + except AttributeError: + return json.JSONEncoder.default(self, o) + + +class DotWizPlusSnakeEncoder(json.JSONEncoder): + """ + Helper class for encoding of (nested) :class:`DotWizPlus` objects + into a standard ``dict``. + """ + + def default(self, o): + """ + Return the snake-cased `dict` data of :class:`DotWizPlus` when + possible, or encode with standard format otherwise. + + :param o: Input object + :return: Serializable data + + """ + try: + __dict = o.__dict__ + return {k.strip('_'): __dict[k] for k in __dict} + + except AttributeError: + return json.JSONEncoder.default(self, o) diff --git a/dotwiz/encoders.pyi b/dotwiz/encoders.pyi new file mode 100644 index 0000000..d66c59c --- /dev/null +++ b/dotwiz/encoders.pyi @@ -0,0 +1,12 @@ +import json +from typing import Any + + +class DotWizEncoder(json.JSONEncoder): + def default(self, o: Any) -> Any: ... + +class DotWizPlusEncoder(json.JSONEncoder): + def default(self, o: Any) -> Any: ... + +class DotWizPlusSnakeEncoder(json.JSONEncoder): + def default(self, o: Any) -> Any: ... From 8b276432162386db53c38849ea072a073ee3223c Mon Sep 17 00:00:00 2001 From: Ritvik Nag Date: Fri, 1 Jul 2022 20:50:53 -0400 Subject: [PATCH 31/55] add a dot --- dotwiz/constants.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotwiz/constants.py b/dotwiz/constants.py index 579b652..f986f22 100644 --- a/dotwiz/constants.py +++ b/dotwiz/constants.py @@ -1,5 +1,5 @@ """ -Project-specific constant values +Project-specific constant values. """ __all__ = [ '__PY_VERSION__', From 6b90bd325a471cda7efd924cb29d0a8dfff02ccb Mon Sep 17 00:00:00 2001 From: Ritvik Nag Date: Fri, 1 Jul 2022 20:57:13 -0400 Subject: [PATCH 32/55] mention `to_json` usage --- dotwiz/plus.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/dotwiz/plus.py b/dotwiz/plus.py index 47d3978..80a9076 100644 --- a/dotwiz/plus.py +++ b/dotwiz/plus.py @@ -289,6 +289,8 @@ class DotWizPlus(metaclass=__add_common_methods__, {'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} + >>> dw.to_json(snake=True) + '{"key_1": [{"3d": {"with": 2}}], "key_two": "5", "r_2_d_2": 3.21}' **Issues with Invalid Characters** From 793dcac6328c6269208afca8e854e5eb4ef3dd1a Mon Sep 17 00:00:00 2001 From: Ritvik Nag Date: Fri, 1 Jul 2022 21:16:38 -0400 Subject: [PATCH 33/55] mention `to_json` usage --- README.rst | 12 +++++++++--- benchmarks/conftest.py | 4 ++++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/README.rst b/README.rst index 8ae0cea..da2d919 100644 --- a/README.rst +++ b/README.rst @@ -97,6 +97,9 @@ creating a ``DotWiz`` object: assert dw['easy: as~ pie?'] assert dw.AnyKey == 'value' + print(dw.to_json()) + #> {"AnyKey": "value", "hello, world!": 123, "easy: as~ pie?": true} + ``DotWizPlus`` ~~~~~~~~~~~~~~ @@ -112,7 +115,7 @@ on `Issues with Invalid Characters`_ below. dw = DotWizPlus(my_dict) print(dw) - #> ✪(this=✪(_1=✪(is_=[✪(for_=✪(all_of=✪(my_fans=True)))]))) + # > ✪(this=✪(_1=✪(is_=[✪(for_=✪(all_of=✪(my_fans=True)))]))) # True assert dw.this._1.is_[0].for_.all_of.my_fans @@ -121,10 +124,13 @@ on `Issues with Invalid Characters`_ below. assert dw['THIS']['1']['is'][0]['For']['AllOf']['My !@ Fans!'] print(dw.to_dict()) - # {'THIS': {'1': {'is': [{'For': {'AllOf': {'My !@ Fans!': True}}}]}}} + # > {'THIS': {'1': {'is': [{'For': {'AllOf': {'My !@ Fans!': True}}}]}}} print(dw.to_attr_dict()) - # {'this': {'_1': {'is_': [{'for_': {'all_of': {'my_fans': True}}}]}}} + # > {'this': {'_1': {'is_': [{'for_': {'all_of': {'my_fans': True}}}]}} + + print(dw.to_json(snake=True)) + # > {"this": {"1": {"is": [{"for": {"all_of": {"my_fans": true}}}]}}} Issues with Invalid Characters ****************************** diff --git a/benchmarks/conftest.py b/benchmarks/conftest.py index 98c1301..a7b1c62 100644 --- a/benchmarks/conftest.py +++ b/benchmarks/conftest.py @@ -26,6 +26,10 @@ def parse(d): @pytest.fixture def ns_to_dict(): + """ + Return a helper function to convert a `SimpleNamespace` object to + a `dict`. + """ def to_dict(ns): """Recursively converts a `SimpleNamespace` object to a `dict`.""" From 1ba9ce85d5332ee7aeb0b59c3301858c71177c57 Mon Sep 17 00:00:00 2001 From: Ritvik Nag Date: Sat, 2 Jul 2022 00:10:22 -0400 Subject: [PATCH 34/55] minor updates --- docs/dotwiz.rst | 16 ++++++++++++++++ dotwiz/common.py | 20 +++++++++++++------- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/docs/dotwiz.rst b/docs/dotwiz.rst index e19fa2e..b77e311 100644 --- a/docs/dotwiz.rst +++ b/docs/dotwiz.rst @@ -12,6 +12,22 @@ dotwiz.common module :undoc-members: :show-inheritance: +dotwiz.constants module +----------------------- + +.. automodule:: dotwiz.constants + :members: + :undoc-members: + :show-inheritance: + +dotwiz.encoders module +---------------------- + +.. automodule:: dotwiz.encoders + :members: + :undoc-members: + :show-inheritance: + dotwiz.main module ------------------ diff --git a/dotwiz/common.py b/dotwiz/common.py index a36e3c2..26fc77c 100644 --- a/dotwiz/common.py +++ b/dotwiz/common.py @@ -63,26 +63,30 @@ def __convert_to_dict_snake_cased__(o, __strip=str.strip): return o - def __convert_to_dict_preserve_keys__(o, snake=False): + def __convert_to_dict_preserve_keys_inner__(o): """ Recursively convert an object (typically a custom `dict` type) to a Python `dict` type, while preserving the lower-cased keys used for attribute access. """ - if snake: - return __convert_to_dict_snake_cased__(o) - __dict = getattr(o, '__orig_dict__', None) if __dict: - return {k: __convert_to_dict_preserve_keys__(v) + return {k: __convert_to_dict_preserve_keys_inner__(v) for k, v in __dict.items()} if isinstance(o, list): - return [__convert_to_dict_preserve_keys__(e) for e in o] + return [__convert_to_dict_preserve_keys_inner__(e) for e in o] return o + def __convert_to_dict_preserve_keys__(o, snake=False): + if snake: + return __convert_to_dict_snake_cased__(o) + + return {k: __convert_to_dict_preserve_keys_inner__(v) + for k, v in o.__orig_dict__.items()} + def to_json(o, attr=False, snake=False, filename=None, encoding='utf-8', errors='strict', file_encoder=json.dump, @@ -129,7 +133,9 @@ def to_json(o, attr=False, snake=False, __convert_to_dict_preserve_keys__.__name__ = 'to_dict' __convert_to_dict_preserve_keys__.__doc__ = ( f'Recursively convert the :class:`{name}` instance back to ' - 'a ``dict``.' + 'a ``dict``.\n\n' + ':param snake: True to return the `snake_case` variant of keys,\n' + ' i.e. with leading and trailing underscores (_) stripped out.' ) # add a `to_attr_dict` method to the class. From cc538d53d3de47e11d7999cf5d9f81d066eb044d Mon Sep 17 00:00:00 2001 From: Ritvik Nag Date: Sat, 2 Jul 2022 00:23:20 -0400 Subject: [PATCH 35/55] minor bug fix --- dotwiz/common.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dotwiz/common.py b/dotwiz/common.py index 26fc77c..2d999cb 100644 --- a/dotwiz/common.py +++ b/dotwiz/common.py @@ -96,7 +96,8 @@ def to_json(o, attr=False, snake=False, __initial_dict = o.__dict__ elif snake: __default_encoder = DotWizPlusSnakeEncoder - __initial_dict = o.__dict__ + __initial_dict = {k.strip('_'): v + for k, v in o.__dict__.items()} else: __default_encoder = DotWizPlusEncoder __initial_dict = o.__orig_dict__ From b736cd85ded030779747044c652e97a174165d76 Mon Sep 17 00:00:00 2001 From: Ritvik Nag Date: Sat, 2 Jul 2022 00:43:45 -0400 Subject: [PATCH 36/55] minor updates --- dotwiz/common.py | 17 +++++++++-------- dotwiz/encoders.py | 23 ----------------------- dotwiz/encoders.pyi | 3 --- 3 files changed, 9 insertions(+), 34 deletions(-) diff --git a/dotwiz/common.py b/dotwiz/common.py index 2d999cb..0eddba2 100644 --- a/dotwiz/common.py +++ b/dotwiz/common.py @@ -3,9 +3,7 @@ """ import json -from dotwiz.encoders import ( - DotWizEncoder, DotWizPlusEncoder, DotWizPlusSnakeEncoder -) +from dotwiz.encoders import DotWizEncoder, DotWizPlusEncoder def __add_common_methods__(name, bases, cls_dict, *, @@ -47,7 +45,7 @@ def __convert_to_dict__(o): # we need to add both `to_dict` and `to_attr_dict` in this case. if has_attr_dict: - def __convert_to_dict_snake_cased__(o, __strip=str.strip): + def __convert_to_dict_snake_cased__(o): """ Recursively convert an object (typically a custom `dict` type) to a Python `dict` type, while preserving snake-cased keys. @@ -82,7 +80,8 @@ def __convert_to_dict_preserve_keys_inner__(o): def __convert_to_dict_preserve_keys__(o, snake=False): if snake: - return __convert_to_dict_snake_cased__(o) + return {k.strip('_'): __convert_to_dict_snake_cased__(v) + for k, v in o.__dict__.items()} return {k: __convert_to_dict_preserve_keys_inner__(v) for k, v in o.__orig_dict__.items()} @@ -95,9 +94,11 @@ def to_json(o, attr=False, snake=False, __default_encoder = DotWizEncoder __initial_dict = o.__dict__ elif snake: - __default_encoder = DotWizPlusSnakeEncoder - __initial_dict = {k.strip('_'): v - for k, v in o.__dict__.items()} + __default_encoder = None + __initial_dict = { + k.strip('_'): __convert_to_dict_snake_cased__(v) + for k, v in o.__dict__.items() + } else: __default_encoder = DotWizPlusEncoder __initial_dict = o.__orig_dict__ diff --git a/dotwiz/encoders.py b/dotwiz/encoders.py index d7be710..891e38f 100644 --- a/dotwiz/encoders.py +++ b/dotwiz/encoders.py @@ -46,26 +46,3 @@ def default(self, o): except AttributeError: return json.JSONEncoder.default(self, o) - - -class DotWizPlusSnakeEncoder(json.JSONEncoder): - """ - Helper class for encoding of (nested) :class:`DotWizPlus` objects - into a standard ``dict``. - """ - - def default(self, o): - """ - Return the snake-cased `dict` data of :class:`DotWizPlus` when - possible, or encode with standard format otherwise. - - :param o: Input object - :return: Serializable data - - """ - try: - __dict = o.__dict__ - return {k.strip('_'): __dict[k] for k in __dict} - - except AttributeError: - return json.JSONEncoder.default(self, o) diff --git a/dotwiz/encoders.pyi b/dotwiz/encoders.pyi index d66c59c..d84e372 100644 --- a/dotwiz/encoders.pyi +++ b/dotwiz/encoders.pyi @@ -7,6 +7,3 @@ class DotWizEncoder(json.JSONEncoder): class DotWizPlusEncoder(json.JSONEncoder): def default(self, o: Any) -> Any: ... - -class DotWizPlusSnakeEncoder(json.JSONEncoder): - def default(self, o: Any) -> Any: ... From 2f0b188933d5aef61092ec617e0042e43c23fed3 Mon Sep 17 00:00:00 2001 From: Ritvik Nag Date: Thu, 28 Jul 2022 20:44:26 -0400 Subject: [PATCH 37/55] add implementation and tests for `from_json` --- dotwiz/main.py | 70 ++++++++++++++++++++++++---------- dotwiz/main.pyi | 51 ++++++++++++++++++++----- dotwiz/plus.py | 67 +++++++++++++++++++++++++------- dotwiz/plus.pyi | 33 +++++++++++++++- requirements-dev.txt | 1 + tests/unit/conftest.py | 26 +++++++++++++ tests/unit/test_dotwiz.py | 68 +++++++++++++++++++++++++++++++++ tests/unit/test_dotwiz_plus.py | 68 +++++++++++++++++++++++++++++++++ 8 files changed, 340 insertions(+), 44 deletions(-) create mode 100644 tests/unit/conftest.py diff --git a/dotwiz/main.py b/dotwiz/main.py index 6fdf0f9..58a31f5 100644 --- a/dotwiz/main.py +++ b/dotwiz/main.py @@ -1,4 +1,5 @@ """Main module.""" +import json from .common import ( __resolve_value__, __add_common_methods__, @@ -25,12 +26,19 @@ def make_dot_wiz(*args, **kwargs): # noinspection PyDefaultArgument def __upsert_into_dot_wiz__(self, input_dict={}, - check_lists=True, **kwargs): + check_lists=True, + __set=object.__setattr__, + __set_dict=False, + **kwargs): """ Helper method to generate / update a :class:`DotWiz` (dot-access dict) from a Python ``dict`` object, and optional *keyword arguments*. """ + if __set_dict: + __set(self, '__dict__', input_dict) + return None + __dict = self.__dict__ if kwargs: @@ -79,7 +87,7 @@ def __reversed_impl__(self): if __PY_39_OR_ABOVE__: # Python >= 3.9, pragma: no cover - def __merge_impl_fn__(op, check_lists=True, __set=object.__setattr__): + def __merge_impl_fn__(op, check_lists=True): """Implementation of `__or__` and `__ror__`, to merge `DotWiz` and `dict` objects.""" def __merge_impl__(self, other): @@ -89,10 +97,7 @@ def __merge_impl__(self, other): } __merged_dict = op(self.__dict__, __other_dict) - __merged = DotWiz() - __set(__merged, '__dict__', __merged_dict) - - return __merged + return DotWiz(__merged_dict, __set_dict=True) return __merge_impl__ @@ -103,7 +108,7 @@ def __merge_impl__(self, other): # Note: this is *before* Union operators were introduced to `dict`, # in https://peps.python.org/pep-0584/ - def __or_impl__(self, other, check_lists=True, __set=object.__setattr__): + def __or_impl__(self, other, check_lists=True): """Implementation of `__or__` to merge `DotWiz` and `dict` objects.""" __other_dict = getattr(other, '__dict__', None) or { k: __resolve_value__(other[k], DotWiz, check_lists) @@ -111,12 +116,9 @@ def __or_impl__(self, other, check_lists=True, __set=object.__setattr__): } __merged_dict = {**self.__dict__, **__other_dict} - __merged = DotWiz() - __set(__merged, '__dict__', __merged_dict) + return DotWiz(__merged_dict, __set_dict=True) - return __merged - - def __ror_impl__(self, other, check_lists=True, __set=object.__setattr__): + def __ror_impl__(self, other, check_lists=True): """Implementation of `__ror__` to merge `DotWiz` and `dict` objects.""" __other_dict = getattr(other, '__dict__', None) or { k: __resolve_value__(other[k], DotWiz, check_lists) @@ -124,10 +126,7 @@ def __ror_impl__(self, other, check_lists=True, __set=object.__setattr__): } __merged_dict = {**__other_dict, **self.__dict__} - __merged = DotWiz() - __set(__merged, '__dict__', __merged_dict) - - return __merged + return DotWiz(__merged_dict, __set_dict=True) def __ior_impl__(self, other, check_lists=True, __update=dict.update): @@ -141,6 +140,36 @@ def __ior_impl__(self, other, check_lists=True, __update=dict.update): return self +def __from_json__(json_string=None, filename=None, + encoding='utf-8', errors='strict', + multiline=False, + file_decoder=json.load, + decoder=json.loads, + __object_hook=lambda d: DotWiz(d, __set_dict=True), + **decoder_kwargs): + """ + Helper function to create and return a :class:`DotWiz` (dot-access dict) + -- or a list of :class:`DotWiz` instances -- from a JSON string. + + """ + if filename: + with open(filename, encoding=encoding, errors=errors) as f: + if multiline: + return [ + decoder(line.strip(), object_hook=__object_hook, + **decoder_kwargs) + for line in f + if line.strip() and not line.strip().startswith('#') + ] + + else: + return file_decoder(f, object_hook=__object_hook, + **decoder_kwargs) + + return decoder(json_string, object_hook=__object_hook, + **decoder_kwargs) + + class DotWiz(metaclass=__add_common_methods__, print_char='✫'): """ @@ -210,19 +239,18 @@ def __len__(self): __reversed__ = __reversed_impl__ + from_json = __from_json__ + def clear(self): return self.__dict__.clear() - def copy(self, __copy=dict.copy, __set=object.__setattr__): + def copy(self, __copy=dict.copy): """ Returns a shallow copy of the `dict` wrapped in :class:`DotWiz`. :return: DotWiz instance """ - dw = DotWiz() - __set(dw, '__dict__', __copy(self.__dict__)) - - return dw + return DotWiz(__copy(self.__dict__), __set_dict=True) # noinspection PyIncorrectDocstring @classmethod diff --git a/dotwiz/main.pyi b/dotwiz/main.pyi index 90d095b..3ca788f 100644 --- a/dotwiz/main.pyi +++ b/dotwiz/main.pyi @@ -1,7 +1,7 @@ import json from os import PathLike from typing import ( - Callable, Protocol, TypeVar, + Callable, Protocol, TypeVar, Union, Iterable, Iterator, Reversible, KeysView, ItemsView, ValuesView, Mapping, MutableMapping, AnyStr, Any, @@ -50,6 +50,8 @@ def make_dot_wiz(*args: Iterable[_KT, _VT], def __upsert_into_dot_wiz__(self: DotWiz, input_dict: MutableMapping[_KT, _VT] = {}, *, check_lists=True, + __set: _SetAttribute = object.__setattr__, + __set_dict=False, **kwargs: _T) -> None: ... def __setitem_impl__(self: DotWiz, @@ -58,25 +60,36 @@ def __setitem_impl__(self: DotWiz, *, check_lists=True) -> None: ... def __merge_impl_fn__(op: Callable[[dict, dict], dict], - *, check_lists=True, - __set: _SetAttribute = object.__setattr__ + *, + check_lists=True ) -> Callable[[DotWiz, DotWiz | dict], DotWiz]: ... def __or_impl__(self: DotWiz, other: DotWiz | dict, - *, check_lists=True, - __set: _SetAttribute = object.__setattr__) -> DotWiz: ... + *, check_lists=True + ) -> DotWiz: ... def __ror_impl__(self: DotWiz, other: DotWiz | dict, - *, check_lists=True, - __set: _SetAttribute = object.__setattr__) -> DotWiz: ... + *, check_lists=True + ) -> DotWiz: ... def __ior_impl__(self: DotWiz, other: DotWiz | dict, *, check_lists=True, __update: _Update = dict.update): ... +def __from_json__(json_string: str = ..., *, + filename: str | PathLike = ..., + encoding: str = ..., + errors: str = ..., + multiline: bool = False, + file_decoder=json.load, + decoder=json.loads, + __object_hook = ..., + **decoder_kwargs + ) -> Union[DotWiz, list[DotWiz]]: ... + class DotWiz: @@ -84,6 +97,8 @@ class DotWiz: def __init__(self, input_dict: MutableMapping[_KT, _VT] = {}, *, check_lists=True, + __set: _SetAttribute = object.__setattr__, + __set_dict=False, **kwargs: _T) -> None: ... def __bool__(self) -> bool: ... @@ -109,6 +124,23 @@ class DotWiz: def __ior__(self, other: DotWiz | dict) -> DotWiz: ... def __ror__(self, other: DotWiz | dict) -> DotWiz: ... + @classmethod + def from_json(cls, json_string: str = ..., *, + filename: str | PathLike = ..., + encoding: str = ..., + errors: str = ..., + multiline: bool = False, + file_decoder=json.load, + decoder=json.loads, + __object_hook=..., + **decoder_kwargs + ) -> Union[DotWiz, list[DotWiz]]: + """ + De-serialize a JSON string into a :class:`DotWiz` instance, or a list + of :class:`DotWiz` instances. + """ + ... + def to_dict(self) -> dict[_KT, _VT]: """ Recursively convert the :class:`DotWiz` instance back to a ``dict``. @@ -139,8 +171,7 @@ class DotWiz: def clear(self) -> None: ... def copy(self, - *, __copy: _Copy = dict.copy, - __set: _SetAttribute = object.__setattr__) -> DotWiz: ... + *, __copy: _Copy = dict.copy) -> DotWiz: ... # noinspection PyUnresolvedReferences @classmethod @@ -176,6 +207,8 @@ class DotWiz: def update(self, __m: MutableMapping[_KT, _VT] = {}, *, check_lists=True, + __set: _SetAttribute = object.__setattr__, + __set_dict=False, **kwargs: _T) -> None: ... def values(self) -> ValuesView: ... diff --git a/dotwiz/plus.py b/dotwiz/plus.py index 47d3978..d3fa8bf 100644 --- a/dotwiz/plus.py +++ b/dotwiz/plus.py @@ -1,5 +1,6 @@ """Dot Wiz Plus module.""" import itertools +import json import keyword from pyheck import snake @@ -101,6 +102,7 @@ def __store_in_object__(__self_dict, __self_orig_dict, __self_orig_keys, # noinspection PyDefaultArgument def __upsert_into_dot_wiz_plus__(self, input_dict={}, check_lists=True, + check_types=True, __skip_init=False, __set=object.__setattr__, **kwargs): @@ -128,21 +130,28 @@ def __upsert_into_dot_wiz_plus__(self, input_dict={}, __orig_keys = {} __set(self, '__orig_keys__', __orig_keys) - 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 check_types: + + 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: - # noinspection PyArgumentList - value = DotWizPlus(value, check_lists) - elif check_lists and t is list: - value = [__resolve_value__(e, DotWizPlus) for e in value] + if t is dict: + # noinspection PyArgumentList + value = DotWizPlus(value, check_lists) + elif check_lists and t is list: + value = [__resolve_value__(e, DotWizPlus) for e in value] - __store_in_object__(__dict, __orig_dict, __orig_keys, key, value) + __store_in_object__(__dict, __orig_dict, __orig_keys, key, value) + + else: # don't check for any nested `dict` and `list` types + + for key, value in input_dict.items(): + __store_in_object__(__dict, __orig_dict, __orig_keys, key, value) def __setattr_impl__(self, item, value, check_lists=True): @@ -268,6 +277,36 @@ def __ior_impl__(self, other, check_lists=True, __update=dict.update): return self +def __from_json__(json_string=None, filename=None, + encoding='utf-8', errors='strict', + multiline=False, + file_decoder=json.load, + decoder=json.loads, + __object_hook=lambda d: DotWizPlus(d, check_types=False), + **decoder_kwargs): + """ + Helper function to create and return a :class:`DotWiz` (dot-access dict) + -- or a list of :class:`DotWiz` instances -- from a JSON string. + + """ + if filename: + with open(filename, encoding=encoding, errors=errors) as f: + if multiline: + return [ + decoder(line.strip(), object_hook=__object_hook, + **decoder_kwargs) + for line in f + if line.strip() and not line.strip().startswith('#') + ] + + else: + return file_decoder(f, object_hook=__object_hook, + **decoder_kwargs) + + return decoder(json_string, object_hook=__object_hook, + **decoder_kwargs) + + class DotWizPlus(metaclass=__add_common_methods__, print_char='✪', has_attr_dict=True): @@ -385,6 +424,8 @@ def __len__(self): __reversed__ = __reversed_impl__ + from_json = __from_json__ + def clear(self, __clear=dict.clear): __clear(self.__orig_dict__) __clear(self.__orig_keys__) diff --git a/dotwiz/plus.pyi b/dotwiz/plus.pyi index 2151975..309ad50 100644 --- a/dotwiz/plus.pyi +++ b/dotwiz/plus.pyi @@ -1,7 +1,7 @@ import json from os import PathLike from typing import ( - Callable, Protocol, TypeVar, + Callable, Protocol, TypeVar, Union, Iterable, Iterator, Reversible, KeysView, ItemsView, ValuesView, Mapping, MutableMapping, AnyStr, Any, @@ -68,6 +68,7 @@ def __store_in_object__(__self_dict: MutableMapping[_KT, _VT], def __upsert_into_dot_wiz_plus__(self: DotWizPlus, input_dict: MutableMapping[_KT, _VT] = {}, *, check_lists=True, + check_types=True, __skip_init=False, __set: _SetAttribute = object.__setattr__, **kwargs: _T) -> None: ... @@ -102,6 +103,17 @@ def __ior_impl__(self: DotWizPlus, *, check_lists=True, __update: _Update = dict.update): ... +def __from_json__(json_string: str = ..., *, + filename: str | PathLike = ..., + encoding: str = ..., + errors: str = ..., + multiline: bool = False, + file_decoder=json.load, + decoder=json.loads, + __object_hook = ..., + **decoder_kwargs + ) -> Union[DotWizPlus, list[DotWizPlus]]: ... + class DotWizPlus: @@ -113,6 +125,7 @@ class DotWizPlus: def __init__(self, input_dict: MutableMapping[_KT, _VT] = {}, *, check_lists=True, + check_types=True, __skip_init=False, **kwargs: _T) -> None: ... @@ -139,6 +152,23 @@ class DotWizPlus: def __ior__(self, other: DotWizPlus | dict) -> DotWizPlus: ... def __ror__(self, other: DotWizPlus | dict) -> DotWizPlus: ... + @classmethod + def from_json(cls, json_string: str = ..., *, + filename: str | PathLike = ..., + encoding: str = ..., + errors: str = ..., + multiline: bool = False, + file_decoder=json.load, + decoder=json.loads, + __object_hook=..., + **decoder_kwargs + ) -> Union[DotWizPlus, list[DotWizPlus]]: + """ + De-serialize a JSON string into a :class:`DotWizPlus` instance, or a + list of :class:`DotWizPlus` instances. + """ + ... + def to_dict(self, *, snake=False) -> dict[_KT, _VT]: """ Recursively convert the :class:`DotWizPlus` instance back to a ``dict``. @@ -223,6 +253,7 @@ class DotWizPlus: def update(self, __m: MutableMapping[_KT, _VT] = {}, *, check_lists=True, + check_types=True, __skip_init=False, **kwargs: _T) -> None: ... diff --git a/requirements-dev.txt b/requirements-dev.txt index cf627cd..892b257 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -10,6 +10,7 @@ twine>=3.8.0,<4 codecov==2.1.12 coverage>=6.2 pytest>=7.0.1,<8 +pytest-mock==3.8.2 pytest-benchmark[histogram]==3.4.1 pytest-cov==3.0.0 dataclass-wizard==0.22.1 # for loading dict to nested dataclass diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py new file mode 100644 index 0000000..e4f42c6 --- /dev/null +++ b/tests/unit/conftest.py @@ -0,0 +1,26 @@ +from unittest.mock import MagicMock, mock_open + +import pytest +from pytest_mock import MockerFixture + + +class FileMock(MagicMock): + + def __init__(self, mocker: MagicMock): + super().__init__() + + self.__dict__ = mocker.__dict__ + + @property + def read_data(self): + return self.side_effect + + @read_data.setter + def read_data(self, mock_data: str): + """set mock data to be returned when `open(...).read()` is called.""" + self.side_effect = mock_open(read_data=mock_data) + + +@pytest.fixture +def mock_file_open(mocker: MockerFixture) -> FileMock: + return FileMock(mocker.patch('builtins.open')) diff --git a/tests/unit/test_dotwiz.py b/tests/unit/test_dotwiz.py index 1f4e8fe..9045a8a 100644 --- a/tests/unit/test_dotwiz.py +++ b/tests/unit/test_dotwiz.py @@ -448,6 +448,74 @@ def test_with_default(self, data, key, default): assert dw[key] == default +def test_from_json(): + """Confirm intended functionality of `DotWiz.from_json`""" + + dw = DotWiz.from_json(""" + { + "key": {"nested": "value"}, + "second-key": [3, {"nestedKey": true}] + } + """) + + assert dw == DotWiz( + { + 'key': {'nested': 'value'}, + 'second-key': [3, {'nestedKey': True}] + } + ) + + assert dw['second-key'][1].nestedKey + + +def test_from_json_with_filename(mock_file_open): + """ + Confirm intended functionality of `DotWiz.from_json` when `filename` + is passed. + """ + + file_contents = """ + { + "key": {"nested": "value"}, + "second-key": [3, {"nestedKey": true}] + } + """ + + mock_file_open.read_data = file_contents + + dw = DotWiz.from_json(filename='test.json') + + assert dw == DotWiz( + { + 'key': {'nested': 'value'}, + 'second-key': [3, {'nestedKey': True}] + } + ) + + assert dw['second-key'][1].nestedKey + + +def test_from_json_with_multiline(mock_file_open): + """ + Confirm intended functionality of `DotWiz.from_json` when `filename` + is passed, and `multiline` is enabled. + """ + + file_contents = """ + {"key": {"nested": "value"}} + {"second-key": [3, {"nestedKey": true}]} + """ + + mock_file_open.read_data = file_contents + + dw_list = DotWiz.from_json(filename='test.json', multiline=True) + + assert dw_list == [DotWiz(key={'nested': 'value'}), + DotWiz({'second-key': [3, {'nestedKey': True}]})] + + assert dw_list[1]['second-key'][1].nestedKey + + def test_to_dict(): """Confirm intended functionality of `DotWiz.to_dict`""" dw = DotWiz(hello=[{"key": "value", "another-key": {"a": "b"}}]) diff --git a/tests/unit/test_dotwiz_plus.py b/tests/unit/test_dotwiz_plus.py index 7efa306..a2eff91 100644 --- a/tests/unit/test_dotwiz_plus.py +++ b/tests/unit/test_dotwiz_plus.py @@ -168,6 +168,74 @@ def test_dotwiz_plus_update_with_no_args(): assert dd.a == 2 +def test_from_json(): + """Confirm intended functionality of `DotWizPlus.from_json`""" + + dw = DotWizPlus.from_json(""" + { + "key": {"nested": "value"}, + "second-key": [3, {"nestedKey": true}] + } + """) + + assert dw == DotWizPlus( + { + 'key': {'nested': 'value'}, + 'second-key': [3, {'nestedKey': True}] + } + ) + + assert dw.second_key[1].nested_key + + +def test_from_json_with_filename(mock_file_open): + """ + Confirm intended functionality of `DotWizPlus.from_json` when `filename` + is passed. + """ + + file_contents = """ + { + "key": {"nested": "value"}, + "second-key": [3, {"nestedKey": true}] + } + """ + + mock_file_open.read_data = file_contents + + dw = DotWizPlus.from_json(filename='test.json') + + assert dw == DotWizPlus( + { + 'key': {'nested': 'value'}, + 'second-key': [3, {'nestedKey': True}] + } + ) + + assert dw.second_key[1].nested_key + + +def test_from_json_with_multiline(mock_file_open): + """ + Confirm intended functionality of `DotWizPlus.from_json` when `filename` + is passed, and `multiline` is enabled. + """ + + file_contents = """ + {"key": {"nested": "value"}} + {"second-key": [3, {"nestedKey": true}]} + """ + + mock_file_open.read_data = file_contents + + dw_list = DotWizPlus.from_json(filename='test.json', multiline=True) + + assert dw_list == [DotWizPlus(key={'nested': 'value'}), + DotWizPlus({'second-key': [3, {'nestedKey': True}]})] + + assert dw_list[1].second_key[1].nested_key + + def test_dotwiz_plus_to_dict(): """Confirm intended functionality of `DotWizPlus.to_dict`""" dw = DotWizPlus(hello=[{"Key": "value", "Another-KEY": {"a": "b"}}], From df9fa4b300ea28d9a1eb28feaa96709b5069593d Mon Sep 17 00:00:00 2001 From: Ritvik Nag Date: Thu, 28 Jul 2022 22:56:25 -0400 Subject: [PATCH 38/55] refactor `from_json` implementation --- dotwiz/common.py | 67 +++++++++++++++++++++++++++++++++++++++++++++--- dotwiz/main.py | 32 ----------------------- dotwiz/main.pyi | 29 +++++++++++---------- dotwiz/plus.py | 32 ----------------------- dotwiz/plus.pyi | 29 +++++++++++---------- 5 files changed, 93 insertions(+), 96 deletions(-) diff --git a/dotwiz/common.py b/dotwiz/common.py index 0eddba2..7e29087 100644 --- a/dotwiz/common.py +++ b/dotwiz/common.py @@ -2,6 +2,7 @@ Common (shared) helpers and utilities. """ import json +from typing import Callable from dotwiz.encoders import DotWizEncoder, DotWizPlusEncoder @@ -23,9 +24,60 @@ def __repr__(self: object): cls_dict['__repr__'] = __repr__ # add utility or helper methods to the class, such as: - # - `to_dict` - convert an instance to a Python `dict` object. - # - `to_json` - serialize an instance as a JSON string. - # - `to_attr_dict` - optional, only if `has_attr_dict` is specified. + # - `from_json` - de-serialize a JSON string into an instance. + # - `to_dict` - convert an instance to a Python `dict` object. + # - `to_json` - serialize an instance as a JSON string. + # - `to_attr_dict` - optional, only if `has_attr_dict` is specified. + + cls: type + __object_hook: Callable + + def __from_json__(json_string=None, filename=None, + encoding='utf-8', errors='strict', + multiline=False, + file_decoder=json.load, + decoder=json.loads, + **decoder_kwargs): + """ + De-serialize a JSON string (or file) as a `DotWiz` or `DotWizPlus` + instance. + """ + if filename: + with open(filename, encoding=encoding, errors=errors) as f: + if multiline: + return [ + decoder(line.strip(), object_hook=__object_hook, + **decoder_kwargs) + for line in f + if line.strip() and not line.strip().startswith('#') + ] + + else: + return file_decoder(f, object_hook=__object_hook, + **decoder_kwargs) + + return decoder(json_string, object_hook=__object_hook, + **decoder_kwargs) + + # add a `from_json` method to the class. + cls_dict['from_json'] = __from_json__ + __from_json__.__doc__ = f""" +De-serialize a JSON string (or file) into a :class:`{name}` instance, +or a list of :class:`{name}` instances. + +:param json_string: The JSON string to de-serialize. +:param filename: If provided, will instead read from a file. +:param encoding: File encoding. +:param errors: How to handle encoding errors. +:param multiline: If enabled, reads the file in JSONL format, + i.e. where each line in the file represents a JSON object. +:param file_decoder: The decoder to use, when `filename` is passed. +:param decoder: The decoder to de-serialize with, defaults + to `json.loads`. +:param decoder_kwargs: The keyword arguments to pass in to the decoder. + +:return: a `{name}` instance, or a list of `{name}` instances. +""" def __convert_to_dict__(o): """ @@ -45,6 +97,9 @@ def __convert_to_dict__(o): # we need to add both `to_dict` and `to_attr_dict` in this case. if has_attr_dict: + def __object_hook(d): + return cls(d, check_types=False) + def __convert_to_dict_snake_cased__(o): """ Recursively convert an object (typically a custom `dict` type) to @@ -152,6 +207,9 @@ def to_json(o, attr=False, snake=False, # we only need to add a `to_dict` method in this case. else: + def __object_hook(d): + return cls(d, __set_dict=True) + def to_json(o, filename=None, encoding='utf-8', errors='strict', file_encoder=json.dump, encoder=json.dumps, **encoder_kwargs): @@ -188,7 +246,8 @@ def to_json(o, filename=None, encoding='utf-8', errors='strict', ) # finally, build and return the new class. - return type(name, bases, cls_dict) + cls = type(name, bases, cls_dict) + return cls def __resolve_value__(value, dict_type, check_lists=True): diff --git a/dotwiz/main.py b/dotwiz/main.py index 58a31f5..ba9ab64 100644 --- a/dotwiz/main.py +++ b/dotwiz/main.py @@ -140,36 +140,6 @@ def __ior_impl__(self, other, check_lists=True, __update=dict.update): return self -def __from_json__(json_string=None, filename=None, - encoding='utf-8', errors='strict', - multiline=False, - file_decoder=json.load, - decoder=json.loads, - __object_hook=lambda d: DotWiz(d, __set_dict=True), - **decoder_kwargs): - """ - Helper function to create and return a :class:`DotWiz` (dot-access dict) - -- or a list of :class:`DotWiz` instances -- from a JSON string. - - """ - if filename: - with open(filename, encoding=encoding, errors=errors) as f: - if multiline: - return [ - decoder(line.strip(), object_hook=__object_hook, - **decoder_kwargs) - for line in f - if line.strip() and not line.strip().startswith('#') - ] - - else: - return file_decoder(f, object_hook=__object_hook, - **decoder_kwargs) - - return decoder(json_string, object_hook=__object_hook, - **decoder_kwargs) - - class DotWiz(metaclass=__add_common_methods__, print_char='✫'): """ @@ -239,8 +209,6 @@ def __len__(self): __reversed__ = __reversed_impl__ - from_json = __from_json__ - def clear(self): return self.__dict__.clear() diff --git a/dotwiz/main.pyi b/dotwiz/main.pyi index 3ca788f..e272e18 100644 --- a/dotwiz/main.pyi +++ b/dotwiz/main.pyi @@ -79,17 +79,6 @@ def __ior_impl__(self: DotWiz, *, check_lists=True, __update: _Update = dict.update): ... -def __from_json__(json_string: str = ..., *, - filename: str | PathLike = ..., - encoding: str = ..., - errors: str = ..., - multiline: bool = False, - file_decoder=json.load, - decoder=json.loads, - __object_hook = ..., - **decoder_kwargs - ) -> Union[DotWiz, list[DotWiz]]: ... - class DotWiz: @@ -132,12 +121,24 @@ class DotWiz: multiline: bool = False, file_decoder=json.load, decoder=json.loads, - __object_hook=..., **decoder_kwargs ) -> Union[DotWiz, list[DotWiz]]: """ - De-serialize a JSON string into a :class:`DotWiz` instance, or a list - of :class:`DotWiz` instances. + De-serialize a JSON string (or file) into a :class:`DotWiz` instance, + or a list of :class:`DotWiz` instances. + + :param json_string: The JSON string to de-serialize. + :param filename: If provided, will instead read from a file. + :param encoding: File encoding. + :param errors: How to handle encoding errors. + :param multiline: If enabled, reads the file in JSONL format, + i.e. where each line in the file represents a JSON object. + :param file_decoder: The decoder to use, when `filename` is passed. + :param decoder: The decoder to de-serialize with, defaults + to `json.loads`. + :param decoder_kwargs: The keyword arguments to pass in to the decoder. + + :return: a `DotWiz` instance, or a list of `DotWiz` instances. """ ... diff --git a/dotwiz/plus.py b/dotwiz/plus.py index 3b1c266..21f4d8c 100644 --- a/dotwiz/plus.py +++ b/dotwiz/plus.py @@ -277,36 +277,6 @@ def __ior_impl__(self, other, check_lists=True, __update=dict.update): return self -def __from_json__(json_string=None, filename=None, - encoding='utf-8', errors='strict', - multiline=False, - file_decoder=json.load, - decoder=json.loads, - __object_hook=lambda d: DotWizPlus(d, check_types=False), - **decoder_kwargs): - """ - Helper function to create and return a :class:`DotWiz` (dot-access dict) - -- or a list of :class:`DotWiz` instances -- from a JSON string. - - """ - if filename: - with open(filename, encoding=encoding, errors=errors) as f: - if multiline: - return [ - decoder(line.strip(), object_hook=__object_hook, - **decoder_kwargs) - for line in f - if line.strip() and not line.strip().startswith('#') - ] - - else: - return file_decoder(f, object_hook=__object_hook, - **decoder_kwargs) - - return decoder(json_string, object_hook=__object_hook, - **decoder_kwargs) - - class DotWizPlus(metaclass=__add_common_methods__, print_char='✪', has_attr_dict=True): @@ -426,8 +396,6 @@ def __len__(self): __reversed__ = __reversed_impl__ - from_json = __from_json__ - def clear(self, __clear=dict.clear): __clear(self.__orig_dict__) __clear(self.__orig_keys__) diff --git a/dotwiz/plus.pyi b/dotwiz/plus.pyi index 309ad50..fe9615f 100644 --- a/dotwiz/plus.pyi +++ b/dotwiz/plus.pyi @@ -103,17 +103,6 @@ def __ior_impl__(self: DotWizPlus, *, check_lists=True, __update: _Update = dict.update): ... -def __from_json__(json_string: str = ..., *, - filename: str | PathLike = ..., - encoding: str = ..., - errors: str = ..., - multiline: bool = False, - file_decoder=json.load, - decoder=json.loads, - __object_hook = ..., - **decoder_kwargs - ) -> Union[DotWizPlus, list[DotWizPlus]]: ... - class DotWizPlus: @@ -160,12 +149,24 @@ class DotWizPlus: multiline: bool = False, file_decoder=json.load, decoder=json.loads, - __object_hook=..., **decoder_kwargs ) -> Union[DotWizPlus, list[DotWizPlus]]: """ - De-serialize a JSON string into a :class:`DotWizPlus` instance, or a - list of :class:`DotWizPlus` instances. + De-serialize a JSON string (or file) into a :class:`DotWizPlus` + instance, or a list of :class:`DotWizPlus` instances. + + :param json_string: The JSON string to de-serialize. + :param filename: If provided, will instead read from a file. + :param encoding: File encoding. + :param errors: How to handle encoding errors. + :param multiline: If enabled, reads the file in JSONL format, + i.e. where each line in the file represents a JSON object. + :param file_decoder: The decoder to use, when `filename` is passed. + :param decoder: The decoder to de-serialize with, defaults + to `json.loads`. + :param decoder_kwargs: The keyword arguments to pass in to the decoder. + + :return: a `DotWizPlus` instance, or a list of `DotWizPlus` instances. """ ... From 297b2e9f3ada72d3a1e2270de6757cfbaa532253 Mon Sep 17 00:00:00 2001 From: Ritvik Nag Date: Thu, 28 Jul 2022 23:01:40 -0400 Subject: [PATCH 39/55] rename var --- dotwiz/common.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/dotwiz/common.py b/dotwiz/common.py index 7e29087..3f4f8cc 100644 --- a/dotwiz/common.py +++ b/dotwiz/common.py @@ -30,7 +30,7 @@ def __repr__(self: object): # - `to_attr_dict` - optional, only if `has_attr_dict` is specified. cls: type - __object_hook: Callable + __object_hook__: Callable def __from_json__(json_string=None, filename=None, encoding='utf-8', errors='strict', @@ -46,17 +46,17 @@ def __from_json__(json_string=None, filename=None, with open(filename, encoding=encoding, errors=errors) as f: if multiline: return [ - decoder(line.strip(), object_hook=__object_hook, + decoder(line.strip(), object_hook=__object_hook__, **decoder_kwargs) for line in f if line.strip() and not line.strip().startswith('#') ] else: - return file_decoder(f, object_hook=__object_hook, + return file_decoder(f, object_hook=__object_hook__, **decoder_kwargs) - return decoder(json_string, object_hook=__object_hook, + return decoder(json_string, object_hook=__object_hook__, **decoder_kwargs) # add a `from_json` method to the class. @@ -97,7 +97,7 @@ def __convert_to_dict__(o): # we need to add both `to_dict` and `to_attr_dict` in this case. if has_attr_dict: - def __object_hook(d): + def __object_hook__(d): return cls(d, check_types=False) def __convert_to_dict_snake_cased__(o): @@ -207,7 +207,7 @@ def to_json(o, attr=False, snake=False, # we only need to add a `to_dict` method in this case. else: - def __object_hook(d): + def __object_hook__(d): return cls(d, __set_dict=True) def to_json(o, filename=None, encoding='utf-8', errors='strict', From 11d046d28018c0627d728fb97531276908f6bc90 Mon Sep 17 00:00:00 2001 From: Ritvik Nag Date: Fri, 29 Jul 2022 00:03:02 -0400 Subject: [PATCH 40/55] minor refactor --- dotwiz/common.py | 14 +++++++++----- dotwiz/common.pyi | 5 ++++- dotwiz/main.py | 8 ++++---- dotwiz/main.pyi | 4 ---- dotwiz/plus.py | 42 +++++++++++++++++++++--------------------- dotwiz/plus.pyi | 9 +++------ 6 files changed, 41 insertions(+), 41 deletions(-) diff --git a/dotwiz/common.py b/dotwiz/common.py index 3f4f8cc..69ccc44 100644 --- a/dotwiz/common.py +++ b/dotwiz/common.py @@ -7,6 +7,10 @@ from dotwiz.encoders import DotWizEncoder, DotWizPlusEncoder +# noinspection PyTypeChecker +__set__ = object.__setattr__ + + def __add_common_methods__(name, bases, cls_dict, *, print_char='*', has_attr_dict=False): @@ -29,7 +33,7 @@ def __repr__(self: object): # - `to_json` - serialize an instance as a JSON string. # - `to_attr_dict` - optional, only if `has_attr_dict` is specified. - cls: type + __cls__: type __object_hook__: Callable def __from_json__(json_string=None, filename=None, @@ -98,7 +102,7 @@ def __convert_to_dict__(o): if has_attr_dict: def __object_hook__(d): - return cls(d, check_types=False) + return __cls__(d, check_types=False) def __convert_to_dict_snake_cased__(o): """ @@ -208,7 +212,7 @@ def to_json(o, attr=False, snake=False, else: def __object_hook__(d): - return cls(d, __set_dict=True) + return __cls__(d, __set_dict=True) def to_json(o, filename=None, encoding='utf-8', errors='strict', file_encoder=json.dump, @@ -246,8 +250,8 @@ def to_json(o, filename=None, encoding='utf-8', errors='strict', ) # finally, build and return the new class. - cls = type(name, bases, cls_dict) - return cls + __cls__ = type(name, bases, cls_dict) + return __cls__ def __resolve_value__(value, dict_type, check_lists=True): diff --git a/dotwiz/common.pyi b/dotwiz/common.pyi index 0407cb0..f1ea106 100644 --- a/dotwiz/common.pyi +++ b/dotwiz/common.pyi @@ -1,4 +1,4 @@ -from typing import Any, Callable, ItemsView, TypeVar +from typing import Any, Callable, ItemsView, TypeVar, Union from dotwiz import DotWiz, DotWizPlus @@ -9,6 +9,9 @@ _KT = TypeVar('_KT') _VT = TypeVar('_VT') _ItemsFn = Callable[[_D ], ItemsView[_KT, _VT]] +_SetAttribute = Callable[[Union[DotWiz, DotWizPlus], str, Any], None] + +__set__: _SetAttribute def __add_common_methods__(name: str, diff --git a/dotwiz/main.py b/dotwiz/main.py index ba9ab64..7cb1c4e 100644 --- a/dotwiz/main.py +++ b/dotwiz/main.py @@ -1,8 +1,9 @@ """Main module.""" -import json from .common import ( - __resolve_value__, __add_common_methods__, + __add_common_methods__, + __resolve_value__, + __set__, ) from .constants import __PY_38_OR_ABOVE__, __PY_39_OR_ABOVE__ @@ -27,7 +28,6 @@ def make_dot_wiz(*args, **kwargs): # noinspection PyDefaultArgument def __upsert_into_dot_wiz__(self, input_dict={}, check_lists=True, - __set=object.__setattr__, __set_dict=False, **kwargs): """ @@ -36,7 +36,7 @@ def __upsert_into_dot_wiz__(self, input_dict={}, """ if __set_dict: - __set(self, '__dict__', input_dict) + __set__(self, '__dict__', input_dict) return None __dict = self.__dict__ diff --git a/dotwiz/main.pyi b/dotwiz/main.pyi index e272e18..0e8b56f 100644 --- a/dotwiz/main.pyi +++ b/dotwiz/main.pyi @@ -17,7 +17,6 @@ _JSONList = list[Any] _JSONObject = dict[str, Any] _Copy = Callable[[dict[_KT, _VT]], dict[_KT, _VT]] -_SetAttribute = Callable[[DotWiz, str, Any], None] class Encoder(Protocol): @@ -50,7 +49,6 @@ def make_dot_wiz(*args: Iterable[_KT, _VT], def __upsert_into_dot_wiz__(self: DotWiz, input_dict: MutableMapping[_KT, _VT] = {}, *, check_lists=True, - __set: _SetAttribute = object.__setattr__, __set_dict=False, **kwargs: _T) -> None: ... @@ -86,7 +84,6 @@ class DotWiz: def __init__(self, input_dict: MutableMapping[_KT, _VT] = {}, *, check_lists=True, - __set: _SetAttribute = object.__setattr__, __set_dict=False, **kwargs: _T) -> None: ... @@ -208,7 +205,6 @@ class DotWiz: def update(self, __m: MutableMapping[_KT, _VT] = {}, *, check_lists=True, - __set: _SetAttribute = object.__setattr__, __set_dict=False, **kwargs: _T) -> None: ... diff --git a/dotwiz/plus.py b/dotwiz/plus.py index 21f4d8c..bb26aae 100644 --- a/dotwiz/plus.py +++ b/dotwiz/plus.py @@ -1,12 +1,13 @@ """Dot Wiz Plus module.""" import itertools -import json import keyword from pyheck import snake from .common import ( - __resolve_value__, __add_common_methods__, + __add_common_methods__, + __resolve_value__, + __set__, ) from .constants import __PY_38_OR_ABOVE__, __PY_39_OR_ABOVE__ @@ -104,7 +105,6 @@ def __upsert_into_dot_wiz_plus__(self, input_dict={}, check_lists=True, check_types=True, __skip_init=False, - __set=object.__setattr__, **kwargs): """ Helper method to generate / update a :class:`DotWizPlus` (dot-access dict) @@ -124,11 +124,11 @@ def __upsert_into_dot_wiz_plus__(self, input_dict={}, # create the instance attribute `__orig_dict__` __orig_dict = {} - __set(self, '__orig_dict__', __orig_dict) + __set__(self, '__orig_dict__', __orig_dict) # create the instance attribute `__orig_keys__` __orig_keys = {} - __set(self, '__orig_keys__', __orig_keys) + __set__(self, '__orig_keys__', __orig_keys) if check_types: @@ -187,7 +187,7 @@ def __reversed_impl__(self): if __PY_39_OR_ABOVE__: # Python >= 3.9, pragma: no cover - def __merge_impl_fn__(op, check_lists=True, __set=object.__setattr__): + def __merge_impl_fn__(op, check_lists=True): """Implementation of `__or__` and `__ror__`, to merge `DotWizPlus` and `dict` objects.""" def __merge_impl__(self, other): @@ -202,9 +202,9 @@ def __merge_impl__(self, other): __merged_orig_keys = op(self.__orig_keys__, other.__orig_keys__) __merged = DotWizPlus(__skip_init=True) - __set(__merged, '__dict__', __merged_dict) - __set(__merged, '__orig_dict__', __merged_orig_dict) - __set(__merged, '__orig_keys__', __merged_orig_keys) + __set__(__merged, '__dict__', __merged_dict) + __set__(__merged, '__orig_dict__', __merged_orig_dict) + __set__(__merged, '__orig_keys__', __merged_orig_keys) return __merged @@ -217,7 +217,7 @@ def __merge_impl__(self, other): # Note: this is *before* Union operators were introduced to `dict`, # in https://peps.python.org/pep-0584/ - def __or_impl__(self, other, check_lists=True, __set=object.__setattr__): + def __or_impl__(self, other, check_lists=True): """Implementation of `__or__` to merge `DotWizPlus` and `dict` objects.""" __other_dict = getattr(other, '__dict__', None) @@ -230,13 +230,13 @@ def __or_impl__(self, other, check_lists=True, __set=object.__setattr__): __merged_orig_keys = {**self.__orig_keys__, **other.__orig_keys__} __merged = DotWizPlus(__skip_init=True) - __set(__merged, '__dict__', __merged_dict) - __set(__merged, '__orig_dict__', __merged_orig_dict) - __set(__merged, '__orig_keys__', __merged_orig_keys) + __set__(__merged, '__dict__', __merged_dict) + __set__(__merged, '__orig_dict__', __merged_orig_dict) + __set__(__merged, '__orig_keys__', __merged_orig_keys) return __merged - def __ror_impl__(self, other, check_lists=True, __set=object.__setattr__): + def __ror_impl__(self, other, check_lists=True): """Implementation of `__ror__` to merge `DotWizPlus` and `dict` objects.""" __other_dict = getattr(other, '__dict__', None) @@ -249,9 +249,9 @@ def __ror_impl__(self, other, check_lists=True, __set=object.__setattr__): __merged_orig_keys = {**other.__orig_keys__, **self.__orig_keys__} __merged = DotWizPlus(__skip_init=True) - __set(__merged, '__dict__', __merged_dict) - __set(__merged, '__orig_dict__', __merged_orig_dict) - __set(__merged, '__orig_keys__', __merged_orig_keys) + __set__(__merged, '__dict__', __merged_dict) + __set__(__merged, '__orig_dict__', __merged_orig_dict) + __set__(__merged, '__orig_keys__', __merged_orig_keys) return __merged @@ -402,16 +402,16 @@ def clear(self, __clear=dict.clear): return __clear(self.__dict__) - def copy(self, __copy=dict.copy, __set=object.__setattr__): + def copy(self, __copy=dict.copy): """ Returns a shallow copy of the `dict` wrapped in :class:`DotWizPlus`. :return: DotWizPlus instance """ dw = DotWizPlus(__skip_init=True) - __set(dw, '__dict__', __copy(self.__dict__)) - __set(dw, '__orig_dict__', __copy(self.__orig_dict__)) - __set(dw, '__orig_keys__', __copy(self.__orig_keys__)) + __set__(dw, '__dict__', __copy(self.__dict__)) + __set__(dw, '__orig_dict__', __copy(self.__orig_dict__)) + __set__(dw, '__orig_keys__', __copy(self.__orig_keys__)) return dw diff --git a/dotwiz/plus.pyi b/dotwiz/plus.pyi index fe9615f..6b3f269 100644 --- a/dotwiz/plus.pyi +++ b/dotwiz/plus.pyi @@ -18,7 +18,6 @@ _JSONObject = dict[str, Any] _Clear = Callable[[dict[_KT, _VT]], None] _Copy = Callable[[dict[_KT, _VT]], dict[_KT, _VT]] -_SetAttribute = Callable[[DotWizPlus, str, Any], None] class Encoder(Protocol): @@ -70,7 +69,6 @@ def __upsert_into_dot_wiz_plus__(self: DotWizPlus, *, check_lists=True, check_types=True, __skip_init=False, - __set: _SetAttribute = object.__setattr__, **kwargs: _T) -> None: ... def __setattr_impl__(self: DotWizPlus, @@ -85,18 +83,17 @@ def __setitem_impl__(self: DotWizPlus, def __merge_impl_fn__(op: Callable[[dict, dict], dict], *, check_lists=True, - __set: _SetAttribute = object.__setattr__ ) -> Callable[[DotWizPlus, DotWizPlus | dict], DotWizPlus]: ... def __or_impl__(self: DotWizPlus, other: DotWizPlus | dict, *, check_lists=True, - __set: _SetAttribute = object.__setattr__) -> DotWizPlus: ... + ) -> DotWizPlus: ... def __ror_impl__(self: DotWizPlus, other: DotWizPlus | dict, *, check_lists=True, - __set: _SetAttribute = object.__setattr__) -> DotWizPlus: ... + ) -> DotWizPlus: ... def __ior_impl__(self: DotWizPlus, other: DotWizPlus | dict, @@ -218,7 +215,7 @@ class DotWizPlus: def copy(self, *, __copy: _Copy = dict.copy, - __set: _SetAttribute = object.__setattr__) -> DotWizPlus: ... + ) -> DotWizPlus: ... # noinspection PyUnresolvedReferences @classmethod From 378e3ff23171ec9ec83b48d336abc99118df3c24 Mon Sep 17 00:00:00 2001 From: Ritvik Nag Date: Fri, 29 Jul 2022 09:23:49 -0400 Subject: [PATCH 41/55] minor refactor --- dotwiz/common.py | 2 +- dotwiz/plus.py | 15 +++++++-------- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/dotwiz/common.py b/dotwiz/common.py index 69ccc44..0165e06 100644 --- a/dotwiz/common.py +++ b/dotwiz/common.py @@ -4,7 +4,7 @@ import json from typing import Callable -from dotwiz.encoders import DotWizEncoder, DotWizPlusEncoder +from .encoders import DotWizEncoder, DotWizPlusEncoder # noinspection PyTypeChecker diff --git a/dotwiz/plus.py b/dotwiz/plus.py index bb26aae..5dc9dcd 100644 --- a/dotwiz/plus.py +++ b/dotwiz/plus.py @@ -64,7 +64,7 @@ def __store_in_object__(__self_dict, __self_orig_dict, __self_orig_keys, # method name such as `items`, add an underscore to key so that # attribute access can then work. if __IS_KEYWORD(lower_key): - __SPECIAL_KEYS[orig_key] = key = f'{lower_key}_' + key = __SPECIAL_KEYS[orig_key] = f'{lower_key}_' __self_orig_keys[key] = orig_key # handle special cases: if the key is not lowercase, or it's not a @@ -74,25 +74,24 @@ def __store_in_object__(__self_dict, __self_orig_dict, __self_orig_keys, elif not key == lower_key or not key.isidentifier(): # transform key to `snake case` and cache the result. - lower_snake = snake(key) + key = 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('__', '_') + if ch in key: + key = key.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:]}' + if key[0].isdigit(): # the key has a leading digit, which is invalid. + key = f'_{key}' - __SPECIAL_KEYS[orig_key] = key = lower_snake + __SPECIAL_KEYS[orig_key] = key __self_orig_keys[key] = orig_key # note: this logic is the same as `DotWizPlus.__setitem__()` From 4ec939395de01d2c12c32b1ec7ce8439c5a003b2 Mon Sep 17 00:00:00 2001 From: Ritvik Nag Date: Fri, 29 Jul 2022 12:02:20 -0400 Subject: [PATCH 42/55] add docs on type hinting and auto-completion --- README.rst | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/README.rst b/README.rst index da2d919..a2ce8cc 100644 --- a/README.rst +++ b/README.rst @@ -172,6 +172,54 @@ as compared to other libraries such as ``prodict`` -- or close to **15x** faster than creating a `Box`_ -- and up to *10x* faster in general to access keys by *dot* notation -- or almost **30x** faster than accessing keys from a `DotMap`_. +Type Hints and Auto Completion +------------------------------ + +For better code quality and to keep IDEs happy, it is possible to achieve auto-completion of key or attribute names, +as well as provide type hinting and auto-suggestion of ``str`` methods for example. + +The simplest way to do it, is to extend from ``DotWiz`` or ``DotWiz+`` and use type annotations, as below. + + Note that this approach does **not** perform auto type conversion, such as ``str`` to ``int``. + +.. code:: python3 + + from __future__ import annotations + + from dotwiz import DotWiz + + + class MyTypedWiz(DotWiz): + # add attribute names and annotations for better type hinting! + i: int + b: bool + nested: list[Nested] + + + class Nested: + s: str + + + dw = MyTypedWiz(i=42, b=False, f=3.21, nested=[{'s': 'Hello, world!!'}]) + print(dw) + # > ✫(i=42, b=False, f=3.21, nested=[✫(s='Hello world!!')]) + + # note that field (and method) auto-completion now works as expected! + assert dw.nested[0].s.lower().rstrip('!') == 'hello, world' + + # we can still access non-declared fields, however auto-completion and type + # hinting won't work as desired. + assert dw.f == 3.21 + + print('\nPrettified JSON string:') + print(dw.to_json(indent=2)) + # prints: + # { + # "i": 42, + # "b": false, + # ... + # } + Contributing ------------ From 957e414cda5453338db636af0a96958a3fb048f3 Mon Sep 17 00:00:00 2001 From: Ritvik Nag Date: Fri, 29 Jul 2022 12:11:56 -0400 Subject: [PATCH 43/55] enhance docs on usage --- docs/usage.rst | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/usage.rst b/docs/usage.rst index 103930d..a3fcda1 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -42,12 +42,18 @@ are made safe for attribute access: # the original keys can also be accessed like a normal `dict`, if needed assert dw['items']['To']['1NFINITY']['AND']['Beyond !! '] - print(dw.to_dict()) + print('to_dict() ->', dw.to_dict()) # > {'items': {'camelCase': 1, 'TitleCase': 2, ...}} - print(dw.to_attr_dict()) + print('to_attr_dict() ->', dw.to_attr_dict()) # > {'items_': {'camel_case': 1, 'title_case': 2, ...}} + # get a JSON string representation with snake-cased keys, which strips out + # underscores from the ends, such as for `and_` or `_42`. + + print('to_json(snake=True) ->', dw.to_json(snake=True)) + # > {"items": {"camel_case": 1, "title_case": 2, ...}} + Complete Example ~~~~~~~~~~~~~~~~ From efd913f47a366b55bbedeca5250e16c9a156f80d Mon Sep 17 00:00:00 2001 From: Ritvik Nag Date: Fri, 9 Sep 2022 17:01:47 -0400 Subject: [PATCH 44/55] add docs on DotWiz.__init__ --- dotwiz/main.py | 10 ++++++++++ dotwiz/main.pyi | 16 +++++++++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/dotwiz/main.py b/dotwiz/main.py index 7cb1c4e..4b81d7a 100644 --- a/dotwiz/main.py +++ b/dotwiz/main.py @@ -34,6 +34,16 @@ def __upsert_into_dot_wiz__(self, input_dict={}, Helper method to generate / update a :class:`DotWiz` (dot-access dict) from a Python ``dict`` object, and optional *keyword arguments*. + :param input_dict: Input `dict` object to process the key-value pairs of. + :param check_lists: False to not check for nested `list` values. Defaults + to True. + :param __set_dict: True to use `input_dict` as is, and skip the bulk of + the initialization logic, such as iterating over the key-value pairs. + This is a huge performance improvement, if we know an input `dict` + only contains simple values, and no nested `dict` or `list` values. + :param kwargs: Additional keyword arguments to process, in addition to + `input_dict`. + """ if __set_dict: __set__(self, '__dict__', input_dict) diff --git a/dotwiz/main.pyi b/dotwiz/main.pyi index 0e8b56f..7c3e14f 100644 --- a/dotwiz/main.pyi +++ b/dotwiz/main.pyi @@ -85,7 +85,21 @@ class DotWiz: input_dict: MutableMapping[_KT, _VT] = {}, *, check_lists=True, __set_dict=False, - **kwargs: _T) -> None: ... + **kwargs: _T) -> None: + """Create a new :class:`DotWiz` instance. + + :param input_dict: Input `dict` object to process the key-value pairs of. + :param check_lists: False to not check for nested `list` values. Defaults + to True. + :param __set_dict: True to use `input_dict` as is, and skip the bulk of + the initialization logic, such as iterating over the key-value pairs. + This is a huge performance improvement, if we know an input `dict` + only contains simple values, and no nested `dict` or `list` values. + :param kwargs: Additional keyword arguments to process, in addition to + `input_dict`. + + """ + ... def __bool__(self) -> bool: ... def __contains__(self, item: _KT) -> bool: ... From 78e3e1ec5b7be3095b3d28813d60611a01af8ff9 Mon Sep 17 00:00:00 2001 From: Ritvik Nag Date: Fri, 9 Sep 2022 17:05:52 -0400 Subject: [PATCH 45/55] add docs on how to add type hinting --- README.rst | 42 ++------------------------------------ docs/usage.rst | 55 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 40 deletions(-) diff --git a/README.rst b/README.rst index a2ce8cc..62bf96b 100644 --- a/README.rst +++ b/README.rst @@ -178,47 +178,9 @@ Type Hints and Auto Completion For better code quality and to keep IDEs happy, it is possible to achieve auto-completion of key or attribute names, as well as provide type hinting and auto-suggestion of ``str`` methods for example. -The simplest way to do it, is to extend from ``DotWiz`` or ``DotWiz+`` and use type annotations, as below. +Check out the `Usage`_ section in the docs for more details. - Note that this approach does **not** perform auto type conversion, such as ``str`` to ``int``. - -.. code:: python3 - - from __future__ import annotations - - from dotwiz import DotWiz - - - class MyTypedWiz(DotWiz): - # add attribute names and annotations for better type hinting! - i: int - b: bool - nested: list[Nested] - - - class Nested: - s: str - - - dw = MyTypedWiz(i=42, b=False, f=3.21, nested=[{'s': 'Hello, world!!'}]) - print(dw) - # > ✫(i=42, b=False, f=3.21, nested=[✫(s='Hello world!!')]) - - # note that field (and method) auto-completion now works as expected! - assert dw.nested[0].s.lower().rstrip('!') == 'hello, world' - - # we can still access non-declared fields, however auto-completion and type - # hinting won't work as desired. - assert dw.f == 3.21 - - print('\nPrettified JSON string:') - print(dw.to_json(indent=2)) - # prints: - # { - # "i": 42, - # "b": false, - # ... - # } +.. _Usage: https://dotwiz.readthedocs.io/en/latest/usage.html#type-hints-and-auto-completion Contributing ------------ diff --git a/docs/usage.rst b/docs/usage.rst index a3fcda1..bb54096 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -100,3 +100,58 @@ mutates keys with invalid characters to a safe, *snake-cased* format: assert dw._99 == dw._1abc == dw.x_y == dw.this_i_s_a_test == dw.hello_w0rld == 3 assert dw.title_case == dw.screaming_snake_case == \ dw.camel_case == dw.pascal_case == dw.spinal_case == 4 + + +Type Hints and Auto Completion +------------------------------ + +For better code quality and to keep IDEs happy, it is possible to achieve auto-completion of key or attribute names, +as well as provide type hinting and auto-suggestion of ``str`` methods for example. + +The simplest way to do it, is to extend from ``DotWiz`` or ``DotWiz+`` and use type annotations, as below. + + Note that this approach does **not** perform auto type conversion, such as ``str`` to ``int``. + +.. code:: python3 + + from typing import TYPE_CHECKING + + from dotwiz import DotWiz + + + # create a simple alias. + MyTypedWiz = DotWiz + + + if TYPE_CHECKING: # this only runs for static type checkers. + + class MyTypedWiz(DotWiz): + # add attribute names and annotations for better type hinting! + i: int + b: bool + nested: list['Nested'] + + + class Nested: + s: str + + + dw = MyTypedWiz(i=42, b=False, f=3.21, nested=[{'s': 'Hello, world!!'}]) + print(dw) + # > ✫(i=42, b=False, f=3.21, nested=[✫(s='Hello world!!')]) + + # note that field (and method) auto-completion now works as expected! + assert dw.nested[0].s.lower().rstrip('!') == 'hello, world' + + # we can still access non-declared fields, however auto-completion and type + # hinting won't work as desired. + assert dw.f == 3.21 + + print('\nPrettified JSON string:') + print(dw.to_json(indent=2)) + # prints: + # { + # "i": 42, + # "b": false, + # ... + # } From 7037cdebdf80cb1bee5199e596e9d400b130e273 Mon Sep 17 00:00:00 2001 From: Ritvik Nag Date: Fri, 9 Sep 2022 17:15:49 -0400 Subject: [PATCH 46/55] add docs for DotWizPlus.__init__ --- dotwiz/plus.py | 13 +++++++++++++ dotwiz/plus.pyi | 19 ++++++++++++++++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/dotwiz/plus.py b/dotwiz/plus.py index 5dc9dcd..0751fd1 100644 --- a/dotwiz/plus.py +++ b/dotwiz/plus.py @@ -109,6 +109,19 @@ def __upsert_into_dot_wiz_plus__(self, input_dict={}, Helper method to generate / update a :class:`DotWizPlus` (dot-access dict) from a Python ``dict`` object, and optional *keyword arguments*. + :param input_dict: Input `dict` object to process the key-value pairs of. + :param check_lists: False to not check for nested `list` values. Defaults + to True. + :param check_types: False to not check for nested `dict` and `list` values. + This is a minor performance improvement, if we know an input `dict` only + contains simple values, and no nested `dict` or `list` values. + Defaults to True. + :param __skip_init: True to simply return, and skip the initialization + logic. This is useful to create an empty `DotWizPlus` instance. + Defaults to False. + :param kwargs: Additional keyword arguments to process, in addition to + `input_dict`. + """ if __skip_init: return None diff --git a/dotwiz/plus.pyi b/dotwiz/plus.pyi index 6b3f269..d102b33 100644 --- a/dotwiz/plus.pyi +++ b/dotwiz/plus.pyi @@ -113,7 +113,24 @@ class DotWizPlus: *, check_lists=True, check_types=True, __skip_init=False, - **kwargs: _T) -> None: ... + **kwargs: _T) -> None: + """Create a new :class:`DotWizPlus` instance. + + :param input_dict: Input `dict` object to process the key-value pairs of. + :param check_lists: False to not check for nested `list` values. Defaults + to True. + :param check_types: False to not check for nested `dict` and `list` values. + This is a minor performance improvement, if we know an input `dict` only + contains simple values, and no nested `dict` or `list` values. + Defaults to True. + :param __skip_init: True to simply return, and skip the initialization + logic. This is useful to create an empty `DotWizPlus` instance. + Defaults to False. + :param kwargs: Additional keyword arguments to process, in addition to + `input_dict`. + + """ + ... def __bool__(self) -> bool: ... def __contains__(self, item: _KT) -> bool: ... From e2fca6a4b6ffe5f28e898c9f169fdead11741fd0 Mon Sep 17 00:00:00 2001 From: Ritvik Nag Date: Fri, 9 Sep 2022 17:44:03 -0400 Subject: [PATCH 47/55] update dev requirements --- requirements-dev.txt | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 892b257..4d410c5 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,15 +1,15 @@ pip>=21.3.1 bump2version==1.0.1 wheel==0.37.1 -watchdog[watchmedo]==2.1.8 +watchdog[watchmedo]==2.1.9 flake8>=3 # pyup: ignore -tox==3.25.0 -Sphinx==5.0.1 -twine>=3.8.0,<4 +tox==3.26.0 +Sphinx==5.1.1 +twine==4.0.1 # Test / Benchmark requirements codecov==2.1.12 -coverage>=6.2 -pytest>=7.0.1,<8 +coverage==6.4.4 +pytest==7.1.3 pytest-mock==3.8.2 pytest-benchmark[histogram]==3.4.1 pytest-cov==3.0.0 @@ -19,7 +19,7 @@ dict2dot==0.1 dotmap==1.3.30 dotsi==0.0.3 dotted-dict==1.1.3 -dotty-dict==1.3.0 +dotty-dict==1.3.1 addict==2.4.0 attrdict3==2.0.2 metadict==0.1.2 From ccbff66c6fe1a86f38ad60e3cc3e691fe5a0d9b2 Mon Sep 17 00:00:00 2001 From: Ritvik Nag Date: Mon, 19 Sep 2022 11:45:41 -0400 Subject: [PATCH 48/55] rename init params like `skip_init` to `_skip_init` --- benchmarks/test_create.py | 4 +-- benchmarks/test_create_special_keys.py | 4 +-- dotwiz/main.py | 22 +++++++-------- dotwiz/main.pyi | 29 +++++++++++--------- dotwiz/plus.py | 36 ++++++++++++------------ dotwiz/plus.pyi | 38 ++++++++++++++------------ 6 files changed, 70 insertions(+), 63 deletions(-) diff --git a/benchmarks/test_create.py b/benchmarks/test_create.py index 6c5952c..7bb44ef 100644 --- a/benchmarks/test_create.py +++ b/benchmarks/test_create.py @@ -79,7 +79,7 @@ def test_dotwiz(benchmark, my_data): def test_dotwiz_without_check_lists(benchmark, my_data): - result = benchmark(dotwiz.DotWiz, my_data, check_lists=False) + result = benchmark(dotwiz.DotWiz, my_data, _check_lists=False) # print(result) # now similar to `dict2dot`, `dict`s nested within `lists` won't work @@ -104,7 +104,7 @@ def test_dotwiz_plus(benchmark, my_data): def test_dotwiz_plus_without_check_lists(benchmark, my_data): - result = benchmark(dotwiz.DotWizPlus, my_data, check_lists=False) + result = benchmark(dotwiz.DotWizPlus, my_data, _check_lists=False) # print(result) # now similar to `dict2dot`, `dict`s nested within `lists` won't work diff --git a/benchmarks/test_create_special_keys.py b/benchmarks/test_create_special_keys.py index b68a0cf..fbdc7f3 100644 --- a/benchmarks/test_create_special_keys.py +++ b/benchmarks/test_create_special_keys.py @@ -181,7 +181,7 @@ def test_dotwiz(benchmark, my_data): def test_dotwiz_without_check_lists(benchmark, my_data): - result = benchmark(dotwiz.DotWiz, my_data, check_lists=False) + result = benchmark(dotwiz.DotWiz, my_data, _check_lists=False) # print(result) assert_eq2(result) @@ -202,7 +202,7 @@ def test_dotwiz_plus(benchmark, my_data): def test_dotwiz_plus_without_check_lists(benchmark, my_data): - result = benchmark(dotwiz.DotWizPlus, my_data, check_lists=False) + result = benchmark(dotwiz.DotWizPlus, my_data, _check_lists=False) # print(result) assert_eq3(result, nested_in_list=False) diff --git a/dotwiz/main.py b/dotwiz/main.py index 4b81d7a..422c830 100644 --- a/dotwiz/main.py +++ b/dotwiz/main.py @@ -27,17 +27,17 @@ def make_dot_wiz(*args, **kwargs): # noinspection PyDefaultArgument def __upsert_into_dot_wiz__(self, input_dict={}, - check_lists=True, - __set_dict=False, + _check_lists=True, + _set_dict=False, **kwargs): """ Helper method to generate / update a :class:`DotWiz` (dot-access dict) from a Python ``dict`` object, and optional *keyword arguments*. :param input_dict: Input `dict` object to process the key-value pairs of. - :param check_lists: False to not check for nested `list` values. Defaults + :param _check_lists: False to not check for nested `list` values. Defaults to True. - :param __set_dict: True to use `input_dict` as is, and skip the bulk of + :param _set_dict: True to use `input_dict` as is, and skip the bulk of the initialization logic, such as iterating over the key-value pairs. This is a huge performance improvement, if we know an input `dict` only contains simple values, and no nested `dict` or `list` values. @@ -45,7 +45,7 @@ def __upsert_into_dot_wiz__(self, input_dict={}, `input_dict`. """ - if __set_dict: + if _set_dict: __set__(self, '__dict__', input_dict) return None @@ -69,8 +69,8 @@ def __upsert_into_dot_wiz__(self, input_dict={}, if t is dict: # noinspection PyArgumentList - value = DotWiz(value, check_lists) - elif check_lists and t is list: + value = DotWiz(value, _check_lists) + elif _check_lists and t is list: value = [__resolve_value__(e, DotWiz) for e in value] # note: this logic is the same as `DotWiz.__setitem__()` @@ -107,7 +107,7 @@ def __merge_impl__(self, other): } __merged_dict = op(self.__dict__, __other_dict) - return DotWiz(__merged_dict, __set_dict=True) + return DotWiz(__merged_dict, _set_dict=True) return __merge_impl__ @@ -126,7 +126,7 @@ def __or_impl__(self, other, check_lists=True): } __merged_dict = {**self.__dict__, **__other_dict} - return DotWiz(__merged_dict, __set_dict=True) + return DotWiz(__merged_dict, _set_dict=True) def __ror_impl__(self, other, check_lists=True): """Implementation of `__ror__` to merge `DotWiz` and `dict` objects.""" @@ -136,7 +136,7 @@ def __ror_impl__(self, other, check_lists=True): } __merged_dict = {**__other_dict, **self.__dict__} - return DotWiz(__merged_dict, __set_dict=True) + return DotWiz(__merged_dict, _set_dict=True) def __ior_impl__(self, other, check_lists=True, __update=dict.update): @@ -228,7 +228,7 @@ def copy(self, __copy=dict.copy): :return: DotWiz instance """ - return DotWiz(__copy(self.__dict__), __set_dict=True) + return DotWiz(__copy(self.__dict__), _set_dict=True) # noinspection PyIncorrectDocstring @classmethod diff --git a/dotwiz/main.pyi b/dotwiz/main.pyi index 7c3e14f..6f4ba98 100644 --- a/dotwiz/main.pyi +++ b/dotwiz/main.pyi @@ -5,12 +5,12 @@ from typing import ( Iterable, Iterator, Reversible, KeysView, ItemsView, ValuesView, Mapping, MutableMapping, AnyStr, Any, - overload, + overload, Generic, ) _T = TypeVar('_T') -_KT = TypeVar('_KT') -_VT = TypeVar('_VT') +_KT = TypeVar('_KT') # Key type. +_VT = TypeVar('_VT') # Value type. # Valid collection types in JSON. _JSONList = list[Any] @@ -48,8 +48,8 @@ def make_dot_wiz(*args: Iterable[_KT, _VT], # noinspection PyDefaultArgument def __upsert_into_dot_wiz__(self: DotWiz, input_dict: MutableMapping[_KT, _VT] = {}, - *, check_lists=True, - __set_dict=False, + *, _check_lists=True, + _set_dict=False, **kwargs: _T) -> None: ... def __setitem_impl__(self: DotWiz, @@ -78,20 +78,21 @@ def __ior_impl__(self: DotWiz, __update: _Update = dict.update): ... -class DotWiz: +class DotWiz(Generic[_KT, _VT]): # noinspection PyDefaultArgument def __init__(self, input_dict: MutableMapping[_KT, _VT] = {}, - *, check_lists=True, - __set_dict=False, + *, + _check_lists=True, + _set_dict=False, **kwargs: _T) -> None: """Create a new :class:`DotWiz` instance. :param input_dict: Input `dict` object to process the key-value pairs of. - :param check_lists: False to not check for nested `list` values. Defaults + :param _check_lists: False to not check for nested `list` values. Defaults to True. - :param __set_dict: True to use `input_dict` as is, and skip the bulk of + :param _set_dict: True to use `input_dict` as is, and skip the bulk of the initialization logic, such as iterating over the key-value pairs. This is a huge performance improvement, if we know an input `dict` only contains simple values, and no nested `dict` or `list` values. @@ -212,14 +213,16 @@ class DotWiz: def popitem(self) -> tuple[_KT, _VT]: ... def setdefault(self, k: _KT, default=None, - *, check_lists=True, + *, + check_lists=True, __get=dict.get) -> _VT: ... # noinspection PyDefaultArgument def update(self, __m: MutableMapping[_KT, _VT] = {}, - *, check_lists=True, - __set_dict=False, + *, + _check_lists=True, + _set_dict=False, **kwargs: _T) -> None: ... def values(self) -> ValuesView: ... diff --git a/dotwiz/plus.py b/dotwiz/plus.py index 0751fd1..b6542df 100644 --- a/dotwiz/plus.py +++ b/dotwiz/plus.py @@ -101,29 +101,29 @@ def __store_in_object__(__self_dict, __self_orig_dict, __self_orig_keys, # noinspection PyDefaultArgument def __upsert_into_dot_wiz_plus__(self, input_dict={}, - check_lists=True, - check_types=True, - __skip_init=False, + _check_lists=True, + _check_types=True, + _skip_init=False, **kwargs): """ Helper method to generate / update a :class:`DotWizPlus` (dot-access dict) from a Python ``dict`` object, and optional *keyword arguments*. :param input_dict: Input `dict` object to process the key-value pairs of. - :param check_lists: False to not check for nested `list` values. Defaults + :param _check_lists: False to not check for nested `list` values. Defaults to True. - :param check_types: False to not check for nested `dict` and `list` values. + :param _check_types: False to not check for nested `dict` and `list` values. This is a minor performance improvement, if we know an input `dict` only contains simple values, and no nested `dict` or `list` values. Defaults to True. - :param __skip_init: True to simply return, and skip the initialization + :param _skip_init: True to simply return, and skip the initialization logic. This is useful to create an empty `DotWizPlus` instance. Defaults to False. :param kwargs: Additional keyword arguments to process, in addition to `input_dict`. """ - if __skip_init: + if _skip_init: return None __dict = self.__dict__ @@ -142,7 +142,7 @@ def __upsert_into_dot_wiz_plus__(self, input_dict={}, __orig_keys = {} __set__(self, '__orig_keys__', __orig_keys) - if check_types: + if _check_types: for key in input_dict: # note: this logic is the same as `__resolve_value__()` @@ -154,8 +154,8 @@ def __upsert_into_dot_wiz_plus__(self, input_dict={}, if t is dict: # noinspection PyArgumentList - value = DotWizPlus(value, check_lists) - elif check_lists and t is list: + value = DotWizPlus(value, _check_lists) + elif _check_lists and t is list: value = [__resolve_value__(e, DotWizPlus) for e in value] __store_in_object__(__dict, __orig_dict, __orig_keys, key, value) @@ -206,14 +206,14 @@ def __merge_impl__(self, other): __other_dict = getattr(other, '__dict__', None) if __other_dict is None: # other is not a `DotWizPlus` instance - other = DotWizPlus(other, check_lists=check_lists) + other = DotWizPlus(other, _check_lists=check_lists) __other_dict = other.__dict__ __merged_dict = op(self.__dict__, __other_dict) __merged_orig_dict = op(self.__orig_dict__, other.__orig_dict__) __merged_orig_keys = op(self.__orig_keys__, other.__orig_keys__) - __merged = DotWizPlus(__skip_init=True) + __merged = DotWizPlus(_skip_init=True) __set__(__merged, '__dict__', __merged_dict) __set__(__merged, '__orig_dict__', __merged_orig_dict) __set__(__merged, '__orig_keys__', __merged_orig_keys) @@ -234,14 +234,14 @@ def __or_impl__(self, other, check_lists=True): __other_dict = getattr(other, '__dict__', None) if __other_dict is None: # other is not a `DotWizPlus` instance - other = DotWizPlus(other, check_lists=check_lists) + other = DotWizPlus(other, _check_lists=check_lists) __other_dict = other.__dict__ __merged_dict = {**self.__dict__, **__other_dict} __merged_orig_dict = {**self.__orig_dict__, **other.__orig_dict__} __merged_orig_keys = {**self.__orig_keys__, **other.__orig_keys__} - __merged = DotWizPlus(__skip_init=True) + __merged = DotWizPlus(_skip_init=True) __set__(__merged, '__dict__', __merged_dict) __set__(__merged, '__orig_dict__', __merged_orig_dict) __set__(__merged, '__orig_keys__', __merged_orig_keys) @@ -253,14 +253,14 @@ def __ror_impl__(self, other, check_lists=True): __other_dict = getattr(other, '__dict__', None) if __other_dict is None: # other is not a `DotWizPlus` instance - other = DotWizPlus(other, check_lists=check_lists) + other = DotWizPlus(other, _check_lists=check_lists) __other_dict = other.__dict__ __merged_dict = {**__other_dict, **self.__dict__} __merged_orig_dict = {**other.__orig_dict__, **self.__orig_dict__} __merged_orig_keys = {**other.__orig_keys__, **self.__orig_keys__} - __merged = DotWizPlus(__skip_init=True) + __merged = DotWizPlus(_skip_init=True) __set__(__merged, '__dict__', __merged_dict) __set__(__merged, '__orig_dict__', __merged_orig_dict) __set__(__merged, '__orig_keys__', __merged_orig_keys) @@ -420,7 +420,7 @@ def copy(self, __copy=dict.copy): :return: DotWizPlus instance """ - dw = DotWizPlus(__skip_init=True) + dw = DotWizPlus(_skip_init=True) __set__(dw, '__dict__', __copy(self.__dict__)) __set__(dw, '__orig_dict__', __copy(self.__orig_dict__)) __set__(dw, '__orig_keys__', __copy(self.__orig_keys__)) @@ -474,7 +474,7 @@ def popitem(self): return self.__orig_dict__.popitem() - def setdefault(self, k, default=None, check_lists=True, __get=dict.get, ): + def setdefault(self, k, default=None, check_lists=True, __get=dict.get): """ Insert key with a value of default if key is not in the dictionary. diff --git a/dotwiz/plus.pyi b/dotwiz/plus.pyi index d102b33..c6a5987 100644 --- a/dotwiz/plus.pyi +++ b/dotwiz/plus.pyi @@ -5,12 +5,12 @@ from typing import ( Iterable, Iterator, Reversible, KeysView, ItemsView, ValuesView, Mapping, MutableMapping, AnyStr, Any, - overload, + overload, Generic, ) _T = TypeVar('_T') -_KT = TypeVar('_KT') -_VT = TypeVar('_VT') +_KT = TypeVar('_KT') # Key type. +_VT = TypeVar('_VT') # Value type. # Valid collection types in JSON. _JSONList = list[Any] @@ -66,9 +66,10 @@ def __store_in_object__(__self_dict: MutableMapping[_KT, _VT], # noinspection PyDefaultArgument def __upsert_into_dot_wiz_plus__(self: DotWizPlus, input_dict: MutableMapping[_KT, _VT] = {}, - *, check_lists=True, - check_types=True, - __skip_init=False, + *, + _check_lists=True, + _check_types=True, + _skip_init=False, **kwargs: _T) -> None: ... def __setattr_impl__(self: DotWizPlus, @@ -101,7 +102,7 @@ def __ior_impl__(self: DotWizPlus, __update: _Update = dict.update): ... -class DotWizPlus: +class DotWizPlus(Generic[_KT, _VT]): __dict__: dict[_KT, _VT] __orig_dict__: dict[_KT, _VT] @@ -110,20 +111,21 @@ class DotWizPlus: # noinspection PyDefaultArgument def __init__(self, input_dict: MutableMapping[_KT, _VT] = {}, - *, check_lists=True, - check_types=True, - __skip_init=False, + *, + _check_lists=True, + _check_types=True, + _skip_init=False, **kwargs: _T) -> None: """Create a new :class:`DotWizPlus` instance. :param input_dict: Input `dict` object to process the key-value pairs of. - :param check_lists: False to not check for nested `list` values. Defaults + :param _check_lists: False to not check for nested `list` values. Defaults to True. - :param check_types: False to not check for nested `dict` and `list` values. + :param _check_types: False to not check for nested `dict` and `list` values. This is a minor performance improvement, if we know an input `dict` only contains simple values, and no nested `dict` or `list` values. Defaults to True. - :param __skip_init: True to simply return, and skip the initialization + :param _skip_init: True to simply return, and skip the initialization logic. This is useful to create an empty `DotWizPlus` instance. Defaults to False. :param kwargs: Additional keyword arguments to process, in addition to @@ -261,15 +263,17 @@ class DotWizPlus: def popitem(self) -> tuple[_KT, _VT]: ... def setdefault(self, k: _KT, default=None, - *, check_lists=True, + *, + check_lists=True, __get=dict.get) -> _VT: ... # noinspection PyDefaultArgument def update(self, __m: MutableMapping[_KT, _VT] = {}, - *, check_lists=True, - check_types=True, - __skip_init=False, + *, + _check_lists=True, + _check_types=True, + _skip_init=False, **kwargs: _T) -> None: ... def values(self) -> ValuesView: ... From e25a5af431fc6ce82c3b35de702539d86821733f Mon Sep 17 00:00:00 2001 From: Ritvik Nag Date: Mon, 19 Sep 2022 12:25:22 -0400 Subject: [PATCH 49/55] rename init param `_set_dict` to `_check_types` for better clarity * add benchmarks for passing `_check_types=False`. * add alias `create_sp` for "create with special keys" benchmarks, for easier typing. --- benchmarks/test_create.py | 12 ++++++++++++ benchmarks/test_create_special_keys.py | 24 ++++++++++++++++++++++-- benchmarks/test_getattr.py | 1 + dotwiz/common.py | 4 ++-- dotwiz/main.py | 15 ++++++++------- dotwiz/main.pyi | 12 +++++++----- pytest.ini | 1 + 7 files changed, 53 insertions(+), 16 deletions(-) diff --git a/benchmarks/test_create.py b/benchmarks/test_create.py index 7bb44ef..8585ad8 100644 --- a/benchmarks/test_create.py +++ b/benchmarks/test_create.py @@ -19,6 +19,7 @@ # Mark all benchmarks in this module, and assign them to the specified group. +# use with: `pytest benchmarks -m create` pytestmark = [pytest.mark.create, pytest.mark.benchmark(group='create')] @@ -89,6 +90,17 @@ def test_dotwiz_without_check_lists(benchmark, my_data): assert result.c.bb[0]['x'] == 77 +def test_dotwiz_without_check_types(benchmark, my_data): + result = benchmark(dotwiz.DotWiz, my_data, _check_types=False) + # print(result) + + # now, `dict`s and `lists` nested within the input `dict` won't work + # assert result.c.bb[0].x == 77 + + # instead, dict access should work fine: + assert result.c['bb'][0]['x'] == 77 + + def test_make_dot_wiz(benchmark, my_data): result = benchmark(dotwiz.make_dot_wiz, my_data) # print(result) diff --git a/benchmarks/test_create_special_keys.py b/benchmarks/test_create_special_keys.py index fbdc7f3..bbf8387 100644 --- a/benchmarks/test_create_special_keys.py +++ b/benchmarks/test_create_special_keys.py @@ -20,7 +20,11 @@ # Mark all benchmarks in this module, and assign them to the specified group. +# use with: `pytest benchmarks -m create_with_special_keys` +# use with: `pytest benchmarks -m create_sp` pytestmark = [pytest.mark.create_with_special_keys, + # alias, for easier typing + pytest.mark.create_sp, pytest.mark.benchmark(group='create_with_special_keys')] @@ -79,7 +83,7 @@ def assert_eq2(result): assert result['Some r@ndom#$(*#@ Key##$# here !!!'] == 'T' -def assert_eq3(result, nested_in_list=True): +def assert_eq3(result, nested_in_dict=True, nested_in_list=True): assert result.camel_case == 1 assert result.snake_case == 2 assert result.pascal_case == 3 @@ -88,8 +92,10 @@ def assert_eq3(result, nested_in_list=True): assert result._3d == 6 if nested_in_list: assert result.for_._1nfinity[0].and_.beyond == 8 - else: + elif nested_in_dict: assert result.for_._1nfinity[0]['and']['Beyond!'] == 8 + else: + assert result.for_['1nfinity'][0]['and']['Beyond!'] == 8 assert result.some_r_ndom_key_here == 'T' @@ -187,6 +193,13 @@ def test_dotwiz_without_check_lists(benchmark, my_data): assert_eq2(result) +def test_dotwiz_without_check_types(benchmark, my_data): + result = benchmark(dotwiz.DotWiz, my_data, _check_types=False) + # print(result) + + assert_eq2(result) + + def test_make_dot_wiz(benchmark, my_data): result = benchmark(dotwiz.make_dot_wiz, my_data) # print(result) @@ -208,6 +221,13 @@ def test_dotwiz_plus_without_check_lists(benchmark, my_data): assert_eq3(result, nested_in_list=False) +def test_dotwiz_plus_without_check_types(benchmark, my_data): + result = benchmark(dotwiz.DotWizPlus, my_data, _check_types=False) + # print(result) + + assert_eq3(result, nested_in_list=False, nested_in_dict=False) + + def test_make_dot_wiz_plus(benchmark, my_data): result = benchmark(dotwiz.make_dot_wiz_plus, my_data) # print(result) diff --git a/benchmarks/test_getattr.py b/benchmarks/test_getattr.py index 8c50e70..edb14d6 100644 --- a/benchmarks/test_getattr.py +++ b/benchmarks/test_getattr.py @@ -20,6 +20,7 @@ # Mark all benchmarks in this module, and assign them to the specified group. +# use with: `pytest benchmarks -m getattr` pytestmark = [pytest.mark.getattr, pytest.mark.benchmark(group='getattr')] diff --git a/dotwiz/common.py b/dotwiz/common.py index 0165e06..64324d0 100644 --- a/dotwiz/common.py +++ b/dotwiz/common.py @@ -102,7 +102,7 @@ def __convert_to_dict__(o): if has_attr_dict: def __object_hook__(d): - return __cls__(d, check_types=False) + return __cls__(d, _check_types=False) def __convert_to_dict_snake_cased__(o): """ @@ -212,7 +212,7 @@ def to_json(o, attr=False, snake=False, else: def __object_hook__(d): - return __cls__(d, __set_dict=True) + return __cls__(d, _check_types=False) def to_json(o, filename=None, encoding='utf-8', errors='strict', file_encoder=json.dump, diff --git a/dotwiz/main.py b/dotwiz/main.py index 422c830..790240b 100644 --- a/dotwiz/main.py +++ b/dotwiz/main.py @@ -28,7 +28,7 @@ def make_dot_wiz(*args, **kwargs): # noinspection PyDefaultArgument def __upsert_into_dot_wiz__(self, input_dict={}, _check_lists=True, - _set_dict=False, + _check_types=True, **kwargs): """ Helper method to generate / update a :class:`DotWiz` (dot-access dict) @@ -37,7 +37,8 @@ def __upsert_into_dot_wiz__(self, input_dict={}, :param input_dict: Input `dict` object to process the key-value pairs of. :param _check_lists: False to not check for nested `list` values. Defaults to True. - :param _set_dict: True to use `input_dict` as is, and skip the bulk of + :param _check_types: False to not check for nested `dict` and `list` values. + In this case, we use `input_dict` as is, and skip the bulk of the initialization logic, such as iterating over the key-value pairs. This is a huge performance improvement, if we know an input `dict` only contains simple values, and no nested `dict` or `list` values. @@ -45,7 +46,7 @@ def __upsert_into_dot_wiz__(self, input_dict={}, `input_dict`. """ - if _set_dict: + if not _check_types: __set__(self, '__dict__', input_dict) return None @@ -107,7 +108,7 @@ def __merge_impl__(self, other): } __merged_dict = op(self.__dict__, __other_dict) - return DotWiz(__merged_dict, _set_dict=True) + return DotWiz(__merged_dict, _check_types=False) return __merge_impl__ @@ -126,7 +127,7 @@ def __or_impl__(self, other, check_lists=True): } __merged_dict = {**self.__dict__, **__other_dict} - return DotWiz(__merged_dict, _set_dict=True) + return DotWiz(__merged_dict, _check_types=False) def __ror_impl__(self, other, check_lists=True): """Implementation of `__ror__` to merge `DotWiz` and `dict` objects.""" @@ -136,7 +137,7 @@ def __ror_impl__(self, other, check_lists=True): } __merged_dict = {**__other_dict, **self.__dict__} - return DotWiz(__merged_dict, _set_dict=True) + return DotWiz(__merged_dict, _check_types=False) def __ior_impl__(self, other, check_lists=True, __update=dict.update): @@ -228,7 +229,7 @@ def copy(self, __copy=dict.copy): :return: DotWiz instance """ - return DotWiz(__copy(self.__dict__), _set_dict=True) + return DotWiz(__copy(self.__dict__), _check_types=False) # noinspection PyIncorrectDocstring @classmethod diff --git a/dotwiz/main.pyi b/dotwiz/main.pyi index 6f4ba98..d31ffbf 100644 --- a/dotwiz/main.pyi +++ b/dotwiz/main.pyi @@ -48,8 +48,9 @@ def make_dot_wiz(*args: Iterable[_KT, _VT], # noinspection PyDefaultArgument def __upsert_into_dot_wiz__(self: DotWiz, input_dict: MutableMapping[_KT, _VT] = {}, - *, _check_lists=True, - _set_dict=False, + *, + _check_lists=True, + _check_types=True, **kwargs: _T) -> None: ... def __setitem_impl__(self: DotWiz, @@ -85,14 +86,15 @@ class DotWiz(Generic[_KT, _VT]): input_dict: MutableMapping[_KT, _VT] = {}, *, _check_lists=True, - _set_dict=False, + _check_types=True, **kwargs: _T) -> None: """Create a new :class:`DotWiz` instance. :param input_dict: Input `dict` object to process the key-value pairs of. :param _check_lists: False to not check for nested `list` values. Defaults to True. - :param _set_dict: True to use `input_dict` as is, and skip the bulk of + :param _check_types: False to not check for nested `dict` and `list` values. + In this case, we use `input_dict` as is, and skip the bulk of the initialization logic, such as iterating over the key-value pairs. This is a huge performance improvement, if we know an input `dict` only contains simple values, and no nested `dict` or `list` values. @@ -222,7 +224,7 @@ class DotWiz(Generic[_KT, _VT]): __m: MutableMapping[_KT, _VT] = {}, *, _check_lists=True, - _set_dict=False, + _check_types=True, **kwargs: _T) -> None: ... def values(self) -> ValuesView: ... diff --git a/pytest.ini b/pytest.ini index 3036192..738834e 100644 --- a/pytest.ini +++ b/pytest.ini @@ -7,4 +7,5 @@ markers = long: mark an integration test that might long to run create create_with_special_keys + create_sp getattr From 05ca75798bbd431b5f0ff56989ed850e18c1f957 Mon Sep 17 00:00:00 2001 From: Ritvik Nag Date: Mon, 19 Sep 2022 12:43:55 -0400 Subject: [PATCH 50/55] add `__class_getitem__()` so subscripting types works --- dotwiz/common.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/dotwiz/common.py b/dotwiz/common.py index 64324d0..f24ca1a 100644 --- a/dotwiz/common.py +++ b/dotwiz/common.py @@ -27,6 +27,14 @@ def __repr__(self: object): # add a `__repr__` magic method to the class. cls_dict['__repr__'] = __repr__ + # __class_getitem__(): used to subscript the type of key-value pairs for + # type hinting purposes. ex.: `DotWiz[str, int]` + def __class_getitem__(cls, _: 'type | tuple[type]'): + return cls + + # add a `__class_getitem__` magic method to the class. + cls_dict['__class_getitem__'] = __class_getitem__ + # add utility or helper methods to the class, such as: # - `from_json` - de-serialize a JSON string into an instance. # - `to_dict` - convert an instance to a Python `dict` object. From 90ef41320bd441e97b7cdfdf5dce8d1e409daf33 Mon Sep 17 00:00:00 2001 From: Ritvik Nag Date: Wed, 21 Sep 2022 22:42:25 -0400 Subject: [PATCH 51/55] add tests for better coverage --- tests/unit/test_dotwiz.py | 12 +++ tests/unit/test_dotwiz_plus.py | 155 +++++++++++++++++++++++++++------ tests/unit/test_encoders.py | 21 +++++ 3 files changed, 161 insertions(+), 27 deletions(-) create mode 100644 tests/unit/test_encoders.py diff --git a/tests/unit/test_dotwiz.py b/tests/unit/test_dotwiz.py index 25400df..362cd37 100644 --- a/tests/unit/test_dotwiz.py +++ b/tests/unit/test_dotwiz.py @@ -89,6 +89,18 @@ def test_init(): assert dd.b == [1, 2, 3] +def test_class_get_item(): + """Using __class_get_item__() to subscript the types, i.e. DotWiz[K, V]""" + dw = DotWiz[str, int](first_key=123, SecondKey=321) + + # type hinting and auto-completion for value (int) works for dict access + assert dw['first_key'].real == 123 + + # however, the same doesn't work for attribute access. i.e. `dw.SecondKey.` + # doesn't result in any method auto-completion or suggestions. + assert dw.SecondKey == 321 + + def test_del_attr(): dd = DotWiz( a=1, diff --git a/tests/unit/test_dotwiz_plus.py b/tests/unit/test_dotwiz_plus.py index 0b21f16..00b485a 100644 --- a/tests/unit/test_dotwiz_plus.py +++ b/tests/unit/test_dotwiz_plus.py @@ -1,4 +1,5 @@ """Tests for the `DotWizPlus` class.""" +from datetime import datetime import pytest @@ -7,7 +8,7 @@ from .conftest import CleanupGetAttr -def test_dot_wiz_plus_with_basic_usage(): +def test_basic_usage(): """Confirm intended functionality of `DotWizPlus`""" dw = DotWizPlus({'Key_1': [{'k': 'v'}], 'keyTwo': '5', @@ -72,7 +73,7 @@ def test_overwrite_raises_an_error_by_default(self): assert 'pass `overwrite=True`' in str(e.value) -def test_dotwiz_plus_init(): +def test_init(): """Confirm intended functionality of `DotWizPlus.__init__`""" dd = DotWizPlus({ 1: 'test', @@ -93,7 +94,19 @@ def test_dotwiz_plus_init(): assert dd.b == [1, 2, 3] -def test_dotwiz_plus_del_attr(): +def test_class_get_item(): + """Using __class_get_item__() to subscript the types, i.e. DotWizPlus[K, V]""" + dw = DotWizPlus[str, int](first_key=123, SecondKey=321) + + # type hinting and auto-completion for value (int) works for dict access + assert dw['first_key'].real == 123 + + # however, the same doesn't work for attribute access. i.e. `dw.second_key.` + # doesn't result in any method auto-completion or suggestions. + assert dw.second_key == 321 + + +def test_del_attr(): dd = DotWizPlus( a=1, b={'one': [1], @@ -119,7 +132,7 @@ def test_dotwiz_plus_del_attr(): assert 'b' not in dd -def test_dotwiz_plus_get_attr(): +def test_get_attr(): """Confirm intended functionality of `DotWizPlus.__getattr__`""" dd = DotWizPlus() dd.a = [{'one': 1, 'two': {'key': 'value'}}] @@ -133,51 +146,52 @@ def test_dotwiz_plus_get_attr(): assert item.two.key == 'value' -def test_dotwiz_plus_get_item(): +def test_get_item(): """Confirm intended functionality of `DotWizPlus.__getitem__`""" dd = DotWizPlus() - dd.a = [{'one': 1, 'two': {'key': 'value'}}] + dd.a = [{'one': 1, 'two': {'any-key': 'value'}}] item = dd['a'][0] assert isinstance(item, DotWizPlus) assert item['one'] == 1 - assert item['two']['key'] == 'value' + assert item.two.any_key == 'value' + assert item['two']['any-key'] == 'value' -def test_dotwiz_plus_set_attr(): +def test_set_attr(): """Confirm intended functionality of `DotWizPlus.__setattr__`""" dd = DotWizPlus() - dd.a = [{'one': 1, 'two': 2}] + dd.AnyOne = [{'one': 1, 'keyTwo': 2}] - item = dd.a[0] + item = dd.AnyOne[0] assert isinstance(item, DotWizPlus) assert item.one == 1 - assert item.two == 2 + assert item.key_two == 2 -def test_dotwiz_plus_set_item(): +def test_set_item(): """Confirm intended functionality of `DotWizPlus.__setitem__`""" dd = DotWizPlus() - dd['a'] = [{'one': 1, 'two': 2}] + dd['AnyOne'] = [{'one': 1, 'keyTwo': 2}] - item = dd.a[0] + item = dd.any_one[0] assert isinstance(item, DotWizPlus) assert item.one == 1 - assert item.two == 2 + assert item.key_two == 2 -def test_dotwiz_plus_update(): +def test_update(): """Confirm intended functionality of `DotWizPlus.update`""" dd = DotWizPlus(a=1, b={'one': [1]}) assert isinstance(dd.b, DotWizPlus) dd.b.update({'two': [{'first': 'one', 'second': 'two'}]}, - three={'four': [{'five': '5'}]}) + threeFour={'five': [{'six': '6'}]}) assert isinstance(dd.b, DotWizPlus) assert isinstance(dd.b.two[0], DotWizPlus) - assert isinstance(dd.b.three, DotWizPlus) + assert isinstance(dd.b.three_four, DotWizPlus) assert dd.b.one == [1] item = dd.b.two[0] @@ -185,12 +199,12 @@ def test_dotwiz_plus_update(): assert item.first == 'one' assert item.second == 'two' - item = dd.b.three.four[0] + item = dd.b.three_four.five[0] assert isinstance(item, DotWizPlus) - assert item.five == '5' + assert item.six == '6' -def test_dotwiz_plus_update_with_no_args(): +def test_update_with_no_args(): """Add for full branch coverage.""" dd = DotWizPlus(a=1, b={'one': [1]}) @@ -269,7 +283,7 @@ def test_from_json_with_multiline(mock_file_open): assert dw_list[1].second_key[1].nested_key -def test_dotwiz_plus_to_dict(): +def test_to_dict(): """Confirm intended functionality of `DotWizPlus.to_dict`""" dw = DotWizPlus(hello=[{"Key": "value", "Another-KEY": {"a": "b"}}], camelCased={r"th@#$%is.is.!@#$%^&*()a{}\:/~`.T'e'\"st": True}) @@ -287,7 +301,29 @@ def test_dotwiz_plus_to_dict(): } -def test_dotwiz_to_json(): +def test_to_dict_with_snake_cased_keys(): + """Confirm intended functionality of `DotWizPlus.to_dict` with `snake=True`""" + dw = DotWizPlus(hello=[{"items": "value", "Another-KEY": {"for": {"123": True}}}], + camelCased={r"th@#$%is.is.!@#$%^&*()a{}\:/~`.T'e'\"st": True}) + + assert dw.to_dict(snake=True) == { + 'hello': [ + { + 'another_key': { + 'for': { + '123': True + } + }, + 'items': 'value', + } + ], + 'camel_cased': { + 'th_is_is_a_t_e_st': True + }, + } + + +def test_to_json(): """Confirm intended functionality of `DotWizPlus.to_json`""" dw = DotWizPlus(hello=[{"Key": "value", "Another-KEY": {"a": "b"}}], camelCased={r"th@#$%is.is.!@#$%^&*()a{}\:/~`.T'e'\"st": True}) @@ -308,16 +344,66 @@ def test_dotwiz_to_json(): }""".lstrip() -def test_dotwiz_plus_to_attr_dict(): +def test_to_json_with_attribute_keys(): + """Confirm intended functionality of `DotWizPlus.to_json` with `attr=True`""" + dw = DotWizPlus(hello=[{"items": "value", "Another-KEY": {"for": {"123": True}}}], + camelCased={r"th@#$%is.is.!@#$%^&*()a{}\:/~`.T'e'\"st": True}) + + assert dw.to_json(attr=True, indent=4) == r""" +{ + "hello": [ + { + "items_": "value", + "another_key": { + "for_": { + "_123": true + } + } + } + ], + "camel_cased": { + "th_is_is_a_t_e_st": true + } +}""".lstrip() + + +def test_to_json_with_snake_cased_keys(): + """Confirm intended functionality of `DotWizPlus.to_json` with `snake=True`""" + dw = DotWizPlus(hello=[{"items": "value", "Another-KEY": {"for": {"123": True}}}], + camelCased={r"th@#$%is.is.!@#$%^&*()a{}\:/~`.T'e'\"st": True}) + + assert dw.to_json(snake=True, indent=4) == r""" +{ + "hello": [ + { + "items": "value", + "another_key": { + "for": { + "123": true + } + } + } + ], + "camel_cased": { + "th_is_is_a_t_e_st": true + } +}""".lstrip() + + +def test_to_attr_dict(): """Confirm intended functionality of `DotWizPlus.to_dict`""" - dw = DotWizPlus(hello=[{"Key": "value", "Another-KEY": {"a": "b"}}], + dw = DotWizPlus(hello=[{"items": "value", "Another-KEY": {"for": {"123": True}}}], camelCased={r"th@#$%is.is.!@#$%^&*()a{}\:/~`.T'e'\"st": True}) assert dw.to_attr_dict() == { 'hello': [ { - 'another_key': {'a': 'b'}, - 'key': 'value', + 'another_key': { + 'for_': { + '_123': True + } + }, + 'items_': 'value', } ], 'camel_cased': {'th_is_is_a_t_e_st': True}, @@ -351,3 +437,18 @@ def test_dir(): assert '1string' not in obj_dir assert 'lambda' not in obj_dir + + +def test_to_json_with_non_serializable_type(): + """ + Confirm intended functionality of `DotWizPlus.to_json` when an object + doesn't define a `__dict__`, so the default `JSONEncoder.default` + implementation is called. + """ + + dw = DotWizPlus(string='val', dt=datetime.min) + # print(dw) + + # TypeError: Object of type `datetime` is not JSON serializable + with pytest.raises(TypeError): + _ = dw.to_json() diff --git a/tests/unit/test_encoders.py b/tests/unit/test_encoders.py new file mode 100644 index 0000000..d24976e --- /dev/null +++ b/tests/unit/test_encoders.py @@ -0,0 +1,21 @@ +import json +from datetime import datetime + +import pytest + +from dotwiz import DotWizPlus +from dotwiz.encoders import DotWizPlusEncoder + + +def test_dotwiz_plus_encoder_default(): + """:meth:`DotWizPlusEncoder.default` when :class:`AttributeError` is raised.""" + dw = DotWizPlus(this={'is': {'a': [{'test': True}]}}) + assert dw.this.is_.a[0].test + + string = json.dumps(dw, cls=DotWizPlusEncoder) + assert string == '{"this": {"is": {"a": [{"test": true}]}}}' + + with pytest.raises(TypeError) as e: + _ = json.dumps({'dt': datetime.min}, cls=DotWizPlusEncoder) + + assert str(e.value) == 'Object of type datetime is not JSON serializable' From 539153a7d64e1b9152aa2a99f1dbbee923211417 Mon Sep 17 00:00:00 2001 From: Ritvik Nag Date: Thu, 22 Sep 2022 00:41:49 -0400 Subject: [PATCH 52/55] add tests for better coverage --- tests/unit/conftest.py | 24 +++++++++++++++++++++--- tests/unit/test_dotwiz.py | 32 ++++++++++++++++++++++++++++++++ tests/unit/test_dotwiz_plus.py | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 85 insertions(+), 3 deletions(-) diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 9bce402..ee82e63 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -1,4 +1,5 @@ """Reusable test utilities and fixtures.""" +from functools import cache from unittest.mock import MagicMock, mock_open import pytest @@ -9,10 +10,14 @@ class FileMock(MagicMock): - def __init__(self, mocker: MagicMock): - super().__init__() + def __init__(self, mocker: MagicMock = None, **kwargs): + super().__init__(**kwargs) - self.__dict__ = mocker.__dict__ + if mocker: + self.__dict__ = mocker.__dict__ + # configure mock object to replace the use of open(...) + # note: this is useful in scenarios where data is written out + _ = mock_open(mock=self) @property def read_data(self): @@ -23,6 +28,19 @@ def read_data(self, mock_data: str): """set mock data to be returned when `open(...).read()` is called.""" self.side_effect = mock_open(read_data=mock_data) + @property + @cache + def write_calls(self): + """a list of calls made to `open().write(...)`""" + handle = self.return_value + write: MagicMock = handle.write + return write.call_args_list + + @property + def write_lines(self) -> str: + """a list of written lines (as a string)""" + return ''.join([c[0][0] for c in self.write_calls]) + @pytest.fixture def mock_file_open(mocker: MockerFixture) -> FileMock: diff --git a/tests/unit/test_dotwiz.py b/tests/unit/test_dotwiz.py index 362cd37..7daa3aa 100644 --- a/tests/unit/test_dotwiz.py +++ b/tests/unit/test_dotwiz.py @@ -592,6 +592,38 @@ def test_to_json(): }""" +def test_to_json_with_filename(mock_file_open): + """Confirm intended functionality of `DotWiz.to_json` with `filename`""" + dw = DotWiz(hello=[{"Key": "value", "Another-KEY": {"a": "b"}}], + camelCased={r"th@#$%is.is.!@#$%^&*()a{}\:/~`.T'e'\"st": True}) + + mock_filename = 'out_file-TEST.json' + + # write out to dummy file + assert dw.to_json(filename=mock_filename, indent=4) is None + + # assert open(...) is called with expected arguments + mock_file_open.assert_called_once_with( + mock_filename, 'w', encoding='utf-8', errors='strict', + ) + + # assert expected mock data is written out + assert mock_file_open.write_lines == r""" +{ + "hello": [ + { + "Key": "value", + "Another-KEY": { + "a": "b" + } + } + ], + "camelCased": { + "th@#$%is.is.!@#$%^&*()a{}\\:/~`.T'e'\\\"st": true + } +}""".lstrip() + + def test_to_json_with_non_serializable_type(): """ Confirm intended functionality of `DotWiz.to_json` when an object diff --git a/tests/unit/test_dotwiz_plus.py b/tests/unit/test_dotwiz_plus.py index 00b485a..357be09 100644 --- a/tests/unit/test_dotwiz_plus.py +++ b/tests/unit/test_dotwiz_plus.py @@ -390,6 +390,38 @@ def test_to_json_with_snake_cased_keys(): }""".lstrip() +def test_to_json_with_filename(mock_file_open): + """Confirm intended functionality of `DotWizPlus.to_json` with `filename`""" + dw = DotWizPlus(hello=[{"Key": "value", "Another-KEY": {"a": "b"}}], + camelCased={r"th@#$%is.is.!@#$%^&*()a{}\:/~`.T'e'\"st": True}) + + mock_filename = 'out_file-TEST.json' + + # write out to dummy file + assert dw.to_json(filename=mock_filename, indent=4) is None + + # assert open(...) is called with expected arguments + mock_file_open.assert_called_once_with( + mock_filename, 'w', encoding='utf-8', errors='strict', + ) + + # assert expected mock data is written out + assert mock_file_open.write_lines == r""" +{ + "hello": [ + { + "Key": "value", + "Another-KEY": { + "a": "b" + } + } + ], + "camelCased": { + "th@#$%is.is.!@#$%^&*()a{}\\:/~`.T'e'\\\"st": true + } +}""".lstrip() + + def test_to_attr_dict(): """Confirm intended functionality of `DotWizPlus.to_dict`""" dw = DotWizPlus(hello=[{"items": "value", "Another-KEY": {"for": {"123": True}}}], From fe2d8948edca3f6d544dc737422b786929e50382 Mon Sep 17 00:00:00 2001 From: Ritvik Nag Date: Thu, 22 Sep 2022 12:41:31 -0400 Subject: [PATCH 53/55] add tests for better coverage --- tests/unit/test_dotwiz.py | 8 +- tests/unit/test_dotwiz_plus.py | 341 +++++++++++++++++++++++++++++++-- 2 files changed, 335 insertions(+), 14 deletions(-) diff --git a/tests/unit/test_dotwiz.py b/tests/unit/test_dotwiz.py index 7daa3aa..17501b0 100644 --- a/tests/unit/test_dotwiz.py +++ b/tests/unit/test_dotwiz.py @@ -239,7 +239,7 @@ def test_against_another_default_dict(self): assert dw != defaultdict(None, data) -class TestFromkeys: +class TestFromKeys: def test_fromkeys(self): assert DotWiz.fromkeys(["Bulbasaur", "Charmander", "Squirtle"]) == DotWiz( {"Bulbasaur": None, "Charmander": None, "Squirtle": None} @@ -311,7 +311,9 @@ def test_ior(): def test_popitem(): dw = DotWiz({"a": 1, "b": 2, "c": 3}) - dw.popitem() + # items are returned in a LIFO (last-in, first-out) order + (k, v) = dw.popitem() + assert (k, v) == ('c', 3) assert len(dw) == 2 @@ -454,7 +456,7 @@ def test_with_default(self, data, key, default): assert dw.pop(key, default) == default -class TestSetdefault: +class TestSetDefault: @pytest.mark.parametrize( "data,key,result", diff --git a/tests/unit/test_dotwiz_plus.py b/tests/unit/test_dotwiz_plus.py index 357be09..92be99e 100644 --- a/tests/unit/test_dotwiz_plus.py +++ b/tests/unit/test_dotwiz_plus.py @@ -1,4 +1,6 @@ """Tests for the `DotWizPlus` class.""" +from collections import OrderedDict, defaultdict +from copy import deepcopy from datetime import datetime import pytest @@ -94,6 +96,22 @@ def test_init(): assert dd.b == [1, 2, 3] +def test_init_with_skip_init(): + """Confirm intended functionality of `DotWizPlus.__init__` with `_skip_init`""" + # adding a constructor call with empty params, for comparison + dw = DotWizPlus() + assert dw.__dict__ == dw.__orig_dict__ == dw.__orig_keys__ == {} + + # now call the constructor with `_skip_init=True` + dw = DotWizPlus(_skip_init=True) + + assert dw.__dict__ == {} + + # assert that attributes aren't present in the `DotWizPlus` object + assert not hasattr(dw, '__orig_dict__') + assert not hasattr(dw, '__orig_keys__') + + def test_class_get_item(): """Using __class_get_item__() to subscript the types, i.e. DotWizPlus[K, V]""" dw = DotWizPlus[str, int](first_key=123, SecondKey=321) @@ -110,7 +128,7 @@ def test_del_attr(): dd = DotWizPlus( a=1, b={'one': [1], - 'two': [{'first': 'one', 'second': 'two'}]}, + 'two': [{'first': 'one', 'secondKey': 'two'}]}, three={'four': [{'five': '5'}]} ) @@ -124,9 +142,11 @@ def test_del_attr(): assert 'a' not in dd assert isinstance(dd.b, DotWizPlus) - assert dd.b.two[0].second == 'two' - del dd.b.two[0].second - assert 'second' not in dd.b.two[0] + assert dd.b.two[0].second_key == 'two' + + assert 'secondKey' in dd.b.two[0] + del dd.b.two[0].second_key + assert 'secondKey' not in dd.b.two[0] del dd.b assert 'b' not in dd @@ -135,15 +155,15 @@ def test_del_attr(): def test_get_attr(): """Confirm intended functionality of `DotWizPlus.__getattr__`""" dd = DotWizPlus() - dd.a = [{'one': 1, 'two': {'key': 'value'}}] + dd.a = [{'one': 1, 'two': {'Inner-Key': 'value'}}] item = getattr(dd, 'a')[0] assert isinstance(item, DotWizPlus) assert getattr(item, 'one') == 1 - assert getattr(getattr(item, 'two'), 'key') == 'value' + assert getattr(getattr(item, 'two'), 'inner_key') == 'value' # alternate way of writing the above - assert item.two.key == 'value' + assert item.two.inner_key == 'value' def test_get_item(): @@ -181,6 +201,240 @@ def test_set_item(): assert item.key_two == 2 +@pytest.mark.parametrize("data,result", [({"a": 42}, True), ({}, False)]) +def test_bool(data, result): + dw = DotWizPlus(data) + assert bool(dw) is result + + +def test_clear(): + dw = DotWizPlus({"a": 42}) + dw.clear() + assert len(dw) == 0 + + +def test_copy(): + data = {"a": 42} + dw = DotWizPlus(data) + assert dw.copy() == data + + +class TestEquals: + + def test_against_another_dot_wiz_plus(self): + data = {"a": 42} + dw = DotWizPlus(data) + assert dw == DotWizPlus(data) + + def test_against_another_dict(self): + data = {"a": 42} + dw = DotWizPlus(data) + assert dw == dict(data) + + def test_against_another_ordered_dict(self): + data = {"a": 42} + dw = DotWizPlus(data) + assert dw == OrderedDict(data) + + def test_against_another_default_dict(self): + data = {"a": 42} + dw = DotWizPlus(data) + assert dw == defaultdict(None, data) + + +class TestNotEquals: + + def test_against_another_dot_wiz_plus(self): + data = {"a": 42} + dw = DotWizPlus(a=41) + assert dw != DotWizPlus(data) + + def test_against_another_dict(self): + data = {"a": 42} + dw = DotWizPlus(a=41) + assert dw != dict(data) + + def test_against_another_ordered_dict(self): + data = {"a": 42} + dw = DotWizPlus(a=41) + assert dw != OrderedDict(data) + + def test_against_another_default_dict(self): + data = {"a": 42} + dw = DotWizPlus(a=41) + assert dw != defaultdict(None, data) + + +class TestFromKeys: + def test_fromkeys(self): + assert DotWizPlus.fromkeys(["Bulbasaur", "The-Charmander", "Squirtle"]) == DotWizPlus( + {"Bulbasaur": None, "The-Charmander": None, "Squirtle": None} + ) + + def test_fromkeys_with_default_value(self): + assert DotWizPlus.fromkeys(["Bulbasaur", "Charmander", "Squirtle"], "captured") == DotWizPlus( + {"Bulbasaur": "captured", "Charmander": "captured", "Squirtle": "captured"} + ) + + dw = DotWizPlus.fromkeys(['class', 'lambda', '123'], 'Value') + assert dw.class_ == dw.lambda_ == dw._123 == 'Value' + + +def test_items(): + dw = DotWizPlus({"a": 1, "secondKey": 2, "lambda": 3}) + assert sorted(dw.items()) == [("a", 1), ("lambda", 3), ("secondKey", 2)] + + +def test_iter(): + dw = DotWizPlus({"a": 1, "secondKey": 2, "c": 3}) + assert sorted([key for key in dw]) == ["a", "c", "secondKey"] + + +def test_keys(): + dw = DotWizPlus({"a": 1, "secondKey": 2, "c": 3}) + assert sorted(dw.keys()) == ["a", "c", "secondKey"] + + +def test_values(): + dw = DotWizPlus({"a": 1, "b": 2, "c": 3}) + assert sorted(dw.values()) == [1, 2, 3] + + +def test_len(): + dw = DotWizPlus({"a": 1, "b": 2, "c": 3}) + assert len(dw) == 3 + + +def test_reversed(): + dw = DotWizPlus({"a": 1, "secondKey": 2, "c": 3}) + assert list(reversed(dw)) == ["c", "secondKey", "a"] + + +@pytest.mark.parametrize( + "op1,op2,result", + [ + (DotWizPlus(a=1, b=2), DotWizPlus(b=1.5, c=3), DotWizPlus({'a': 1, 'b': 1.5, 'c': 3})), + (DotWizPlus(a=1, b=2), dict(b=1.5, c=3), DotWizPlus({'a': 1, 'b': 1.5, 'c': 3})), + ], +) +def test_or(op1, op2, result): + actual = op1 | op2 + + assert type(actual) == type(result) + assert actual == result + + +def test_ror(): + op1 = {'a': 1, 'b': 2} + op2 = DotWizPlus(b=1.5, c=3) + + assert op1 | op2 == DotWizPlus({'a': 1, 'b': 1.5, 'c': 3}) + + +# TODO: apparently __setitem__() or __or__() doesn't work with different cased +# keys are used for the update. Will have to look into how to best handle this. +def test_ior(): + op1 = DotWizPlus(a=1, secondKey=2) + op1 |= {'Second-Key': 1.5, 'c': 3} + + assert op1 == DotWizPlus({'a': 1, 'secondKey': 2, 'Second-Key': 1.5, 'c': 3}) + assert op1 != DotWizPlus({'a': 1, 'Second-Key': 1.5, 'c': 3}) + + +def test_popitem(): + dw = DotWizPlus({"a": 1, "b": 2, "c": 3, "class": 4}) + + assert len(dw) == len(dw.__dict__) == len(dw.__orig_dict__) == 4 + assert dw.__orig_keys__ == {'class_': 'class'} + + # items are returned in a LIFO (last-in, first-out) order + (k, v) = dw.popitem() + assert (k, v) == ('class', 4) + + assert len(dw) == len(dw.__dict__) == len(dw.__orig_dict__) == 3 + assert dw.__orig_keys__ == {} + + +@pytest.mark.parametrize( + "data,key,result", + [ + ({"this-key": 42}, "this-key", 42), + ({"this-key": 42}, "this_key", None), + ({"a": 42}, "b", None), + # TODO: enable once we set up dot-style access + # ({"a": {"b": 42}}, "a.b", 42), + # ({"a": {"b": {"c": 42}}}, "a.b.c", 42), + # ({"a": [42]}, "a[0]", 42), + # ({"a": [{"b": 42}]}, "a[0].b", 42), + # ({"a": [42]}, "a[1]", None), + # ({"a": [{"b": 42}]}, "a[1].b", None), + # ({"a": {"b": 42}}, "a.c", None), + # ({"a": {"b": {"c": 42}}}, "a.b.d", None), + ], +) +def test_get(data, key, result): + dw = DotWizPlus(data) + assert dw.get(key) == result + + +@pytest.mark.parametrize( + "data,key,default", + [ + ({}, "b", None), + ({"a": 42}, "b", "default"), + ], +) +def test_with_default(data, key, default): + dw = DotWizPlus(data) + assert dw.get(key, default) == default + + +class TestDelitem: + @pytest.mark.parametrize( + "data,key", + [ + ({"a": 42}, "a"), + ({"a": 1, "b": 2}, "b"), + ], + ) + def test_delitem(self, data, key): + dw = DotWizPlus(deepcopy(data)) + del dw[key] + assert key not in dw + + def test_key_error(self): + dw = DotWizPlus({"a": 1, "c": 3}) + with pytest.raises(KeyError): + del dw["b"] + + @pytest.mark.parametrize( + "data,key", + [ + ({False: "a"}, False), + ({1: "a", 2: "b"}, 2), + ], + ) + def test_type_error(self, data, key): + dw = DotWizPlus(deepcopy(data)) + # raises `TypeError` internally, but delete is still successful + del dw[key] + assert key not in dw + + +class TestContains: + @pytest.mark.parametrize( + "data,key,result", + [ + ({"MyKey": 42}, "MyKey", True), + ({"MyKey": 42}, "my_key", False), + ({"a": 42}, "b", False), + ], + ) + def test_contains(self, data, key, result): + dw = DotWizPlus(data) + assert (key in dw) is result + + def test_update(): """Confirm intended functionality of `DotWizPlus.update`""" dd = DotWizPlus(a=1, b={'one': [1]}) @@ -206,13 +460,78 @@ def test_update(): def test_update_with_no_args(): """Add for full branch coverage.""" - dd = DotWizPlus(a=1, b={'one': [1]}) + dd = DotWizPlus(First_Key=1, b={'one': [1]}) dd.update() - assert dd.a == 1 + assert dd.first_key == 1 + + dd.update(firstKey=2) + assert dd.first_key == 2 - dd.update(a=2) - assert dd.a == 2 + +class TestPop: + + @pytest.mark.parametrize( + "data,key,result", + [ + ({"a": 42}, "a", 42), + ({"a": 1, "b": 2}, "b", 2), + ], + ) + def test_pop(self, data, key, result): + dw = DotWizPlus(deepcopy(data)) + assert dw.pop(key) == result + assert key not in dw + + @pytest.mark.parametrize( + "data,key,default", + [ + ({}, "b", None), + ({"a": 1}, "b", 42), + ], + ) + def test_with_default(self, data, key, default): + dw = DotWizPlus(deepcopy(data)) + assert dw.pop(key, default) == default + + +class TestSetDefault: + + @pytest.mark.parametrize( + "data,key,result", + [ + ({"a": 42}, "a", 42), + ({"a": 1}, "b", None), + # ({"a": {"b": 42}}, "a.b", 42), + # ({"a": {"b": {"c": 42}}}, "a.b.c", 42), + # ({"a": [42]}, "a[0]", 42), + # ({"a": [{"b": 42}]}, "a[0].b", 42), + # ({"a": {"b": 1}}, "a.c", None), + # ({"a": {"b": {"c": 1}}}, "a.b.d", None), + # ({"a": [{"b": 1}]}, "a[0].c", None), + # ({"a": {"b": {"c": 42}}}, "a.d.e.f", None), + ], + ) + def test_setdefault(self, data, key, result): + dw = DotWizPlus(deepcopy(data)) + assert dw.setdefault(key) == result + assert dw[key] == result + + @pytest.mark.parametrize( + "data,key,default", + [ + ({}, "b", None), + ({"a": 1}, "b", "default"), + # ({"a": {"b": 1}}, "a.c", "default"), + # ({"a": {"b": {"c": 1}}}, "a.b.d", "default"), + # ({"a": [{"b": 1}]}, "a[0].c", "default"), + # ({"a": {"b": {"c": 42}}}, "a.d.e.f", "default"), + ], + ) + def test_with_default(self, data, key, default): + dw = DotWizPlus(deepcopy(data)) + assert dw.setdefault(key, default) == default + assert dw[key] == default def test_from_json(): From 96287412536ac37fb8bec6bf3cb5c3a01667d7c7 Mon Sep 17 00:00:00 2001 From: Ritvik Nag Date: Mon, 3 Oct 2022 19:55:15 -0400 Subject: [PATCH 54/55] bug: fix mutable default argument --- dotwiz/main.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/dotwiz/main.py b/dotwiz/main.py index 790240b..80e5c38 100644 --- a/dotwiz/main.py +++ b/dotwiz/main.py @@ -47,7 +47,11 @@ def __upsert_into_dot_wiz__(self, input_dict={}, """ if not _check_types: - __set__(self, '__dict__', input_dict) + __set__(self, '__dict__', kwargs) + + if input_dict: + kwargs.update(input_dict) + return None __dict = self.__dict__ From 1cb63a29467e6f7e3804a0d7fc7188a0a8d062b0 Mon Sep 17 00:00:00 2001 From: Ritvik Nag Date: Mon, 3 Oct 2022 23:55:20 -0400 Subject: [PATCH 55/55] fix reqs --- requirements-dev.txt | 1 + tests/unit/conftest.py | 10 +++++++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 60b7562..8fe9011 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -27,3 +27,4 @@ prodict==0.8.18 python-box==6.0.2 glom==22.1.0 scalpl==0.4.2 +backports.cached_property; python_version <= "3.7" diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index ee82e63..a63327c 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -1,5 +1,10 @@ """Reusable test utilities and fixtures.""" -from functools import cache +try: + from functools import cached_property +except ImportError: # Python <= 3.7 + # noinspection PyUnresolvedReferences, PyPackageRequirements + from backports.cached_property import cached_property + from unittest.mock import MagicMock, mock_open import pytest @@ -28,8 +33,7 @@ def read_data(self, mock_data: str): """set mock data to be returned when `open(...).read()` is called.""" self.side_effect = mock_open(read_data=mock_data) - @property - @cache + @cached_property def write_calls(self): """a list of calls made to `open().write(...)`""" handle = self.return_value