diff --git a/README.rst b/README.rst index be8359e..62bf96b 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`` wrapper that enables *dot access* notation via Python attribute style. Nested ``dict`` and ``list`` values are automatically transformed as well. @@ -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 ****************************** @@ -166,6 +172,16 @@ 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. + +Check out the `Usage`_ section in the docs for more details. + +.. _Usage: https://dotwiz.readthedocs.io/en/latest/usage.html#type-hints-and-auto-completion + Contributing ------------ diff --git a/benchmarks/conftest.py b/benchmarks/conftest.py new file mode 100644 index 0000000..a7b1c62 --- /dev/null +++ b/benchmarks/conftest.py @@ -0,0 +1,41 @@ +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(): + """ + Return a helper function to convert a `SimpleNamespace` object to + a `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 diff --git a/benchmarks/test_create.py b/benchmarks/test_create.py index 3cd3bd9..8585ad8 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 @@ -18,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')] @@ -77,6 +79,28 @@ 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_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) @@ -91,6 +115,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) @@ -152,6 +187,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 +225,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..bbf8387 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 @@ -19,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')] @@ -78,14 +83,19 @@ def assert_eq2(result): assert result['Some r@ndom#$(*#@ Key##$# here !!!'] == 'T' -def assert_eq3(result): +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 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 + 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' @@ -124,6 +134,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 @@ -161,6 +186,20 @@ 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_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) @@ -175,6 +214,20 @@ 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_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) @@ -234,6 +287,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 +319,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 2e23b87..edb14d6 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 @@ -19,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')] @@ -72,6 +74,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) @@ -141,6 +151,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) @@ -179,3 +197,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/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/docs/usage.rst b/docs/usage.rst index 72c3084..57950ef 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -72,12 +72,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 ~~~~~~~~~~~~~~~~ @@ -124,3 +130,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, + # ... + # } diff --git a/dotwiz/__init__.py b/dotwiz/__init__.py index 7175579..c8299d8 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 2172abc..8becca1 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.4.0' diff --git a/dotwiz/common.py b/dotwiz/common.py index 2e045e4..f24ca1a 100644 --- a/dotwiz/common.py +++ b/dotwiz/common.py @@ -1,70 +1,275 @@ """ Common (shared) helpers and utilities. """ +import json +from typing import Callable +from .encoders import DotWizEncoder, DotWizPlusEncoder -def __add_repr__(name, bases, cls_dict, *, print_char='*', use_attr_dict=False): + +# noinspection PyTypeChecker +__set__ = object.__setattr__ + + +def __add_common_methods__(name, bases, cls_dict, *, + print_char='*', + has_attr_dict=False): """ - Metaclass to generate and add a `__repr__` to a class. + Metaclass to generate and add common or shared methods -- such + as :meth:`__repr__` and :meth:`to_json` -- to a class. """ - # 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)})' - - 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)})' + # __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)})' + # add a `__repr__` magic method to the class. cls_dict['__repr__'] = __repr__ - return type(name, bases, cls_dict) + # __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__ -def __convert_to_attr_dict__(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): - return {k: __convert_to_attr_dict__(v) for k, v in o.__dict__.items()} + # 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. + # - `to_json` - serialize an instance as a JSON string. + # - `to_attr_dict` - optional, only if `has_attr_dict` is specified. - if isinstance(o, list): - return [__convert_to_attr_dict__(e) for e in o] + __cls__: type + __object_hook__: Callable - return o + 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) -def __convert_to_dict__(o, __items_fn=dict.items): - """ - Recursively convert an object (typically a `dict` subclass) to a - Python `dict` type. - """ - 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)} + 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): + """ + 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 + + # 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 + a Python `dict` type, while preserving snake-cased keys. + """ + __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_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. + """ + __dict = getattr(o, '__orig_dict__', None) + + if __dict: + 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_inner__(e) for e in o] + + return o + + def __convert_to_dict_preserve_keys__(o, snake=False): + if snake: + 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()} + + 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 = 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__ + + 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. + +: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__ + __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``.\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. + 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.' + ) + + # we only need to add a `to_dict` method in this case. + else: + + def __object_hook__(d): + return __cls__(d, _check_types=False) + + 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) + + 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. + +: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) +""" - if isinstance(o, list): - return [__convert_to_dict__(e) for e in o] + # 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 ' + f'a ``dict``.' + ) - return o + # finally, build and return the new class. + __cls__ = type(name, bases, cls_dict) + return __cls__ -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 33b1055..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,17 +9,18 @@ _KT = TypeVar('_KT') _VT = TypeVar('_VT') _ItemsFn = Callable[[_D ], ItemsView[_KT, _VT]] +_SetAttribute = Callable[[Union[DotWiz, DotWizPlus], str, Any], None] +__set__: _SetAttribute -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] : ... +def __add_common_methods__(name: str, + bases: tuple[type, ...], + cls_dict: dict[str, Any], + *, print_char='*', + has_attr_dict=False): ... -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], + check_lists=True) -> _T | _D | list[_D]: ... diff --git a/dotwiz/constants.py b/dotwiz/constants.py new file mode 100644 index 0000000..f986f22 --- /dev/null +++ b/dotwiz/constants.py @@ -0,0 +1,20 @@ +""" +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/encoders.py b/dotwiz/encoders.py new file mode 100644 index 0000000..891e38f --- /dev/null +++ b/dotwiz/encoders.py @@ -0,0 +1,48 @@ +""" +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) diff --git a/dotwiz/encoders.pyi b/dotwiz/encoders.pyi new file mode 100644 index 0000000..d84e372 --- /dev/null +++ b/dotwiz/encoders.pyi @@ -0,0 +1,9 @@ +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: ... diff --git a/dotwiz/main.py b/dotwiz/main.py index 4c9458f..80e5c38 100644 --- a/dotwiz/main.py +++ b/dotwiz/main.py @@ -1,10 +1,11 @@ """Main module.""" from .common import ( - __add_repr__, - __convert_to_dict__, + __add_common_methods__, __resolve_value__, + __set__, ) +from .constants import __PY_38_OR_ABOVE__, __PY_39_OR_ABOVE__ def make_dot_wiz(*args, **kwargs): @@ -26,13 +27,33 @@ def make_dot_wiz(*args, **kwargs): # noinspection PyDefaultArgument def __upsert_into_dot_wiz__(self, input_dict={}, - __set=dict.__setitem__, + _check_lists=True, + _check_types=True, **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 + to True. + :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. + :param kwargs: Additional keyword arguments to process, in addition to + `input_dict`. + """ + if not _check_types: + __set__(self, '__dict__', kwargs) + + if input_dict: + kwargs.update(input_dict) + + return None + __dict = self.__dict__ if kwargs: @@ -52,26 +73,92 @@ 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__()` - __set(self, key, value) __dict[key] = value -def __setitem_impl__(self, key, value, __set=dict.__setitem__): +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) - __set(self, key, value) self.__dict__[key] = value -class DotWiz(dict, metaclass=__add_repr__, print_char='✫'): +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, pragma: no cover + # 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__: # Python >= 3.9, pragma: no cover + def __merge_impl_fn__(op, check_lists=True): + """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) + + return DotWiz(__merged_dict, _check_types=False) + + return __merge_impl__ + + __or_impl__ = __merge_impl_fn__(dict.__or__) + __ror_impl__ = __merge_impl_fn__(dict.__ror__) + +else: # Python < 3.9, pragma: no cover + # 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): + """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 = {**self.__dict__, **__other_dict} + + return DotWiz(__merged_dict, _check_types=False) + + 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) + for k in other + } + __merged_dict = {**__other_dict, **self.__dict__} + + return DotWiz(__merged_dict, _check_types=False) + + +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) + for k in other + } + __update(self.__dict__, __other_dict) + + return self + + +class DotWiz(metaclass=__add_common_methods__, + print_char='✫'): """ - :class:`DotWiz` - a blazing *fast* ``dict`` subclass that also supports + :class:`DotWiz` - a blazing *fast* ``dict`` wrapper that also supports *dot access* notation. Usage:: @@ -81,18 +168,123 @@ class DotWiz(dict, metaclass=__add_repr__, print_char='✫'): >>> 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__', ) __init__ = update = __upsert_into_dot_wiz__ - __delattr__ = __delitem__ = dict.__delitem__ - __setattr__ = __setitem__ = __setitem_impl__ + def __bool__(self): + return True if self.__dict__ else False + + def __contains__(self, item): + # assuming that item is usually a `str`, this is actually faster + # than simply: `item in self.__dict__` + try: + _ = 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 __getitem__(self, key): - return self.__dict__[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] + + __setattr__ = __setitem__ = __setitem_impl__ + + def __iter__(self): + return iter(self.__dict__) + + def __len__(self): + return len(self.__dict__) + + __or__ = __or_impl__ + __ior__ = __ior_impl__ + __ror__ = __ror_impl__ + + __reversed__ = __reversed_impl__ + + def clear(self): + return self.__dict__.clear() + + def copy(self, __copy=dict.copy): + """ + Returns a shallow copy of the `dict` wrapped in :class:`DotWiz`. + + :return: DotWiz instance + """ + return DotWiz(__copy(self.__dict__), _check_types=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:`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: DotWiz instance + """ + return cls(__from_keys(seq, value)) + + def get(self, k, default=None, __get=dict.get): + """ + Get value from :class:`DotWiz` instance, or default if the key + does not exist. + """ + return __get(self.__dict__, k, default) + + def keys(self): + return self.__dict__.keys() + + def items(self): + return self.__dict__.items() + + def pop(self, key, *args): + return self.__dict__.pop(key, *args) + + def popitem(self): + return self.__dict__.popitem() + + 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 - to_dict = __convert_to_dict__ - to_dict.__doc__ = 'Recursively convert the :class:`DotWiz` instance ' \ - 'back to a ``dict``.' + def values(self): + return self.__dict__.values() diff --git a/dotwiz/main.pyi b/dotwiz/main.pyi index e0e6611..d31ffbf 100644 --- a/dotwiz/main.pyi +++ b/dotwiz/main.pyi @@ -1,10 +1,33 @@ -from typing import TypeVar, Callable, Protocol, Mapping, MutableMapping, Iterable +import json +from os import PathLike +from typing import ( + Callable, Protocol, TypeVar, Union, + Iterable, Iterator, Reversible, + KeysView, ItemsView, ValuesView, + Mapping, MutableMapping, AnyStr, Any, + overload, Generic, +) _T = TypeVar('_T') -_KT = TypeVar('_KT') -_VT = TypeVar('_VT') +_KT = TypeVar('_KT') # Key type. +_VT = TypeVar('_VT') # Value type. -_SetItem = Callable[[dict, _KT, _VT], None] +# Valid collection types in JSON. +_JSONList = list[Any] +_JSONObject = dict[str, Any] + +_Copy = Callable[[dict[_KT, _VT]], dict[_KT, _VT]] + + +class Encoder(Protocol): + """ + Represents an encoder for Python object -> JSON, e.g. analogous to + `json.dumps` + """ + + def __call__(self, obj: _JSONObject | _JSONList, + **kwargs) -> AnyStr: + ... # Ref: https://stackoverflow.com/a/68392079/10237506 class _Update(Protocol): @@ -12,6 +35,12 @@ class _Update(Protocol): __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: ... @@ -19,21 +48,67 @@ 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, + _check_types=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], + *, + check_lists=True + ) -> Callable[[DotWiz, DotWiz | dict], DotWiz]: ... + +def __or_impl__(self: DotWiz, + other: DotWiz | dict, + *, check_lists=True + ) -> DotWiz: ... + +def __ror_impl__(self: DotWiz, + other: DotWiz | dict, + *, check_lists=True + ) -> DotWiz: ... + +def __ior_impl__(self: DotWiz, + other: DotWiz | dict, + *, check_lists=True, + __update: _Update = dict.update): ... -class DotWiz(dict): +class DotWiz(Generic[_KT, _VT]): # noinspection PyDefaultArgument def __init__(self, input_dict: MutableMapping[_KT, _VT] = {}, - **kwargs: _T) -> None: ... + *, + _check_lists=True, + _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 _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. + :param kwargs: Additional keyword arguments to process, in addition to + `input_dict`. + + """ + ... + + 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: ... @@ -44,16 +119,114 @@ class DotWiz(dict): 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 __reversed__(self) -> Reversible: ... + + def __or__(self, other: DotWiz | dict) -> 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, + **decoder_kwargs + ) -> Union[DotWiz, list[DotWiz]]: + """ + 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. + """ + ... + def to_dict(self) -> dict[_KT, _VT]: """ Recursively convert the :class:`DotWiz` instance back to a ``dict``. """ ... + 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) + """ + ... + + def clear(self) -> None: ... + + def copy(self, + *, __copy: _Copy = dict.copy) -> DotWiz: ... + + # noinspection PyUnresolvedReferences + @classmethod + def fromkeys(cls: type[DotWiz], + 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] = {}, - *, __set: _SetItem = dict.__setitem__, + *, + _check_lists=True, + _check_types=True, **kwargs: _T) -> None: ... + def values(self) -> ValuesView: ... + def __repr__(self) -> str: ... diff --git a/dotwiz/plus.py b/dotwiz/plus.py index 00f1e10..b6542df 100644 --- a/dotwiz/plus.py +++ b/dotwiz/plus.py @@ -5,17 +5,19 @@ from pyheck import snake from .common import ( - __add_repr__, - __convert_to_attr_dict__, - __convert_to_dict__, + __add_common_methods__, __resolve_value__, + __set__, ) +from .constants import __PY_38_OR_ABOVE__, __PY_39_OR_ABOVE__ # A running cache of special-cased or non-lowercase keys that we've # transformed before. __SPECIAL_KEYS = {} +__GET_SPECIAL_KEY__ = __SPECIAL_KEYS.get + def make_dot_wiz_plus(*args, **kwargs): """ @@ -34,8 +36,8 @@ 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, __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 @@ -43,102 +45,256 @@ 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 - # 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] + __self_orig_keys[key] = 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): + 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 + # 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) + 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[key] = key = lower_snake + __SPECIAL_KEYS[orig_key] = key + __self_orig_keys[key] = orig_key # 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, + _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 + 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 + __dict = self.__dict__ if kwargs: - # avoids the potential pitfall of a "mutable default argument" - - # only update or modify `input_dict` if the param is passed in. if input_dict: input_dict.update(kwargs) else: input_dict = kwargs - for key in input_dict: - # note: this logic is the same as `__resolve_value__()` - # - # *however*, I decided to inline it because it's actually faster - # to eliminate a function call here. - value = input_dict[key] - t = type(value) + # 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) + + 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: - value = DotWizPlus(value) - elif 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__(self, __dict, 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 __setitem_impl__(self, key, value): + +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) - __store_in_object__(self, self.__dict__, key, value) + value = __resolve_value__(value, DotWizPlus, check_lists) + + __store_in_object__(self.__dict__, self.__orig_dict__, self.__orig_keys__, + key, value) + + +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, pragma: no cover + # 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__)) -class DotWizPlus(dict, metaclass=__add_repr__, +if __PY_39_OR_ABOVE__: # Python >= 3.9, pragma: no cover + def __merge_impl_fn__(op, check_lists=True): + """Implementation of `__or__` and `__ror__`, to merge `DotWizPlus` and `dict` objects.""" + + 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_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) + __set__(__merged, '__dict__', __merged_dict) + __set__(__merged, '__orig_dict__', __merged_orig_dict) + __set__(__merged, '__orig_keys__', __merged_orig_keys) + + return __merged + + return __merge_impl__ + + __or_impl__ = __merge_impl_fn__(dict.__or__) + __ror_impl__ = __merge_impl_fn__(dict.__ror__) + +else: # Python < 3.9, pragma: no cover + # 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): + """Implementation of `__or__` to merge `DotWizPlus` and `dict` objects.""" + __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_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) + + return __merged + + def __ror_impl__(self, other, check_lists=True): + """Implementation of `__ror__` to merge `DotWizPlus` and `dict` objects.""" + __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_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) + + return __merged + + +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, __orig_keys, key, value) + + return self + + +class DotWizPlus(metaclass=__add_common_methods__, print_char='✪', - use_attr_dict=True): + 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. @@ -154,6 +310,8 @@ class DotWizPlus(dict, metaclass=__add_repr__, {'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** @@ -180,24 +338,162 @@ class DotWizPlus(dict, metaclass=__add_repr__, .. _this example: https://dotwiz.readthedocs.io/en/latest/usage.html#complete-example """ - __slots__ = ('__dict__', ) + __slots__ = ( + '__dict__', + '__orig_dict__', + '__orig_keys__', + ) __init__ = update = __upsert_into_dot_wiz_plus__ + 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] + # noinspection PyUnresolvedReferences + 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] + + __orig_key = self.__orig_keys__.pop(item, item) + del self.__orig_dict__[__orig_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__[__dict_key] + del self.__orig_dict__[key] + # __getattr__: Use the default `object.__getattr__` implementation. - # __getitem__: Use the default `dict.__getitem__` implementation. - __delattr__ = __delitem__ = dict.__delitem__ - __setattr__ = __setitem__ = __setitem_impl__ + def __getitem__(self, key): + return self.__orig_dict__[key] + + __setattr__ = __setattr_impl__ + __setitem__ = __setitem_impl__ + + def __iter__(self): + return iter(self.__orig_dict__) + + def __len__(self): + return len(self.__orig_dict__) + + __or__ = __or_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, __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__)) + + return dw + + # 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=dict.get): + """ + Get value from :class:`DotWizPlus` instance, or default if the key + does not exist. + """ + return __get(self.__orig_dict__, k, default) + + def keys(self): + return self.__orig_dict__.keys() + + def items(self): + return self.__orig_dict__.items() + + def pop(self, key, *args): + result = self.__orig_dict__.pop(key, *args) + + __dict_key = __GET_SPECIAL_KEY__(key) + if __dict_key: + del self.__orig_keys__[__dict_key] + else: + __dict_key = key + + _ = self.__dict__.pop(__dict_key, None) + return result + + def popitem(self): + key, _ = self.__dict__.popitem() + self.__orig_keys__.pop(key, None) + + return self.__orig_dict__.popitem() + + 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. + """ + result = __get(self.__orig_dict__, k) + + if result is not None: + return result + + default = __resolve_value__(default, DotWizPlus, check_lists) + __store_in_object__( + self.__dict__, self.__orig_dict__, self.__orig_keys__, k, default + ) - 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.' + return default - to_dict = __convert_to_dict__ - to_dict.__doc__ = 'Recursively convert the :class:`DotWizPlus` instance ' \ - 'back to a ``dict``.' + 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 16b25f5..c6a5987 100644 --- a/dotwiz/plus.pyi +++ b/dotwiz/plus.pyi @@ -1,48 +1,144 @@ -import keyword -from typing import TypeVar, Callable, Protocol, Mapping, MutableMapping, Iterable +import json +from os import PathLike +from typing import ( + Callable, Protocol, TypeVar, Union, + Iterable, Iterator, Reversible, + KeysView, ItemsView, ValuesView, + Mapping, MutableMapping, AnyStr, Any, + overload, Generic, +) _T = TypeVar('_T') -_KT = TypeVar('_KT') -_VT = TypeVar('_VT') +_KT = TypeVar('_KT') # Key type. +_VT = TypeVar('_VT') # Value type. -_SetItem = Callable[[dict, _KT, _VT], None] +# Valid collection types in JSON. +_JSONList = list[Any] +_JSONObject = dict[str, Any] + +_Clear = Callable[[dict[_KT, _VT]], None] +_Copy = Callable[[dict[_KT, _VT]], dict[_KT, _VT]] + + +class Encoder(Protocol): + """ + Represents an encoder for Python object -> JSON, e.g. analogous to + `json.dumps` + """ + + def __call__(self, obj: _JSONObject | _JSONList, + **kwargs) -> AnyStr: + ... # 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 = ... __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], + __self_orig_keys: MutableMapping[str, _KT], 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, + _check_types=True, + _skip_init=False, **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) -> None: ... + value: _VT, + *, check_lists=True) -> None: ... + +def __merge_impl_fn__(op: Callable[[dict, dict], dict], + *, check_lists=True, + ) -> Callable[[DotWizPlus, DotWizPlus | dict], DotWizPlus]: ... + +def __or_impl__(self: DotWizPlus, + other: DotWizPlus | dict, + *, check_lists=True, + ) -> DotWizPlus: ... + +def __ror_impl__(self: DotWizPlus, + other: DotWizPlus | dict, + *, check_lists=True, + ) -> DotWizPlus: ... + +def __ior_impl__(self: DotWizPlus, + other: DotWizPlus | dict, + *, check_lists=True, + __update: _Update = dict.update): ... -class DotWizPlus(dict): +class DotWizPlus(Generic[_KT, _VT]): + + __dict__: dict[_KT, _VT] + __orig_dict__: dict[_KT, _VT] + __orig_keys__: dict[str, _KT] # noinspection PyDefaultArgument def __init__(self, input_dict: MutableMapping[_KT, _VT] = {}, - **kwargs: _T) -> None: ... + *, + _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 + 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: ... + + def __eq__(self, other: object) -> bool: ... + def __ne__(self, other: object) -> bool: ... def __delattr__(self, item: str) -> None: ... def __delitem__(self, v: _KT) -> None: ... @@ -53,6 +149,52 @@ class DotWizPlus(dict): 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 __reversed__(self) -> Reversible: ... + + def __or__(self, other: DotWizPlus | dict) -> 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, + **decoder_kwargs + ) -> Union[DotWizPlus, list[DotWizPlus]]: + """ + 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. + """ + ... + + 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. + """ + ... + def to_attr_dict(self) -> dict[_KT, _VT]: """ Recursively convert the :class:`DotWizPlus` instance back to a ``dict``, @@ -60,15 +202,81 @@ class DotWizPlus(dict): """ ... - def to_dict(self) -> dict[_KT, _VT]: + 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: """ - Recursively convert the :class:`DotWizPlus` instance back to a ``dict``. + 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) """ ... + def clear(self, + *, __clear: _Clear = dict.clear) -> None: ... + + def copy(self, + *, __copy: _Copy = dict.copy, + ) -> 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] = {}, + *, + _check_lists=True, + _check_types=True, + _skip_init=False, **kwargs: _T) -> None: ... + def values(self) -> ValuesView: ... + + def __dir__(self) -> Iterable[str]: ... def __repr__(self) -> str: ... 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 diff --git a/requirements-dev.txt b/requirements-dev.txt index e265624..8fe9011 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -8,8 +8,9 @@ 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 dataclass-wizard==0.22.1 # for loading dict to nested dataclass @@ -20,8 +21,10 @@ dotsi==0.0.3 dotted-dict==1.1.3 dotty-dict==1.3.1 addict==2.4.0 +attrdict3==2.0.2 metadict==0.1.2 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 f863ab4..a63327c 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -1,9 +1,56 @@ """Reusable test utilities and fixtures.""" +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 +from pytest_mock import MockerFixture from dotwiz import DotWiz, DotWizPlus +class FileMock(MagicMock): + + def __init__(self, mocker: MagicMock = None, **kwargs): + super().__init__(**kwargs) + + 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): + 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) + + @cached_property + 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: + return FileMock(mocker.patch('builtins.open')) + + class CleanupGetAttr: def teardown_method(self): diff --git a/tests/unit/test_dotwiz.py b/tests/unit/test_dotwiz.py index 1ef9c3b..17501b0 100644 --- a/tests/unit/test_dotwiz.py +++ b/tests/unit/test_dotwiz.py @@ -1,4 +1,7 @@ """Tests for `dotwiz` package.""" +from collections import OrderedDict, defaultdict +from copy import deepcopy +from datetime import datetime import pytest @@ -7,7 +10,7 @@ from .conftest import CleanupGetAttr -def test_dot_wiz_with_basic_usage(): +def test_basic_usage(): """Confirm intended functionality of `DotWiz`""" dw = DotWiz({'key_1': [{'k': 'v'}], 'keyTwo': '5', @@ -65,7 +68,7 @@ def test_overwrite_raises_an_error_by_default(self): assert 'pass `overwrite=True`' in str(e.value) -def test_dotwiz_init(): +def test_init(): """Confirm intended functionality of `DotWiz.__init__`""" dd = DotWiz({ 1: 'test', @@ -86,7 +89,19 @@ def test_dotwiz_init(): assert dd.b == [1, 2, 3] -def test_dotwiz_del_attr(): +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, b={'one': [1], @@ -112,7 +127,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'}}] @@ -126,7 +141,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'}}] @@ -138,7 +153,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}] @@ -149,7 +164,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}] @@ -160,7 +175,228 @@ 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_reversed(): + dw = DotWiz({"a": 1, "b": 2, "c": 3}) + 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 actual == 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}) + # items are returned in a LIFO (last-in, first-out) order + (k, v) = dw.popitem() + assert (k, v) == ('c', 3) + 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) @@ -183,7 +419,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]}) @@ -194,7 +430,140 @@ 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_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"}}]) @@ -206,3 +575,67 @@ def test_dotwiz_to_dict(): } ] } + + +def test_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_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 + 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 6d21c9c..92be99e 100644 --- a/tests/unit/test_dotwiz_plus.py +++ b/tests/unit/test_dotwiz_plus.py @@ -1,4 +1,7 @@ """Tests for the `DotWizPlus` class.""" +from collections import OrderedDict, defaultdict +from copy import deepcopy +from datetime import datetime import pytest @@ -7,7 +10,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 +75,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,11 +96,39 @@ def test_dotwiz_plus_init(): assert dd.b == [1, 2, 3] -def test_dotwiz_plus_del_attr(): +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) + + # 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], - 'two': [{'first': 'one', 'second': 'two'}]}, + 'two': [{'first': 'one', 'secondKey': 'two'}]}, three={'four': [{'five': '5'}]} ) @@ -111,73 +142,310 @@ def test_dotwiz_plus_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 -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'}}] + 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_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 + + +@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_dotwiz_plus_update(): +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]}) 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,23 +453,156 @@ 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]}) + 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 + + +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(): + """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}] + } + ) - dd.update(a=2) - assert dd.a == 2 + assert dw.second_key[1].nested_key -def test_dotwiz_plus_to_dict(): +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_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}) @@ -219,16 +620,141 @@ def test_dotwiz_plus_to_dict(): } -def test_dotwiz_plus_to_attr_dict(): - """Confirm intended functionality of `DotWizPlus.to_dict`""" +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}) + 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_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_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}}}], + 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}, @@ -246,3 +772,34 @@ 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 + + +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'