diff --git a/.gitignore b/.gitignore index af1b6b7..e6bfa66 100644 --- a/.gitignore +++ b/.gitignore @@ -2,12 +2,14 @@ __pycache__/* *.pyc +# Virtual Environment +env/* + # Packaging build/* dist/* scalpl.egg-info/* - # Mypy .mypy_cache/* @@ -18,3 +20,6 @@ htmlcov/* # Tests reddit.json .cache/* + +# IDE +.vscode/ diff --git a/.travis.yml b/.travis.yml index c831bec..f912f7a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -9,5 +9,5 @@ install: - pip3 install black script: - - pytest tests.py - - black --check scalpl.py tests.py setup.py + - pytest + - black --check scalpl tests setup.py diff --git a/README.rst b/README.rst index 34c7221..32dbb2b 100644 --- a/README.rst +++ b/README.rst @@ -1,4 +1,4 @@ -.. image:: https://raw.githubusercontent.com/ducdetronquito/scalpl/master/scalpl.png +.. image:: https://raw.githubusercontent.com/ducdetronquito/scalpl/master/assets/scalpl.png :target: https://github.com/ducdetronquito/scalpl Scalpl @@ -10,7 +10,7 @@ Scalpl .. image:: https://img.shields.io/badge/coverage-100%25-green.svg :target: # -.. image:: https://img.shields.io/badge/pypi-v0.2.6-blue.svg +.. image:: https://img.shields.io/badge/pypi-v0.3.0-blue.svg :target: https://pypi.python.org/pypi/scalpl/ .. image:: https://travis-ci.org/ducdetronquito/scalpl.svg?branch=master @@ -54,40 +54,34 @@ such as `Addict `_ or `Box `_ , but if you give **Scalpl** a try, you will find it: -* ⚡ Fast * 🚀 Powerful as the standard dict API +* ⚡ Lightweight * 👌 Well tested Installation ~~~~~~~~~~~~ -**Scalpl** is a Python3-only module that you can install via ``pip`` +**Scalpl** is a Python3 library that you can install via ``pip`` .. code:: sh pip3 install scalpl + Usage ~~~~~ -**Scalpl** provides two classes that can wrap around your dictionaries: - -- **LightCut**: a wrapper that handles operations on nested ``dict``. -- **Cut**: a wrapper that handles operations on nested ``dict`` and - that can cut accross ``list`` item. +**Scalpl** provides a simple class named **Cut** that wraps around your dictionary +and handles operations on nested ``dict`` and that can cut accross ``list`` item. -Usually, you will only need to use the ``Cut`` wrapper, but if you do -not need to operate through lists, you should work with the ``LightCut`` -wrapper as its computation overhead is a bit smaller. - -These two wrappers strictly follow the standard ``dict`` -`API `_, that +This wrapper strictly follows the standard ``dict`` +`API `_, which means you can operate seamlessly on ``dict``, -``collections.defaultdict`` or ``collections.OrderedDict``. - +``collections.defaultdict`` or ``collections.OrderedDict`` by using their methods +with dot-separated keys. -Let's see what it looks like with a toy dictionary ! 👇 +Let's see what it looks like with an example ! 👇 .. code:: python @@ -215,6 +209,7 @@ remove all keys. proxy.clear() + Benchmark ~~~~~~~~~ @@ -223,56 +218,57 @@ of `Scalpl `_ compared to `Addict `_ and the built-in ``dict``. It will summarize the *number of operations per second* that each library is -able to perform on the JSON dump of the `Python subreddit main page `_. +able to perform on a portion of the JSON dump of the `Python subreddit main page `_. You can run this benchmark on your machine with the following command: - python3 ./performance_tests.py + python3 ./benchmarks/performance_comparison.py Here are the results obtained on an Intel Core i5-7500U CPU (2.50GHz) with **Python 3.6.4**. -**Addict**:: - instanciate:-------- 18,485 ops per second. - get:---------------- 18,806 ops per second. - get through list:--- 18,599 ops per second. - set:---------------- 18,797 ops per second. - set through list:--- 18,129 ops per second. +**Addict** 2.2.1:: + + instanciate:-------- 271,132 ops per second. + get:---------------- 276,090 ops per second. + get through list:--- 293,773 ops per second. + set:---------------- 300,324 ops per second. + set through list:--- 282,149 ops per second. -**Box**:: +**Box** 3.4.2:: - instanciate:--------- 4,150,396 ops per second. - get:----------------- 1,424,529 ops per second. - get through list:---- 110,926 ops per second. - set:----------------- 1,332,435 ops per second. - set through list:---- 110,833 ops per second. + instanciate:--------- 4,093,439 ops per second. + get:----------------- 957,069 ops per second. + get through list:---- 164,013 ops per second. + set:----------------- 900,466 ops per second. + set through list:---- 165,522 ops per second. -**Scalpl**:: +**Scalpl** latest:: - instanciate:-------- 136,517,371 ops per second. - get:---------------- 24,918,648 ops per second. - get through list:--- 12,624,630 ops per second. - set:---------------- 26,409,542 ops per second. - set through list:--- 13,765,265 ops per second. + instanciate:-------- 183,879,865 ops per second. + get:---------------- 14,941,355 ops per second. + get through list:--- 14,175,349 ops per second. + set:---------------- 11,320,968 ops per second. + set through list:--- 11,956,001 ops per second. **dict**:: - instanciate:--------- 92,119,547 ops per second. - get:----------------- 186,290,996 ops per second. - get through list:---- 178,747,154 ops per second. - set:----------------- 159,224,669 ops per second. - set through list :--- 79,294,520 ops per second. + instanciate:--------- 37,816,714 ops per second. + get:----------------- 84,317,032 ops per second. + get through list:---- 62,480,474 ops per second. + set:----------------- 146,484,375 ops per second. + set through list :--- 122,473,974 ops per second. -As a conclusion and despite being ~10 times slower than the built-in -``dict``, **Scalpl** is ~20 times faster than Box on simple read/write -operations, and ~100 times faster when it traverse lists. **Scalpl** is -also ~1300 times faster than Addict. +As a conclusion and despite being an order of magniture slower than the built-in +``dict``, **Scalpl** is faster than Box and Addict by an order of magnitude for any operations. +Besides, the gap increase in favor of **Scalpl** when wrapping large dictionaries. -However, do not trust benchmarks and test it on a real use-case. +Keeping in mind that this benchmark may vary depending on your use-case, it is very unlikely that +**Scalpl** will become a bottleneck of your application. Frequently Asked Questions: @@ -295,6 +291,7 @@ Frequently Asked Questions: proxy = Cut(data) proxy['it works perfectly'] = 'fine' + How to Contribute ~~~~~~~~~~~~~~~~~ diff --git a/scalpl.png b/assets/scalpl.png similarity index 100% rename from scalpl.png rename to assets/scalpl.png diff --git a/benchmarks/perfomance_comparison.py b/benchmarks/perfomance_comparison.py new file mode 100644 index 0000000..ffc2755 --- /dev/null +++ b/benchmarks/perfomance_comparison.py @@ -0,0 +1,168 @@ +from copy import deepcopy +import json +from timeit import timeit +import unittest + +from scalpl import Cut + +from addict import Dict +from box import Box +import requests + + +class TestDictPerformance(unittest.TestCase): + """ + Base class to test performance of different + dict wrapper regarding insertion and lookup. + """ + + # We use a portion of the JSON dump of the Python Reddit page. + + PYTHON_REDDIT = { + "kind": "Listing", + "data": { + "modhash": "", + "dist": 27, + "children": [ + { + "kind": "t3", + "data": { + "approved_at_utc": None, "subreddit": "Python", + "selftext": "Top Level comments must be **Job Opportunities.**\n\nPlease include **Location** or any other **Requirements** in your comment. If you require people to work on site in San Francisco, *you must note that in your post.* If you require an Engineering degree, *you must note that in your post*.\n\nPlease include as much information as possible.\n\nIf you are looking for jobs, send a PM to the poster.", + "author_fullname": "t2_628u", "saved": False, + "mod_reason_title": None, "gilded": 0, "clicked": False, "title": "r/Python Job Board", "link_flair_richtext": [], + "subreddit_name_prefixed": "r/Python", "hidden": False, "pwls": 6, "link_flair_css_class": None, "downs": 0, "hide_score": False, "name": "t3_cmq4jj", + "quarantine": False, "link_flair_text_color": "dark", "author_flair_background_color": "", "subreddit_type": "public", "ups": 11, "total_awards_received": 0, + "media_embed": {}, "author_flair_template_id": None, "is_original_content": False, "user_reports": [], "secure_media": None, + "is_reddit_media_domain": False, "is_meta": False, "category": None, "secure_media_embed": {}, "link_flair_text": None, "can_mod_post": False, + "score": 11, "approved_by": None, "thumbnail": "", "edited": False, "author_flair_css_class": "", "author_flair_richtext": [], "gildings": {}, + "content_categories": None, "is_self": True, "mod_note": None, "created": 1565124336.0, "link_flair_type": "text", "wls": 6, "banned_by": None, + "author_flair_type": "text", "domain": "self.Python", + "allow_live_comments": False, + "selftext_html": "<!-- SC_OFF --><div class=\"md\"><p>Top Level comments must be <strong>Job Opportunities.</strong></p>\n\n<p>Please include <strong>Location</strong> or any other <strong>Requirements</strong> in your comment. If you require people to work on site in San Francisco, <em>you must note that in your post.</em> If you require an Engineering degree, <em>you must note that in your post</em>.</p>\n\n<p>Please include as much information as possible.</p>\n\n<p>If you are looking for jobs, send a PM to the poster.</p>\n</div><!-- SC_ON -->", + "likes": None, "suggested_sort": None, "banned_at_utc": None, "view_count": None, "archived": False, "no_follow": False, "is_crosspostable": False, "pinned": False, + "over_18": False, "all_awardings": [], "media_only": False, "can_gild": False, "spoiler": False, "locked": False, "author_flair_text": "reticulated", + "visited": False, "num_reports": None, "distinguished": None, "subreddit_id": "t5_2qh0y", "mod_reason_by": None, "removal_reason": None, "link_flair_background_color": "", + "id": "cmq4jj", "is_robot_indexable": True, "report_reasons": None, "author": "aphoenix", "num_crossposts": 0, "num_comments": 2, "send_replies": False, "whitelist_status": "all_ads", + "contest_mode": False, "mod_reports": [], "author_patreon_flair": False, "author_flair_text_color": "dark", + "permalink": "/r/Python/comments/cmq4jj/rpython_job_board/", "parent_whitelist_status": "all_ads", "stickied": True, + "url": "https://www.reddit.com/r/Python/comments/cmq4jj/rpython_job_board/", "subreddit_subscribers": 399170, "created_utc": 1565095536.0, + "discussion_type": None, "media": None, "is_video": False + } + } + ] + } + } + + namespace = { + 'Wrapper': dict + } + + def setUp(self): + self.data = deepcopy(self.PYTHON_REDDIT) + self.namespace.update(self=self) + + def execute(self, statement, method): + n = 1000 + time = timeit(statement, globals=self.namespace, number=n) + print( + '# ', + self.namespace['Wrapper'], + ' - ', + method, + ': ', + int(60 / (time/n)), + ' ops per second.' + ) + + def test_init(self): + self.execute('Wrapper(self.data)', 'instanciate') + + def test_getitem(self): + self.execute("Wrapper(self.data)['data']['modhash']", 'get') + + def test_getitem_through_list(self): + statement = ( + "Wrapper(self.data)['data']['children'][0]['data']['author']" + ) + self.execute(statement, 'get through list') + + def test_setitem(self): + statement = "Wrapper(self.data)['data']['modhash'] = 'dunno'" + self.execute(statement, 'set') + + def test_setitem_through_list(self): + statement = ( + "Wrapper(self.data)['data']['children'][0]" + "['data']['author'] = 'Captain Obvious'" + ) + self.execute(statement, 'set through list') + + +class TestCutPerformance(TestDictPerformance): + + namespace = { + 'Wrapper': Cut + } + + def test_getitem(self): + self.execute("Wrapper(self.data)['data.modhash']", 'get') + + def test_getitem_through_list(self): + statement = ( + "Wrapper(self.data)['data.children[0].data.author']" + ) + self.execute(statement, 'get through list') + + def test_setitem(self): + statement = "Wrapper(self.data)['data.modhash'] = 'dunno'" + self.execute(statement, 'set') + + def test_setitem_through_list(self): + statement = ( + "Wrapper(self.data)['data.children[0]" + ".data.author'] = 'Captain Obvious'" + ) + self.execute(statement, 'set through list') + + +class TestBoxPerformance(TestDictPerformance): + + namespace = { + 'Wrapper': Box + } + + def test_getitem(self): + self.execute("Wrapper(self.data).data.modhash", 'get - 1st lookup') + self.execute("Wrapper(self.data).data.modhash", 'get - 2nd lookup') + + def test_getitem_through_list(self): + statement = ( + "Wrapper(self.data).data.children[0].data.author" + ) + self.execute(statement, 'get through list - 1st lookup') + self.execute(statement, 'get through list - 2nd lookup') + + def test_setitem(self): + statement = "Wrapper(self.data).data.modhash = 'dunno'" + self.execute(statement, 'set - 1st lookup') + self.execute(statement, 'set - 2nd lookup') + + def test_setitem_through_list(self): + statement = ( + "Wrapper(self.data).data.children[0]" + ".data.author = 'Captain Obvious'" + ) + self.execute(statement, 'set through list - 1st lookup') + self.execute(statement, 'set through list - 2nd lookup') + + +class TestAddictPerformance(TestDictPerformance): + + namespace = { + 'Wrapper': Dict + } + + +if __name__ == '__main__': + unittest.main() diff --git a/performance_tests.py b/performance_tests.py deleted file mode 100644 index d82f590..0000000 --- a/performance_tests.py +++ /dev/null @@ -1,143 +0,0 @@ -from copy import deepcopy -import json -from timeit import timeit -import unittest - -from scalpl import Cut - -from addict import Dict -from box import Box -import requests - - -class TestDictPerformance(unittest.TestCase): - """ - Base class to test performance of different - dict wrapper regarding insertion and lookup. - """ - - # We use the JSON dump of the Python Reddit page. - # We only collect it once. - try: - with open('reddit.json', 'r') as f: - PYTHON_REDDIT = json.loads(f.read()) - except: - PYTHON_REDDIT = requests.get( - 'https://reddit.com/r/Python/.json' - ).json() - - with open('reddit.json', 'w') as f: - f.write(json.dumps(PYTHON_REDDIT)) - - namespace = { - 'Wrapper': dict - } - - def setUp(self): - self.data = deepcopy(self.PYTHON_REDDIT) - self.namespace.update(self=self) - - def execute(self, statement, method): - n = 1000 - time = timeit(statement, globals=self.namespace, number=n) - print( - '# ', - self.namespace['Wrapper'], - ' - ', - method, - ': ', - int(60 / (time/n)), - ' ops per second.' - ) - - def test_init(self): - self.execute('Wrapper(self.data)', 'instanciate') - - def test_getitem(self): - self.execute("Wrapper(self.data)['data']['modhash']", 'get') - - def test_getitem_through_list(self): - statement = ( - "Wrapper(self.data)['data']['children'][0]['data']['author']" - ) - self.execute(statement, 'get through list') - - def test_setitem(self): - statement = "Wrapper(self.data)['data']['modhash'] = 'dunno'" - self.execute(statement, 'set') - - def test_setitem_through_list(self): - statement = ( - "Wrapper(self.data)['data']['children'][0]" - "['data']['author'] = 'Captain Obvious'" - ) - self.execute(statement, 'set through list') - - -class TestCutPerformance(TestDictPerformance): - - namespace = { - 'Wrapper': Cut - } - - def test_getitem(self): - self.execute("Wrapper(self.data)['data.modhash']", 'get') - - def test_getitem_through_list(self): - statement = ( - "Wrapper(self.data)['data.children[0].data.author']" - ) - self.execute(statement, 'get through list') - - def test_setitem(self): - statement = "Wrapper(self.data)['data.modhash'] = 'dunno'" - self.execute(statement, 'set') - - def test_setitem_through_list(self): - statement = ( - "Wrapper(self.data)['data.children[0]" - ".data.author'] = 'Captain Obvious'" - ) - self.execute(statement, 'set through list') - - -class TestBoxPerformance(TestDictPerformance): - - namespace = { - 'Wrapper': Box - } - - def test_getitem(self): - self.execute("Wrapper(self.data).data.modhash", 'get - 1st lookup') - self.execute("Wrapper(self.data).data.modhash", 'get - 2nd lookup') - - def test_getitem_through_list(self): - statement = ( - "Wrapper(self.data).data.children[0].data.author" - ) - self.execute(statement, 'get through list - 1st lookup') - self.execute(statement, 'get through list - 2nd lookup') - - def test_setitem(self): - statement = "Wrapper(self.data).data.modhash = 'dunno'" - self.execute(statement, 'set - 1st lookup') - self.execute(statement, 'set - 2nd lookup') - - def test_setitem_through_list(self): - statement = ( - "Wrapper(self.data).data.children[0]" - ".data.author = 'Captain Obvious'" - ) - self.execute(statement, 'set through list - 1st lookup') - self.execute(statement, 'set through list - 2nd lookup') - - -class TestAddictPerformance(TestDictPerformance): - - namespace = { - 'Wrapper': Dict - } - - -if __name__ == '__main__': - unittest.main() diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..be57201 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +python_files = tests/* diff --git a/scalpl.py b/scalpl.py deleted file mode 100644 index 72b4a5c..0000000 --- a/scalpl.py +++ /dev/null @@ -1,203 +0,0 @@ -""" - A lightweight wrapper to operate on nested dictionaries seamlessly. -""" -from typing import Any, Optional -from itertools import chain - - -class LightCut: - """ - LightCut is a simple wrapper over the built-in dict class. - - It enables the standard dict API to operate on nested dictionnaries - by using dot-separated string keys. - - ex: - query = {...} # Any dict structure - proxy = Cut(query) - proxy['pokemons.charmander.level'] - proxy['pokemons.charmander.level'] = 666 - """ - - __slots__ = ("data", "sep") - - def __init__(self, data: Optional[dict] = None, sep: str = ".") -> None: - self.data = data or {} - self.sep = sep - - def __bool__(self) -> bool: - return bool(self.data) - - def __contains__(self, key: str) -> bool: - try: - parent, last_key = self._traverse(self.data, key) - parent[last_key] - return True - except (IndexError, KeyError): - return False - - def __delitem__(self, key: str) -> None: - try: - parent, last_key = self._traverse(self.data, key) - del parent[last_key] - except KeyError: - raise KeyError('key "' + key + '" not found.') - except IndexError: - raise IndexError('index out of range in key "' + key + '".') - - def __eq__(self, other: Any) -> bool: - return self.data == other - - def __getitem__(self, key: str) -> Any: - try: - parent, last_key = self._traverse(self.data, key) - return parent[last_key] - except KeyError: - raise KeyError('key "' + key + '" not found.') - except IndexError: - raise IndexError('index out of range in key "' + key + '".') - - def __iter__(self): - return iter(self.data) - - def __len__(self) -> int: - return len(self.data) - - def __ne__(self, other: Any) -> bool: - return self.data != other - - def __setitem__(self, key: str, value: Any) -> None: - try: - parent, last_key = self._traverse(self.data, key) - parent[last_key] = value - except KeyError: - raise KeyError('key "' + key + '" not found.') - except IndexError: - raise IndexError('index out of range in key "' + key + '".') - - def __str__(self) -> str: - return str(self.data) - - def _traverse(self, parent, key: str): - *parent_keys, last_key = key.split(self.sep) - if parent_keys: - for key in parent_keys: - parent = parent[key] - return parent, last_key - - def all(self, key: str): - """Wrap each item of an Iterable.""" - items = self[key] - cls = self.__class__ - return (cls(_dict, self.sep) for _dict in items) - - def clear(self) -> None: - return self.data.clear() - - def copy(self) -> dict: - return self.data.copy() - - @classmethod - def fromkeys(cls, seq, value=None): - return cls(dict.fromkeys(seq, value)) - - def get(self, key: str, default: Any = None) -> Any: - try: - return self[key] - except (KeyError, IndexError): - return default - - def keys(self): - return self.data.keys() - - def items(self): - return self.data.items() - - def pop(self, key: str, default: Any = None) -> Any: - try: - parent, last_key = self._traverse(self.data, key) - return parent.pop(last_key) - except (KeyError, IndexError): - return default - - def popitem(self) -> Any: - return self.data.popitem() - - def setdefault(self, key: str, default: Any = None) -> Any: - parent = self.data - *parent_keys, last_key = key.split(self.sep) - if parent_keys: - for key in parent_keys: - parent = parent.setdefault(key, {}) - return parent.setdefault(last_key, default) - - def update(self, data=None, **kwargs): - data = data or {} - try: - data.update(kwargs) - pairs = data.items() - except AttributeError: - pairs = chain(data, kwargs.items()) - for key, value in pairs: - self.__setitem__(key, value) - - def values(self): - return self.data.values() - - -class Cut(LightCut): - """ - Cut is a simple wrapper over the built-in dict class. - - It enables the standard dict API to operate on nested dictionnaries - and cut accross list item by using dot-separated string keys. - - ex: - query = {...} # Any dict structure - proxy = Cut(query) - proxy['pokemons[0].level'] - proxy['pokemons[0].level'] = 666 - """ - - __slots__ = () - - def setdefault(self, key: str, default: Any = None) -> Any: - try: - parent = self.data - *parent_keys, last_key = key.split(self.sep) - if parent_keys: - for _key in parent_keys: - parent, _key = self._traverse_list(parent, _key) - try: - parent = parent[_key] - except KeyError: - child = {} - parent[_key] = child - parent = child - parent, last_key = self._traverse_list(parent, last_key) - return parent[last_key] - except KeyError: - parent[last_key] = default - return default - except IndexError: - raise IndexError('index out of range in key "' + key + '".') - - def _traverse_list(self, parent, key): - key, *str_indexes = key.split("[") - if str_indexes: - parent = parent[key] - for str_index in str_indexes[:-1]: - index = int(str_index[:-1]) - parent = parent[index] - return parent, int(str_indexes[-1][:-1]) - else: - return parent, key - - def _traverse(self, parent, key): - *parent_keys, last_key = key.split(self.sep) - if parent_keys: - for _key in parent_keys: - parent, _key = self._traverse_list(parent, _key) - parent = parent[_key] - parent, last_key = self._traverse_list(parent, last_key) - return parent, last_key diff --git a/scalpl/__init__.py b/scalpl/__init__.py new file mode 100644 index 0000000..6c92532 --- /dev/null +++ b/scalpl/__init__.py @@ -0,0 +1,3 @@ +from .scalpl import Cut + +__version__ = "0.3.0" diff --git a/scalpl/scalpl.py b/scalpl/scalpl.py new file mode 100644 index 0000000..36131e2 --- /dev/null +++ b/scalpl/scalpl.py @@ -0,0 +1,234 @@ +""" + A lightweight wrapper to operate on nested dictionaries seamlessly. +""" +from itertools import chain +from typing import ( + Any, + ItemsView, + Iterable, + Iterator, + KeysView, + Optional, + Type, + TypeVar, + ValuesView, +) + + +def key_error(failing_key, original_path, raised_error): + return KeyError( + f"Cannot access key '{failing_key}' in path '{original_path}'," + f" because of error: {repr(raised_error)}." + ) + + +def index_error(failing_key, original_path, raised_error): + return IndexError( + f"Cannot access index '{failing_key}' in path '{original_path}'," + f" because of error: {repr(raised_error)}." + ) + + +TCut = TypeVar("TCut", bound="Cut") + + +class Cut: + """ + Cut is a simple wrapper over the built-in dict class. + + It enables the standard dict API to operate on nested dictionnaries + and cut accross list item by using dot-separated string keys. + + ex: + query = {...} # Any dict structure + proxy = Cut(query) + proxy['pokemons[0].level'] + proxy['pokemons[0].level'] = 666 + """ + + __slots__ = ("data", "sep") + + def __init__(self, data: Optional[dict] = None, sep: str = ".") -> None: + self.data = data or {} + self.sep = sep + + def __bool__(self) -> bool: + return bool(self.data) + + def __contains__(self, path: str) -> bool: + parent, last_key = self._traverse(self.data, path) + try: + parent[last_key] + return True + except (IndexError, KeyError): + return False + + def __delitem__(self, path: str) -> None: + parent, last_key = self._traverse(self.data, path) + + try: + del parent[last_key] + except KeyError as error: + raise key_error(last_key, path, error) + except IndexError as error: + raise index_error(last_key, path, error) + + def __eq__(self, other: Any) -> bool: + return self.data == other + + def __getitem__(self, path: str) -> Any: + parent, last_key = self._traverse(self.data, path) + + try: + return parent[last_key] + except KeyError as error: + raise key_error(last_key, path, error) + except IndexError as error: + raise index_error(last_key, path, error) + + def __iter__(self) -> Iterator: + return iter(self.data) + + def __len__(self) -> int: + return len(self.data) + + def __ne__(self, other: Any) -> bool: + return self.data != other + + def __setitem__(self, path: str, value: Any) -> None: + parent, last_key = self._traverse(self.data, path) + + try: + parent[last_key] = value + except IndexError as error: + raise index_error(last_key, path, error) + + def __str__(self) -> str: + return str(self.data) + + def all(self: TCut, path: str) -> Iterator[TCut]: + """Wrap each item of an Iterable.""" + items = self[path] + cls = self.__class__ + return (cls(_dict, self.sep) for _dict in items) + + def clear(self) -> None: + return self.data.clear() + + def copy(self) -> dict: + return self.data.copy() + + @classmethod + def fromkeys( + cls: Type[TCut], seq: Iterable, value: Optional[Iterable] = None + ) -> TCut: + return cls(dict.fromkeys(seq, value)) + + def get(self, path: str, default: Optional[Any] = None) -> Any: + try: + return self[path] + except (KeyError, IndexError) as error: + if default is not None: + return default + raise error + + def keys(self) -> KeysView: + return self.data.keys() + + def items(self) -> ItemsView: + return self.data.items() + + def pop(self, path: str, default: Any = None) -> Any: + parent, last_key = self._traverse(self.data, path) + try: + return parent.pop(last_key) + except IndexError as error: + if default is not None: + return default + raise index_error(last_key, path, error) + except KeyError as error: + if default is not None: + return default + raise key_error(last_key, path, error) + + def popitem(self) -> Any: + return self.data.popitem() + + def setdefault(self, path: str, default: Optional[Any] = None) -> Any: + parent = self.data + *parent_keys, last_key = path.split(self.sep) + + if parent_keys: + for _key in parent_keys: + parent, _key = self._traverse_list(parent, _key, path) + try: + parent = parent[_key] + except KeyError: + child: dict = {} + parent[_key] = child + parent = child + except IndexError as error: + raise index_error(_key, path, error) + + parent, last_key = self._traverse_list(parent, last_key, path) + + try: + return parent[last_key] + except KeyError: + parent[last_key] = default + return default + except IndexError as error: + raise index_error(last_key, path, error) + + def update(self, data=None, **kwargs): + data = data or {} + try: + data.update(kwargs) + pairs = data.items() + except AttributeError: + pairs = chain(data, kwargs.items()) + + for key, value in pairs: + self.__setitem__(key, value) + + def values(self) -> ValuesView: + return self.data.values() + + def _traverse_list(self, parent, key, original_path: str): + key, *str_indexes = key.split("[") + if not str_indexes: + return parent, key + + try: + parent = parent[key] + except KeyError as error: + raise key_error(key, original_path, error) + + try: + for str_index in str_indexes[:-1]: + index = int(str_index[:-1]) + parent = parent[index] + except IndexError as error: + raise index_error(index, original_path, error) + + try: + last_index = int(str_indexes[-1][:-1]) + except ValueError as error: + raise index_error(str_indexes[-1][:-1], original_path, error) + + return parent, last_index + + def _traverse(self, parent, path: str): + *parent_keys, last_key = path.split(self.sep) + if len(parent_keys) > 0: + try: + for sub_key in parent_keys: + parent, sub_key = self._traverse_list(parent, sub_key, path) + parent = parent[sub_key] + except KeyError as error: + raise key_error(sub_key, path, error) + except IndexError as error: + raise index_error(sub_key, path, error) + + parent, last_key = self._traverse_list(parent, last_key, path) + return parent, last_key diff --git a/setup.py b/setup.py index 957fba2..c38ac7c 100644 --- a/setup.py +++ b/setup.py @@ -5,17 +5,15 @@ setup( name="scalpl", - py_modules=["scalpl"], - version="0.2.6", - description=( - "A lightweight wrapper to operate on nested " "dictionaries seamlessly." - ), + packages=["scalpl"], + version="0.3.0", + description=("A lightweight wrapper to operate on nested dictionaries seamlessly."), long_description=readme, author="Guillaume Paulet", author_email="guillaume.paulet@giome.fr", license="Public Domain", url="https://github.com/ducdetronquito/scalpl", - download_url=("https://github.com/ducdetronquito/scalpl/archive/" "0.2.6.tar.gz"), + download_url=("https://github.com/ducdetronquito/scalpl/archive/" "0.3.0.tar.gz"), tests_require=[ "addict", "mypy", @@ -39,7 +37,6 @@ "wrapper", ], classifiers=[ - "Development Status :: 3 - Alpha", "Intended Audience :: Developers", "License :: Public Domain", "Operating System :: OS Independent", diff --git a/tests.py b/tests.py deleted file mode 100644 index ffab430..0000000 --- a/tests.py +++ /dev/null @@ -1,571 +0,0 @@ -from collections import defaultdict, OrderedDict -from copy import deepcopy -from types import GeneratorType - -from scalpl import Cut, LightCut - - -ASH = { - "name": "Ash", - "age": 666, - "sex": None, - "badges": {"Boulder": True, "Cascade": False}, -} - -BULBASAUR = { - "name": "Bulbasaur", - "type": ["Grass", "Poison"], - "category": "Seed", - "ability": "Overgrow", -} - -CHARMANDER = { - "name": "Charmander", - "type": "Fire", - "category": "Lizard", - "ability": "Blaze", -} - -SQUIRTLE = { - "name": "Squirtle", - "type": "Water", - "category": "Tiny Turtle", - "ability": "Torrent", -} - -FIRST_SET = [CHARMANDER, SQUIRTLE, BULBASAUR] -SECOND_SET = [CHARMANDER, BULBASAUR, SQUIRTLE] - -BASE = { - "trainer": ASH, - "pokemons": [BULBASAUR, CHARMANDER, SQUIRTLE], - "team_sets": [FIRST_SET, SECOND_SET], -} - - -class TestLightCutProxiedMethods: - """ - Here lives tests of dict methods that are simply proxied by scalpl. - """ - - Dict = dict - Wrapper = LightCut - - def setup(self): - self.data = self.Wrapper(deepcopy(self.Dict(BASE))) - - def test_bool(self): - assert bool(self.data) is True - - def test_clear(self): - self.data.clear() - assert len(self.data) == 0 - - def test_copy(self): - assert self.data.copy() == BASE - - def test_equalty_with_Cut(self): - other = self.Wrapper(self.data.data) - assert self.data == other - - def test_equalty_with_dict(self): - other = self.data.data - assert self.data == other - - def test_fromkeys(self): - expected = self.Wrapper({"Catz": "Lulz", "Dogz": "Lulz", "Fishz": "Lulz"}) - - seq = ["Catz", "Dogz", "Fishz"] - value = "Lulz" - assert self.Wrapper.fromkeys(seq, value) == expected - - def test_fromkeys_default(self): - expected = self.Wrapper({"Catz": None, "Dogz": None, "Fishz": None}) - - seq = ["Catz", "Dogz", "Fishz"] - assert self.Wrapper.fromkeys(seq) == expected - - def test_inequalty_with_Cut(self): - other = self.Wrapper(deepcopy(BASE)) - other["trainer.name"] = "Giovanni" - assert self.data != other - - def test_inequalty_with_dict(self): - other = self.Wrapper(deepcopy(BASE)) - other["trainer.name"] = "Giovanni" - assert self.data != other.data - - def test_items(self): - result = sorted(self.data.items()) - expected = [ - ("pokemons", [BULBASAUR, CHARMANDER, SQUIRTLE]), - ("team_sets", [FIRST_SET, SECOND_SET]), - ("trainer", ASH), - ] - assert result == expected - - def test_iter(self): - keys = sorted([key for key in self.data]) - assert keys[0] == "pokemons" - assert keys[1] == "team_sets" - assert keys[2] == "trainer" - - def test_keys(self): - result = sorted(self.data.keys()) - expected = ["pokemons", "team_sets", "trainer"] - assert result == expected - - def test_len(self): - assert len(self.data) == 3 - - def test_popitem(self): - self.data.popitem() - assert len(self.data) == 2 - - def test_str(self): - result = str(self.Wrapper(OrderedDict(deepcopy(BASE)))) - expected = str(OrderedDict(deepcopy(BASE))) - assert result == expected - - def test_values(self): - result = list(self.Wrapper(OrderedDict(deepcopy(BASE))).values()) - expected = list(OrderedDict(deepcopy(BASE)).values()) - assert result == expected - - -class TestLightCutCustomLogicMethods: - """ - Here lives tests of dict methods where scalpl adds its custom logic - to handle operate on nested dictionnaries. - """ - - Dict = dict - Wrapper = LightCut - - def setup(self): - self.data = self.Wrapper(deepcopy(self.Dict(BASE))) - - def test_delitem(self): - del self.data["trainer"] - assert "trainer" not in self.data - - def test_delitem_nested_key(self): - del self.data["trainer.name"] - assert "name" not in self.data["trainer"] - assert "trainer" in self.data - - def test_delitem_undefined_key(self): - try: - del self.data["trainer.bicycle"] - self.fail() - except KeyError as error: - expected = "'key \"trainer.bicycle\" not found.'" - assert str(error) == expected - - def test_get(self): - assert self.data.get("trainer") == ASH - assert self.data.get("trainer.badges") == {"Boulder": True, "Cascade": False} - assert self.data.get("trainer.badges.Boulder") is True - - def test_get_undefined_key(self): - assert self.data.get("game", "Pokemon Blue") == "Pokemon Blue" - assert self.data.get("trainer.hometown", "Pallet Town") == "Pallet Town" - assert self.data.get("trainer.badges.Thunder", False) is False - - def test_getitem(self): - assert self.data["trainer"] == ASH - assert self.data["trainer.badges"] == {"Boulder": True, "Cascade": False} - assert self.data["trainer.badges.Cascade"] is False - - def test_getitem_undefined_key(self): - try: - self.data["trainer.badges.Thunder"] - self.fail() - except KeyError as error: - assert str(error) == "'key \"trainer.badges.Thunder\" not found.'" - - def test_getitem_with_custom_separator(self): - self.data = self.Wrapper(deepcopy(BASE), sep="+") - assert self.data["trainer"] == ASH - assert self.data["trainer+badges"] == {"Boulder": True, "Cascade": False} - assert self.data["trainer+badges+Boulder"] is True - - def test_in(self): - assert "trainer" in self.data - assert "trainer.badges" in self.data - assert "trainer.badges.Boulder" in self.data - - def test_not_in(self): - assert "game" not in self.data - assert "trainer.hometown" not in self.data - assert "trainer.badges.Thunder" not in self.data - - def test_pop(self): - assert self.data.pop("trainer") == ASH - assert "trainer" not in self.data - - def test_pop_nested_key(self): - assert self.data.pop("trainer.badges.Cascade") is False - assert "trainer.badges.Cascade" not in self.data - - def test_pop_undefined_key(self): - assert self.data.pop("trainer.bicycle", "Not Found") == "Not Found" - - def test_setitem(self): - self.data["trainer.badges.Boulder"] = False - assert self.data["trainer"]["badges"]["Boulder"] is False - - def test_settitem_on_undefined_key(self): - try: - self.data["trainer.bicycle.size"] = 180 - self.fail() - except KeyError as error: - assert str(error) == "'key \"trainer.bicycle.size\" not found.'" - - def test_setdefault(self): - assert self.data.setdefault("trainer", "Not Found") == ASH - - def test_setdefault_nested_key(self): - result = self.data.setdefault("trainer.badges.Boulder", "Undefined") - assert result is True - - def test_setdefault_on_undefined_key(self): - result = self.data.setdefault("trainer.bicycle.size", 180) - assert result == 180 - assert self.data.data["trainer"]["bicycle"]["size"] == 180 - - def test_update_from_dict(self): - from_other = {"trainer.friend": "Brock"} - self.data.update(from_other) - assert self.data["trainer"]["friend"] == "Brock" - - def test_update_from_dict_and_keyword_args(self): - from_other = {"trainer.friend": "Brock"} - self.data.update(from_other, game="Pokemon Blue") - assert self.data["trainer"]["friend"] == "Brock" - assert self.data["game"] == "Pokemon Blue" - - def test_update_from_list(self): - from_other = [("trainer.friend", "Brock")] - self.data.update(from_other) - assert self.data["trainer"]["friend"] == "Brock" - - def test_update_from_list_and_keyword_args(self): - from_other = [("trainer.friend", "Brock")] - self.data.update(from_other, game="Pokemon Blue") - assert self.data["trainer"]["friend"] == "Brock" - assert self.data["game"] == "Pokemon Blue" - - def test_all_returns_generator(self): - result = self.data.all("pokemons") - assert isinstance(result, GeneratorType) is True - result = list(result) - assert result[0].data == BULBASAUR - assert result[1].data == CHARMANDER - assert result[2].data == SQUIRTLE - - def test_all_with_custom_separator(self): - self.data.sep = "/" - result = self.data.all("pokemons") - result = list(result) - assert result[0].sep == "/" - assert result[1].sep == "/" - assert result[2].sep == "/" - - -class TestLightCutWithOrderedDictPM(TestLightCutProxiedMethods): - Dict = OrderedDict - Wrapper = LightCut - - -class TestLightCutWithOrderedDictCLM(TestLightCutCustomLogicMethods): - Dict = OrderedDict - Wrapper = LightCut - - -class TestLightCutWithDefaultDictPM(TestLightCutProxiedMethods): - Dict = defaultdict - Wrapper = LightCut - - def setup(self): - self.data = self.Wrapper(deepcopy(self.Dict(None, BASE))) - - -class TestLightCutWithDefaultDictCLM(TestLightCutCustomLogicMethods): - Dict = defaultdict - Wrapper = LightCut - - def setup(self): - self.data = self.Wrapper(deepcopy(self.Dict(None, BASE))) - - -class TestCutWithDictPM(TestLightCutProxiedMethods): - Dict = dict - Wrapper = Cut - - -class TestCutWithDictCLM(TestLightCutCustomLogicMethods): - Dict = dict - Wrapper = Cut - - def test_delitem_through_list(self): - del self.data["pokemons[1].category"] - assert "category" not in self.data.data["pokemons"][1] - - def test_delitem_through_nested_list(self): - del self.data["team_sets[0][0].name"] - assert "name" not in self.data.data["team_sets"][0][0] - - def test_delitem_list_item(self): - assert len(self.data.data["pokemons"][0]["type"]) == 2 - del self.data["pokemons[0].type[1]"] - assert len(self.data.data["pokemons"][0]["type"]) == 1 - - def test_delitem_undefined_list_item(self): - try: - del self.data["pokemons[42].type[1]"] - self.fail() - except IndexError as error: - expected = 'index out of range in key "pokemons[42].type[1]".' - assert str(error) == expected - - def test_delitem_list_item_through_nested_list(self): - assert len(self.data.data["team_sets"][0]) == 3 - del self.data["team_sets[0][0]"] - assert len(self.data.data["team_sets"][0]) == 2 - - def test_delitem_list_item_through_undefined_nested_list(self): - try: - del self.data["team_sets[42][0]"] - self.fail() - except IndexError as error: - expected = 'index out of range in key "team_sets[42][0]".' - assert str(error) == expected - - def test_get_through_list(self): - assert self.data.get("pokemons[0].type[1]") == "Poison" - - def test_get_through_nested_list(self): - result = self.data.get("team_sets[0][0].name", "Unknwown") - assert result == "Charmander" - - def test_get_undefined_key_through_list(self): - assert self.data.get("pokemons[0].sex", "Unknown") == "Unknown" - - def test_get_undefined_list_item(self): - assert self.data.get("pokemons[0].type[42]", "Unknown") == "Unknown" - - def test_getitem_through_list(self): - assert self.data["pokemons[0].type[1]"] == "Poison" - - def test_getitem_through_nested_list(self): - result = self.data["team_sets[0][0].name"] - assert result == "Charmander" - - def test_getitem_through_undefined_list_item(self): - try: - self.data["pokemons[42].type[1]"] - self.fail() - except IndexError as error: - expected = 'index out of range in key "pokemons[42].type[1]".' - assert str(error) == expected - - def test_in_through_list(self): - assert "pokemons[0].type[1]" in self.data - - def test_in_through_nested_list(self): - assert "team_sets[0][0].name" in self.data - - def test_not_in_through_list(self): - assert "pokemons[1].favorite_meal" not in self.data - - def test_not_in_through_nested_list(self): - assert "team_sets[0][0].favorite_meal" not in self.data - - def test_not_in_through_undefined_list_item(self): - assert "pokemons[42].favorite_meal" not in self.data - - def test_pop_list_item(self): - assert len(self.data.data["pokemons"][0]["type"]) == 2 - result = self.data.pop("pokemons[0].type[1]") - assert result == "Poison" - assert len(self.data.data["pokemons"][0]["type"]) == 1 - - def test_pop_list_item_through_nested_list(self): - assert len(self.data.data["team_sets"][0]) == 3 - result = self.data.pop("team_sets[0][0]") - assert result == CHARMANDER - assert len(self.data.data["team_sets"][0]) == 2 - - def test_pop_through_list(self): - result = self.data.pop("pokemons[0].type") - assert result == ["Grass", "Poison"] - assert "type" not in self.data.data["pokemons"][0] - - def test_pop_through_nested_list(self): - result = self.data.pop("team_sets[0][0].type") - assert result == "Fire" - assert "type" not in self.data.data["team_sets"][0][0] - - def test_setdefault_on_list_item(self): - assert self.data.setdefault("pokemons[0].type[1]", "Funny") == "Poison" - - def test_setdefault_on_list_item_through_nested_list(self): - result = self.data.setdefault("team_sets[0][0]", "MissingNo") - assert result == CHARMANDER - - def test_setdefault_on_undefined_first_item(self): - try: - self.data.setdefault("pokemons[666]", BULBASAUR) - self.fail() - except IndexError as error: - assert str(error) == 'index out of range in key "pokemons[666]".' - - def test_setdefault_on_undefined_list_item(self): - try: - self.data.setdefault("pokemons[0].type[2]", "Funny") - self.fail() - except IndexError as error: - expected = 'index out of range in key "pokemons[0].type[2]".' - assert str(error) == expected - - def test_setdefault_through_list(self): - assert "sex" not in self.data["pokemons"][0] - assert self.data.setdefault("pokemons[0].sex", "Unknown") == "Unknown" - assert self.data.data["pokemons"][0]["sex"] == "Unknown" - - def test_setdefault_undefined_key_through_list(self): - try: - self.data.setdefault("pokemons[42].sex", "Unknown") - self.fail() - except IndexError as error: - expected = 'index out of range in key "pokemons[42].sex".' - assert str(error) == expected - - def test_setdefault_through_nested_list(self): - assert "sex" not in self.data["team_sets"][0][0] - result = self.data.setdefault("team_sets[0][0].sex", "Unknown") - assert result == "Unknown" - assert self.data.data["team_sets"][0][0]["sex"] == "Unknown" - - def test_setdefault_undefined_first_item_in_nested_list_item(self): - try: - self.data.setdefault("team_sets[42][0]", BULBASAUR) - self.fail() - except IndexError as error: - expected = 'index out of range in key "team_sets[42][0]".' - assert str(error) == expected - - def test_setdefault_undefined_nested_list_item(self): - try: - self.data.setdefault("team_sets[0][42]", BULBASAUR) - self.failt() - except IndexError as error: - expected = 'index out of range in key "team_sets[0][42]".' - assert str(error) == expected - - def test_setitem_through_list(self): - self.data["pokemons[0].category"] = "Onion" - assert self.data.data["pokemons"][0]["category"] == "Onion" - - def test_setitem_through_nested_list(self): - self.data["team_sets[0][0].category"] = "Lighter" - assert self.data.data["team_sets"][0][0]["category"] == "Lighter" - - def test_setitem_list_item(self): - assert self.data.data["pokemons"][0]["type"][1] == "Poison" - self.data["pokemons[0].type[1]"] = "Fire" - assert self.data["pokemons"][0]["type"][1] == "Fire" - - def test_setitem_undefined_list_item(self): - try: - self.data["pokemons[42].type[1]"] = "Fire" - self.fail() - except IndexError as error: - expected = 'index out of range in key "pokemons[42].type[1]".' - assert str(error) == expected - - def test_settitem_nested_list_item(self): - assert self.data.data["team_sets"][0][2] == BULBASAUR - self.data["team_sets[0][2]"] = CHARMANDER - assert self.data.data["team_sets"][0][2] == CHARMANDER - - def test_settitem_nested_undefined_list_item(self): - try: - self.data["team_sets[42][0]"] = CHARMANDER - self.fail() - except IndexError as error: - expected = 'index out of range in key "team_sets[42][0]".' - assert str(error) == expected - - def test_update_from_dict_through_list(self): - assert self.data.data["pokemons"][1]["category"] == "Lizard" - from_other = {"pokemons[1].category": "Lighter"} - self.data.update(from_other) - assert self.data.data["pokemons"][1]["category"] == "Lighter" - - def test_update_from_dict_through_nested_list(self): - assert self.data.data["team_sets"][0][0]["category"] == "Lizard" - from_other = {"team_sets[0][0].category": "Lighter"} - self.data.update(from_other) - assert self.data.data["team_sets"][0][0]["category"] == "Lighter" - - def test_update_list_item_from_dict(self): - assert self.data["pokemons"][0]["type"][1] == "Poison" - from_other = {"pokemons[0].type[1]": "Onion"} - self.data.update(from_other) - assert self.data["pokemons"][0]["type"][1] == "Onion" - - def test_update_nested_list_item_from_dict(self): - assert self.data.data["team_sets"][0][0] == CHARMANDER - from_other = {"team_sets[0][0]": SQUIRTLE} - self.data.update(from_other) - assert self.data.data["team_sets"][0][0] == SQUIRTLE - - def test_update_from_dict_and_keyword_args_through_list(self): - assert "game" not in self.data - assert self.data["pokemons"][1]["category"] == "Lizard" - from_other = {"pokemons[1].category": "Lighter"} - self.data.update(from_other, game="Pokemon Blue") - assert self.data["pokemons"][1]["category"] == "Lighter" - assert self.data["game"] == "Pokemon Blue" - - def test_update_from_list_through_list(self): - assert self.data["pokemons"][1]["category"] == "Lizard" - from_other = [("pokemons[1].category", "Lighter")] - self.data.update(from_other) - assert self.data["pokemons"][1]["category"] == "Lighter" - - def test_update_from_list_and_keyword_args_through_list(self): - assert "game" not in self.data - assert self.data["pokemons"][1]["category"] == "Lizard" - from_other = [("pokemons[1].category", "Lighter")] - self.data.update(from_other, game="Pokemon Blue") - assert self.data["pokemons"][1]["category"] == "Lighter" - assert self.data["game"] == "Pokemon Blue" - - -class TestCutWithOrderedDictPM(TestCutWithDictPM): - Dict = OrderedDict - Wrapper = Cut - - -class TestCutWithOrderedDictCLM(TestCutWithDictCLM): - Dict = OrderedDict - Wrapper = Cut - - -class TestCutWithDefaultDictPM(TestCutWithDictPM): - Dict = defaultdict - Wrapper = Cut - - def setup(self): - self.data = self.Wrapper(deepcopy(self.Dict(None, BASE))) - - -class TestCutWithDefaultDictCLM(TestCutWithDictCLM): - Dict = defaultdict - Wrapper = Cut - - def setup(self): - self.data = self.Wrapper(deepcopy(self.Dict(None, BASE))) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures.py b/tests/fixtures.py new file mode 100644 index 0000000..0110c18 --- /dev/null +++ b/tests/fixtures.py @@ -0,0 +1,61 @@ +from collections import defaultdict, OrderedDict +from copy import deepcopy +from functools import partial +import pytest +from scalpl import Cut + + +ASH = { + "name": "Ash", + "age": 666, + "sex": None, + "badges": {"Boulder": True, "Cascade": False}, +} + +BULBASAUR = { + "name": "Bulbasaur", + "types": ["Grass", "Poison"], + "category": "Seed", + "ability": "Overgrow", +} + +CHARMANDER = { + "name": "Charmander", + "types": ["Fire"], + "category": "Lizard", + "ability": "Blaze", +} + +SQUIRTLE = { + "name": "Squirtle", + "types": ["Water"], + "category": "Tiny Turtle", + "ability": "Torrent", +} + +FIRST_SET = [CHARMANDER, SQUIRTLE, BULBASAUR] +SECOND_SET = [CHARMANDER, BULBASAUR, SQUIRTLE] +TEAM_SETS = [FIRST_SET, SECOND_SET] +POKEMONS = [BULBASAUR, CHARMANDER, SQUIRTLE] + + +BASE = {"trainer": ASH, "pokemons": POKEMONS, "team_sets": TEAM_SETS} + + +@pytest.fixture( + params=[(Cut, dict), (Cut, OrderedDict), (Cut, partial(defaultdict, None))] +) +def proxy(request): + scalpl_class = request.param[0] + underlying_dict_class = request.param[1] + return scalpl_class(underlying_dict_class(deepcopy(BASE))) + + +@pytest.fixture() +def scalpl_class(request): + return Cut + + +@pytest.fixture(params=[dict, OrderedDict, partial(defaultdict, None)]) +def underlying_dict_class(request): + return request.param diff --git a/tests/tests.py b/tests/tests.py new file mode 100644 index 0000000..96124cc --- /dev/null +++ b/tests/tests.py @@ -0,0 +1,618 @@ +from collections import defaultdict, OrderedDict +from copy import deepcopy +from .fixtures import ( + BULBASAUR, + CHARMANDER, + SQUIRTLE, + ASH, + FIRST_SET, + SECOND_SET, + BASE, + proxy, + scalpl_class, + underlying_dict_class, +) +import pytest +from types import GeneratorType + + +class TestCutTraverse: + def test_traverse_single_key(self, proxy): + parent, last_key = proxy._traverse(ASH, "name") + assert parent == ASH + assert last_key == "name" + + def test_traverse_single_undefined_key(self, proxy): + parent, last_key = proxy._traverse(ASH, "undefined_key") + assert parent == ASH + assert last_key == "undefined_key" + + def test_traverse_nested_keys(self, proxy): + parent, last_key = proxy._traverse(ASH, "badges.Boulder") + assert parent == ASH["badges"] + assert last_key == "Boulder" + + def test_traverse_undefined_nested_keys(self, proxy): + with pytest.raises(KeyError) as error: + parent, last_key = proxy._traverse(ASH, "undefined_key.name") + + expected_error = KeyError( + "Cannot access key 'undefined_key' in path 'undefined_key.name', " + "because of error: KeyError('undefined_key',)." + ) + assert str(error.value) == str(expected_error) + + def test_traverse_single_list_item(self, proxy): + data = {"types": ["Fire"]} + parent, last_key = proxy._traverse(data, "types[0]") + assert parent == data["types"] + assert last_key == 0 + + def test_traverse_undefined_list_item_do_not_throw_error(self, proxy): + data = {"types": ["Fire"]} + parent, last_key = proxy._traverse(data, "types[1]") + assert parent == data["types"] + assert last_key == 1 + + def test_traverse_undefined_list_raises_exception(self, proxy): + data = {"types": ["Fire"]} + + with pytest.raises(KeyError) as error: + proxy._traverse(data, "colors[0]") + + expected_error = KeyError( + "Cannot access key 'colors' in path 'colors[0]', " + "because of error: KeyError('colors',)." + ) + assert str(error.value) == str(expected_error) + + def test_traverse_nested_list_item(self, proxy): + data = {"types": [["Fire", "Water"]]} + parent, last_key = proxy._traverse(BULBASAUR, "types[0][1]") + assert parent == BULBASAUR["types"][0] + assert last_key == 1 + + def test_traverse_undefined_nested_list_item_raises_exception(self, proxy): + data = {"types": [["Fire", "Water"]]} + + with pytest.raises(IndexError) as error: + proxy._traverse(BULBASAUR, "types[42][0]") + + expected_error = IndexError( + "Cannot access index '42' in path 'types[42][0]', " + "because of error: IndexError('list index out of range',)." + ) + assert str(error.value) == str(expected_error) + + def test_traverse_with_non_integer_index_raises_exception(self, proxy): + data = {"types": [["Fire", "Water"]]} + + with pytest.raises(IndexError) as error: + proxy._traverse(BULBASAUR, "types[toto]") + + expected_error = IndexError( + "Cannot access index 'toto' in path 'types[toto]', " + "because of error: ValueError(\"invalid literal for int() with base 10: 'toto'\",)." + ) + + assert str(error.value) == str(expected_error) + + +class TestBool: + def test_evaluate_to_true_for_existing_data(self, proxy): + assert bool(proxy) is True + + def test_evaluate_to_false_for_empty_data(self, proxy): + proxy.clear() + assert bool(proxy) is False + + +class TestClear: + def test_clear(self, proxy): + proxy.clear() + assert len(proxy) == 0 + + +class TestCopy: + def test_copy(self, proxy): + assert proxy.copy() == BASE + + +class TestEquals: + def test_against_another_scalpl_class(self, proxy): + assert proxy == deepcopy(proxy) + + def test_against_another_dict(self, proxy): + other = dict(proxy.data) + assert proxy == other + + def test_against_another_ordered_dict(self, proxy): + other = OrderedDict(proxy.data) + assert proxy == other + + def test_against_another_default_dict(self, proxy): + other = defaultdict(None, proxy.data) + assert proxy == other + + +class TestFromKeys: + def test_scalpl_class_from_keys(self, scalpl_class, underlying_dict_class): + expected = scalpl_class( + {"Bulbasaur": "captured", "Charmander": "captured", "Squirtle": "captured"} + ) + seq = ["Bulbasaur", "Charmander", "Squirtle"] + value = "captured" + + assert scalpl_class.fromkeys(seq, value) == expected + + def test_with_default_values(self, scalpl_class, underlying_dict_class): + expected = scalpl_class( + {"Bulbasaur": None, "Charmander": None, "Squirtle": None} + ) + seq = ["Bulbasaur", "Charmander", "Squirtle"] + + assert scalpl_class.fromkeys(seq) == expected + + +class TestItems: + def test_items(self, proxy): + result = sorted(proxy.items()) + expected = [ + ("pokemons", [BULBASAUR, CHARMANDER, SQUIRTLE]), + ("team_sets", [FIRST_SET, SECOND_SET]), + ("trainer", ASH), + ] + assert result == expected + + +class TestIter: + def test_iter(self, proxy): + keys = sorted([key for key in proxy]) + assert keys == ["pokemons", "team_sets", "trainer"] + + +class TestKeys: + def test_keys(self, proxy): + result = sorted(proxy.keys()) + assert result == ["pokemons", "team_sets", "trainer"] + + +class TestLen: + def test_len(self, proxy): + assert len(proxy) == 3 + + +class TestPopitem: + def test_popitem(self, proxy): + proxy.popitem() + assert len(proxy) == 2 + + +class TestDelitem: + def test_delitem(self, proxy): + del proxy["trainer"] + assert "trainer" not in proxy + + def test_on_nested_key(self, proxy): + del proxy["trainer.name"] + assert "name" not in proxy["trainer"] + assert "trainer" in proxy + + def test_on_undefined_key(self, proxy): + with pytest.raises(KeyError) as error: + del proxy["trainer.bicycle"] + + expected_error = KeyError( + "Cannot access key 'bicycle' in path 'trainer.bicycle', " + "because of error: KeyError('bicycle',)." + ) + assert str(error.value) == str(expected_error) + + def test_through_list(self, proxy): + del proxy["pokemons[1].category"] + assert "category" not in proxy.data["pokemons"][1] + + def test_undefined_key_through_list_(self, proxy): + with pytest.raises(KeyError) as error: + del proxy["pokemons[1].has_been_seen"] + + expected_error = KeyError( + "Cannot access key 'has_been_seen' in path 'pokemons[1].has_been_seen', " + "because of error: KeyError('has_been_seen',)." + ) + assert str(error.value) == str(expected_error) + + def test_through_nested_list(self, proxy): + del proxy["team_sets[0][0].name"] + assert "name" not in proxy.data["team_sets"][0][0] + + def test_undefined_key_through_nested_list(self, proxy): + with pytest.raises(KeyError) as error: + del proxy["team_sets[0][0].can_fly"] + + expected_error = KeyError( + "Cannot access key 'can_fly' in path 'team_sets[0][0].can_fly', " + "because of error: KeyError('can_fly',)." + ) + assert str(error.value) == str(expected_error) + + def test_list_item(self, proxy): + del proxy["pokemons[0].types[1]"] + assert len(proxy.data["pokemons"][0]["types"]) == 1 + + def test_undefined_list_item(self, proxy): + with pytest.raises(IndexError) as error: + del proxy["pokemons[0].types[42]"] + + expected_error = IndexError( + "Cannot access index '42' in path 'pokemons[0].types[42]', " + "because of error: IndexError('list assignment index out of range',)." + ) + assert str(error.value) == str(expected_error) + + def test_undefined_list_index(self, proxy): + with pytest.raises(IndexError) as error: + del proxy["pokemons[42].types[1]"] + + expected = IndexError( + "Cannot access index '42' in path 'pokemons[42].types[1]', " + "because of error: IndexError('list index out of range',)." + ) + assert str(error.value) == str(expected) + + def test_list_item_through_nested_list(self, proxy): + del proxy["team_sets[0][0]"] + assert len(proxy.data["team_sets"][0]) == 2 + + +class TestGet: + def test_get(self, proxy): + assert proxy.get("trainer") == ASH + assert proxy.get("trainer.badges") == {"Boulder": True, "Cascade": False} + assert proxy.get("trainer.badges.Boulder") is True + + def test_get_undefined_key_raises_exception(self, proxy): + with pytest.raises(KeyError) as error: + proxy.get("trainer.hometown") + + expected = KeyError( + "Cannot access key 'hometown' in path 'trainer.hometown', " + "because of error: KeyError('hometown',)." + ) + assert str(error.value) == str(expected) + + def test_get_undefined_key_when_default_is_provided(self, proxy): + assert proxy.get("game", "Pokemon Blue") == "Pokemon Blue" + assert proxy.get("trainer.hometown", "Pallet Town") == "Pallet Town" + assert proxy.get("trainer.badges.Thunder", False) is False + + def test_get_through_list(self, proxy): + assert proxy.get("pokemons[0].types[1]") == "Poison" + + def test_get_through_nested_list(self, proxy): + result = proxy.get("team_sets[0][0].name", "Unknwown") + assert result == "Charmander" + + def test_get_undefined_key_through_list(self, proxy): + assert proxy.get("pokemons[0].sex", "Unknown") == "Unknown" + + def test_get_undefined_list_item(self, proxy): + assert proxy.get("pokemons[0].types[42]", "Unknown") == "Unknown" + + +class TestGetitem: + def test_getitem(self, proxy): + assert proxy["trainer"] == ASH + assert proxy["trainer.badges"] == {"Boulder": True, "Cascade": False} + assert proxy["trainer.badges.Cascade"] is False + + def test_getitem_undefined_key(self, proxy): + with pytest.raises(KeyError) as error: + proxy["trainer.badges.Thunder"] + + expected_error = KeyError( + "Cannot access key 'Thunder' in path 'trainer.badges.Thunder', " + "because of error: KeyError('Thunder',)." + ) + assert str(error.value) == str(expected_error) + + def test_getitem_with_custom_separator(self, proxy): + proxy.sep = "+" + assert proxy["trainer"] == ASH + assert proxy["trainer+badges"] == {"Boulder": True, "Cascade": False} + assert proxy["trainer+badges+Boulder"] is True + + def test_getitem_through_list(self, proxy): + assert proxy["pokemons[0].types[1]"] == "Poison" + + def test_getitem_through_nested_list(self, proxy): + assert proxy["team_sets[0][0].name"] == "Charmander" + + def test_getitem_through_undefined_list_item_raises_exception(self, proxy): + with pytest.raises(IndexError) as error: + proxy["pokemons[42].types[1]"] + + expected = IndexError( + "Cannot access index '42' in path 'pokemons[42].types[1]', " + "because of error: IndexError('list index out of range',)." + ) + assert str(error.value) == str(expected) + + def test_getitem_through_undefined_list_item_raises_exception(self, proxy): + with pytest.raises(IndexError) as error: + proxy["pokemons[0].types[42]"] + + expected = IndexError( + "Cannot access index '42' in path 'pokemons[0].types[42]', " + "because of error: IndexError('list index out of range',)." + ) + assert str(error.value) == str(expected) + + +class TestIn: + def test_in(self, proxy): + assert "trainer" in proxy + assert "trainer.badges" in proxy + assert "trainer.badges.Boulder" in proxy + + def test_on_undefined_nested_key_raises_exception(self, proxy): + with pytest.raises(KeyError) as error: + "trainer.hometown.size" not in proxy + + expected = KeyError( + "Cannot access key 'hometown' in path 'trainer.hometown.size', " + "because of error: KeyError('hometown',)." + ) + assert str(error.value) == str(expected) + + def test_through_list(self, proxy): + assert "pokemons[0].types[1]" in proxy + + def test_through_nested_list(self, proxy): + assert "team_sets[0][0].name" in proxy + + def test_through_undefined_list_item(self, proxy): + with pytest.raises(IndexError) as error: + "pokemons[42].favorite_meal" in proxy + + expected = IndexError( + "Cannot access index '42' in path 'pokemons[42].favorite_meal', " + "because of error: IndexError('list index out of range',)." + ) + assert str(error.value) == str(expected) + + +class TestPop: + def test_pop(self, proxy): + assert proxy.pop("trainer") == ASH + assert "trainer" not in proxy + + def test_with_nested_key(self, proxy): + assert proxy.pop("trainer.badges.Cascade") is False + assert "trainer.badges.Cascade" not in proxy + + def test_when_key_is_undefined_and_default_value_is_provided(self, proxy): + assert proxy.pop("trainer.bicycle", "Not Found") == "Not Found" + + def test_when_key_is_undefined(self, proxy): + with pytest.raises(KeyError) as error: + proxy.pop("trainer.bicycle") + + expected_error = KeyError( + "Cannot access key 'bicycle' in path 'trainer.bicycle', " + "because of error: KeyError('bicycle',)." + ) + + assert str(error.value) == str(expected_error) + + def test_when_index_is_undefined(self, proxy): + with pytest.raises(IndexError) as error: + proxy.pop("pokemons[42]") + + expected_error = IndexError( + "Cannot access index '42' in path 'pokemons[42]', " + "because of error: IndexError('pop index out of range',)." + ) + assert str(error.value) == str(expected_error) + + def test_pop_list_item(self, proxy): + assert proxy.pop("pokemons[0].types[1]") == "Poison" + assert len(proxy.data["pokemons"][0]["types"]) == 1 + + def test_pop_list_item_through_nested_list(self, proxy): + assert proxy.pop("team_sets[0][0]") == CHARMANDER + assert len(proxy.data["team_sets"][0]) == 2 + + def test_pop_through_list(self, proxy): + assert proxy.pop("pokemons[0].types") == ["Grass", "Poison"] + assert "types" not in proxy.data["pokemons"][0] + + def test_pop_through_nested_list(self, proxy): + assert proxy.pop("team_sets[0][0].types") == ["Fire"] + assert "types" not in proxy.data["team_sets"][0][0] + + +class TestSetitem: + def test_setitem(self, proxy): + proxy["trainer.badges.Boulder"] = False + assert proxy["trainer"]["badges"]["Boulder"] is False + + def test_set_a_value_through_list(self, proxy): + proxy["pokemons[0].category"] = "Onion" + assert proxy.data["pokemons"][0]["category"] == "Onion" + + def test_set_a_value_through_nested_list(self, proxy): + proxy["team_sets[0][0].category"] = "Lighter" + assert proxy.data["team_sets"][0][0]["category"] == "Lighter" + + def test_set_a_value_through_a_nested_item(self, proxy): + proxy["pokemons[0].types[1]"] = "Fire" + assert proxy["pokemons"][0]["types"][1] == "Fire" + + def test_set_value_on_an_undefined_list_item(self, proxy): + with pytest.raises(IndexError) as error: + proxy["pokemons[42].types[1]"] = "Fire" + + expected = 'Index out of range in key "pokemons[42].types[1]".' + assert str(error) == expected + + def test_set_a_value_in_nested_list_item(self, proxy): + proxy["team_sets[0][2]"] = CHARMANDER + assert proxy.data["team_sets"][0][2] == CHARMANDER + + def test_settitem_nested_undefined_list_item_raises_exception(self, proxy): + with pytest.raises(IndexError) as error: + proxy["team_sets[0][42]"] = CHARMANDER + + expected_error = IndexError( + "Cannot access index '42' in path 'team_sets[0][42]', " + "because of error: IndexError('list assignment index out of range',)." + ) + assert str(error.value) == str(expected_error) + + +class TestSetdefault: + def test_setdefault(self, proxy): + assert proxy.setdefault("trainer", "Not Found") == ASH + + def test_with_nested_key(self, proxy): + result = proxy.setdefault("trainer.badges.Boulder", "Undefined") + assert result is True + + def test_when_key_is_undefined(self, proxy): + result = proxy.setdefault("trainer.bicycle.size", 180) + assert result == 180 + assert proxy.data["trainer"]["bicycle"]["size"] == 180 + + def test_on_list_item(self, proxy): + assert proxy.setdefault("pokemons[0].types[1]", "Funny") == "Poison" + + def test_on_list_item_through_nested_list(self, proxy): + result = proxy.setdefault("team_sets[0][0]", "MissingNo") + assert result == CHARMANDER + + def test_on_undefined_first_item(self, proxy): + with pytest.raises(IndexError) as error: + proxy.setdefault("pokemons[666]", BULBASAUR) + + assert str(error) == 'Index out of range in key "pokemons[666]".' + + def test_on_undefined_list_item(self, proxy): + with pytest.raises(IndexError) as error: + proxy.setdefault("pokemons[0].types[2]", "Funny") + + expected = 'Index out of range in key "pokemons[0].types[2]".' + assert str(error) == expected + + def test_through_list(self, proxy): + assert proxy.setdefault("pokemons[0].sex", "Unknown") == "Unknown" + assert proxy.data["pokemons"][0]["sex"] == "Unknown" + + def test_undefined_key_through_list(self, proxy): + with pytest.raises(IndexError) as error: + proxy.setdefault("pokemons[42].sex", "Unknown") + + expected = 'Index out of range in key "pokemons[42].sex".' + assert str(error) == expected + + def test_through_nested_list(self, proxy): + result = proxy.setdefault("team_sets[0][0].sex", "Unknown") + assert result == "Unknown" + assert proxy.data["team_sets"][0][0]["sex"] == "Unknown" + + def test_undefined_list_item(self, proxy): + with pytest.raises(IndexError) as error: + proxy.setdefault("pokemons[42]", BULBASAUR) + + expected_error = IndexError( + "Cannot access index '42' in path 'pokemons[42]', " + "because of error: IndexError('list index out of range',)." + ) + assert str(error) == expected + + def test_undefined_nested_list_item(self, proxy): + with pytest.raises(IndexError) as error: + proxy.setdefault("team_sets[0][42]", BULBASAUR) + + expected_error = IndexError( + "Cannot access index '42' in path 'team_sets[0][42]', " + "because of error: IndexError('list index out of range',)." + ) + assert str(error) == expected + + +class TestUpdate: + def test_from_dict(self, proxy): + from_other = {"trainer.friend": "Brock"} + proxy.update(from_other) + assert proxy["trainer"]["friend"] == "Brock" + + def test_from_dict_and_keyword_args(self, proxy): + from_other = {"trainer.friend": "Brock"} + proxy.update(from_other, game="Pokemon Blue") + assert proxy["trainer"]["friend"] == "Brock" + assert proxy["game"] == "Pokemon Blue" + + def test_from_list(self, proxy): + from_other = [("trainer.friend", "Brock")] + proxy.update(from_other) + assert proxy["trainer"]["friend"] == "Brock" + + def test_from_list_and_keyword_args(self, proxy): + from_other = [("trainer.friend", "Brock")] + proxy.update(from_other, game="Pokemon Blue") + assert proxy["trainer"]["friend"] == "Brock" + assert proxy["game"] == "Pokemon Blue" + + def test_update_from_dict_through_list(self, proxy): + from_other = {"pokemons[1].category": "Lighter"} + proxy.update(from_other) + assert proxy.data["pokemons"][1]["category"] == "Lighter" + + def test_update_from_dict_through_nested_list(self, proxy): + from_other = {"team_sets[0][0].category": "Lighter"} + proxy.update(from_other) + assert proxy.data["team_sets"][0][0]["category"] == "Lighter" + + def test_update_list_item_from_dict(self, proxy): + from_other = {"pokemons[0].types[1]": "Onion"} + proxy.update(from_other) + assert proxy["pokemons"][0]["types"][1] == "Onion" + + def test_update_nested_list_item_from_dict(self, proxy): + from_other = {"team_sets[0][0]": SQUIRTLE} + proxy.update(from_other) + assert proxy.data["team_sets"][0][0] == SQUIRTLE + + def test_update_from_dict_and_keyword_args_through_list(self, proxy): + from_other = {"pokemons[1].category": "Lighter"} + proxy.update(from_other, game="Pokemon Blue") + assert proxy["pokemons"][1]["category"] == "Lighter" + assert proxy["game"] == "Pokemon Blue" + + def test_update_from_list_through_list(self, proxy): + from_other = [("pokemons[1].category", "Lighter")] + proxy.update(from_other) + assert proxy["pokemons"][1]["category"] == "Lighter" + + def test_update_from_list_and_keyword_args_through_list(self, proxy): + from_other = [("pokemons[1].category", "Lighter")] + proxy.update(from_other, game="Pokemon Blue") + assert proxy["pokemons"][1]["category"] == "Lighter" + assert proxy["game"] == "Pokemon Blue" + + +class TestAll: + def test_all_returns_generator(self, proxy): + result = proxy.all("pokemons") + assert isinstance(result, GeneratorType) is True + + def test_generated_values(self, proxy): + values = [item.data for item in proxy.all("pokemons")] + assert values == [BULBASAUR, CHARMANDER, SQUIRTLE] + + def test_all_with_custom_separator(self, proxy): + proxy.sep = "/" + separators = [item.sep for item in proxy.all("pokemons")] + + assert all(sep == "/" for sep in separators)