├── .gitignore ├── .travis.yml ├── README.rst ├── UNLICENSE.txt ├── assets └── scalpl.png ├── benchmarks └── perfomance_comparison.py ├── pytest.ini ├── scalpl ├── __init__.py └── scalpl.py ├── setup.py └── tests ├── __init__.py └── tests.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Python 2 | __pycache__/* 3 | *.pyc 4 | 5 | # Virtual Environment 6 | env/* 7 | 8 | # Packaging 9 | build/* 10 | dist/* 11 | scalpl.egg-info/* 12 | 13 | # Mypy 14 | .mypy_cache/* 15 | 16 | # Coverage.py 17 | .coverage 18 | htmlcov/* 19 | 20 | # Tests 21 | reddit.json 22 | .cache/* 23 | 24 | # IDE 25 | .vscode/ 26 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | python: 4 | - "3.6" 5 | - "3.7" 6 | - "3.8" 7 | - "3.9" 8 | 9 | install: 10 | - pip3 install -e . 11 | - pip3 install pytest black mypy 12 | 13 | script: 14 | - pytest 15 | - black --check scalpl tests setup.py 16 | - mypy scalpl 17 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | .. image:: https://raw.githubusercontent.com/ducdetronquito/scalpl/master/assets/scalpl.png 2 | :target: https://github.com/ducdetronquito/scalpl 3 | 4 | Scalpl 5 | ====== 6 | 7 | .. image:: https://img.shields.io/badge/license-public%20domain-ff69b4.svg 8 | :target: https://github.com/ducdetronquito/scalpl#license 9 | 10 | .. image:: https://img.shields.io/badge/coverage-100%25-green.svg 11 | :target: # 12 | 13 | .. image:: https://img.shields.io/badge/pypi-v0.4.2-blue.svg 14 | :target: https://pypi.python.org/pypi/scalpl/ 15 | 16 | .. image:: https://travis-ci.org/ducdetronquito/scalpl.svg?branch=master 17 | :target: https://travis-ci.org/ducdetronquito/scalpl 18 | 19 | 20 | Outline 21 | ~~~~~~~ 22 | 23 | 1. `Overview `_ 24 | 2. `Benefits `_ 25 | 3. `Installation `_ 26 | 4. `Usage `_ 27 | 5. `Benchmark `_ 28 | 6. `Frequently Asked Questions `_ 29 | 7. `How to Contribute `_ 30 | 8. `License `_ 31 | 32 | 33 | Overview 34 | ~~~~~~~~ 35 | 36 | 37 | **Scalpl** provides a **lightweight wrapper** that helps you to operate 38 | on **nested dictionaries** seamlessly **through the built-in** ``dict`` 39 | **API**, by using dot-separated string keys. 40 | 41 | It's not a drop-in replacement for your dictionnaries, just syntactic 42 | sugar to avoid ``this['annoying']['kind']['of']['things']`` and 43 | ``prefer['a.different.approach']``. 44 | 45 | No conversion cost, a thin computation overhead: that's **Scalpl** in a 46 | nutshell. 47 | 48 | 49 | Benefits 50 | ~~~~~~~~ 51 | 52 | There are a lot of good libraries to operate on nested dictionaries, 53 | such as `Addict `_ or 54 | `Box `_ , but if you give **Scalpl** 55 | a try, you will find it: 56 | 57 | * 🚀 Powerful as the standard dict API 58 | * ⚡ Lightweight 59 | * 👌 Well tested 60 | 61 | 62 | Installation 63 | ~~~~~~~~~~~~ 64 | 65 | **Scalpl** is a Python3 library that you can install via ``pip`` 66 | 67 | .. code:: sh 68 | 69 | pip3 install scalpl 70 | 71 | 72 | Usage 73 | ~~~~~ 74 | 75 | **Scalpl** provides a simple class named **Cut** that wraps around your dictionary 76 | and handles operations on nested ``dict`` and that can cut accross ``list`` item. 77 | 78 | This wrapper strictly follows the standard ``dict`` 79 | `API `_, which 80 | means you can operate seamlessly on ``dict``, 81 | ``collections.defaultdict`` or ``collections.OrderedDict`` by using their methods 82 | with dot-separated keys. 83 | 84 | Let's see what it looks like with an example ! 👇 85 | 86 | .. code:: python 87 | 88 | from scalpl import Cut 89 | 90 | data = { 91 | 'pokemon': [ 92 | { 93 | 'name': 'Bulbasaur', 94 | 'type': ['Grass', 'Poison'], 95 | 'category': 'Seed', 96 | 'ability': 'Overgrow' 97 | }, 98 | { 99 | 'name': 'Charmander', 100 | 'type': 'Fire', 101 | 'category': 'Lizard', 102 | 'ability': 'Blaze', 103 | }, 104 | { 105 | 'name': 'Squirtle', 106 | 'type': 'Water', 107 | 'category': 'Tiny Turtle', 108 | 'ability': 'Torrent', 109 | } 110 | ], 111 | 'trainers': [ 112 | { 113 | 'name': 'Ash', 114 | 'hometown': 'Pallet Town' 115 | } 116 | ] 117 | } 118 | # Just wrap your data, and you're ready to go deeper ! 119 | proxy = Cut(data) 120 | 121 | You can use the built-in ``dict`` API to access its values. 122 | 123 | .. code:: python 124 | 125 | proxy['pokemon[0].name'] 126 | # 'Bulbasaur' 127 | proxy.get('pokemon[1].sex', 'Unknown') 128 | # 'Unknown' 129 | 'trainers[0].hometown' in proxy 130 | # True 131 | 132 | By default, **Scalpl** uses dot as a key separator, but you are free to 133 | use a different character that better suits your needs. 134 | 135 | .. code:: python 136 | 137 | # You just have to provide one when you wrap your data. 138 | proxy = Cut(data, sep='->') 139 | # Yarrr! 140 | proxy['pokemon[0]->name'] 141 | 142 | You can also easily create or update any key/value pair. 143 | 144 | .. code:: python 145 | 146 | proxy['pokemon[1].weaknesses'] = ['Ground', 'Rock', 'Water'] 147 | proxy['pokemon[1].weaknesses'] 148 | # ['Ground', 'Rock', 'Water'] 149 | proxy.update({ 150 | 'trainers[0].region': 'Kanto', 151 | }) 152 | 153 | 154 | Following its purpose in the standard API, the *setdefault* method allows 155 | you to create any missing dictionary when you try to access a nested key. 156 | 157 | .. code:: python 158 | 159 | proxy.setdefault('pokemon[2].moves.Scratch.power', 40) 160 | # 40 161 | 162 | 163 | And it is still possible to iterate over your data. 164 | 165 | .. code:: python 166 | 167 | proxy.items() 168 | # [('pokemon', [...]), ('trainers', [...])] 169 | proxy.keys() 170 | # ['pokemon', 'trainers'] 171 | proxy.values() 172 | # [[...], [...]] 173 | 174 | By the way, if you have to operate on a list of dictionaries, the 175 | ``Cut.all`` method is what you are looking for. 176 | 177 | .. code:: python 178 | 179 | # Let's teach these pokemon some sick moves ! 180 | for pokemon in proxy.all('pokemon'): 181 | pokemon.setdefault('moves.Scratch.power', 40) 182 | 183 | Also, you can remove a specific or an arbitrary key/value pair. 184 | 185 | .. code:: python 186 | 187 | proxy.pop('pokemon[0].category') 188 | # 'Seed' 189 | proxy.popitem() 190 | # ('trainers', [...]) 191 | del proxy['pokemon[1].type'] 192 | 193 | Because **Scalpl** is only a wrapper around your data, it means you can 194 | get it back at will without any conversion cost. If you use an external 195 | API that operates on dictionary, it will just work. 196 | 197 | .. code:: python 198 | 199 | import json 200 | json.dumps(proxy.data) 201 | # "{'pokemon': [...]}" 202 | 203 | Finally, you can retrieve a shallow copy of the inner dictionary or 204 | remove all keys. 205 | 206 | .. code:: python 207 | 208 | shallow_copy = proxy.copy() 209 | 210 | proxy.clear() 211 | 212 | 213 | Benchmark 214 | ~~~~~~~~~ 215 | 216 | This humble benchmark is an attempt to give you an overview of the performance 217 | of `Scalpl `_ compared to `Addict `_, 218 | `Box `_ and the built-in ``dict``. 219 | 220 | It will summarize the *number of operations per second* that each library is 221 | able to perform on a portion of the JSON dump of the `Python subreddit main page `_. 222 | 223 | You can run this benchmark on your machine with the following command: 224 | 225 | python3 ./benchmarks/performance_comparison.py 226 | 227 | Here are the results obtained on an Intel Core i5-7500U CPU (2.50GHz) with **Python 3.6.4**. 228 | 229 | 230 | **Addict** 2.2.1:: 231 | 232 | instantiate:-------- 271,132 ops per second. 233 | get:---------------- 276,090 ops per second. 234 | get through list:--- 293,773 ops per second. 235 | set:---------------- 300,324 ops per second. 236 | set through list:--- 282,149 ops per second. 237 | 238 | 239 | **Box** 3.4.2:: 240 | 241 | instantiate:--------- 4,093,439 ops per second. 242 | get:----------------- 957,069 ops per second. 243 | get through list:---- 164,013 ops per second. 244 | set:----------------- 900,466 ops per second. 245 | set through list:---- 165,522 ops per second. 246 | 247 | 248 | **Scalpl** latest:: 249 | 250 | instantiate:-------- 183,879,865 ops per second. 251 | get:---------------- 14,941,355 ops per second. 252 | get through list:--- 14,175,349 ops per second. 253 | set:---------------- 11,320,968 ops per second. 254 | set through list:--- 11,956,001 ops per second. 255 | 256 | 257 | **dict**:: 258 | 259 | instantiate:--------- 37,816,714 ops per second. 260 | get:----------------- 84,317,032 ops per second. 261 | get through list:---- 62,480,474 ops per second. 262 | set:----------------- 146,484,375 ops per second. 263 | set through list :--- 122,473,974 ops per second. 264 | 265 | 266 | As a conclusion and despite being an order of magniture slower than the built-in 267 | ``dict``, **Scalpl** is faster than Box and Addict by an order of magnitude for any operations. 268 | Besides, the gap increase in favor of **Scalpl** when wrapping large dictionaries. 269 | 270 | Keeping in mind that this benchmark may vary depending on your use-case, it is very unlikely that 271 | **Scalpl** will become a bottleneck of your application. 272 | 273 | 274 | Frequently Asked Questions: 275 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 276 | 277 | * **What if my keys contain dots ?** 278 | If your keys contain a lot of dots, you should use an other 279 | key separator when wrapping your data:: 280 | 281 | proxy = Cut(data, sep='->') 282 | proxy['computer->network->127.0.0.1'] 283 | 284 | Otherwise, split your key in two part:: 285 | 286 | proxy = Cut(data) 287 | proxy['computer.network']['127.0.0.1'] 288 | 289 | * **What if my keys contain spaces ?**:: 290 | 291 | proxy = Cut(data) 292 | proxy['it works perfectly'] = 'fine' 293 | 294 | 295 | How to Contribute 296 | ~~~~~~~~~~~~~~~~~ 297 | 298 | Contributions are welcomed and anyone can feel free to submit a patch, report a bug or ask for a feature. Please open an issue first in order to encourage and keep tracks of potential discussions ✍️ 299 | 300 | 301 | License 302 | ~~~~~~~ 303 | 304 | **Scalpl** is released into the **Public Domain**. 🎉 305 | 306 | Ps: If we meet some day, and you think this small stuff worths it, you 307 | can give me a beer, a coffee or a high-five in return: I would be really 308 | happy to share a moment with you ! 🍻 309 | -------------------------------------------------------------------------------- /UNLICENSE.txt: -------------------------------------------------------------------------------- 1 | Scalpl is a free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | 26 | Ps: If we meet some day, and you think this stuff is worth it, you can give me 27 | a beer, a coffee or a high-five in return. 28 | -------------------------------------------------------------------------------- /assets/scalpl.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ducdetronquito/scalpl/a11a6a6c3e1cabbc8afccbc4aa78b655e20bd413/assets/scalpl.png -------------------------------------------------------------------------------- /benchmarks/perfomance_comparison.py: -------------------------------------------------------------------------------- 1 | from copy import deepcopy 2 | import json 3 | from timeit import timeit 4 | import unittest 5 | 6 | from scalpl import Cut 7 | 8 | from addict import Dict 9 | from box import Box 10 | import requests 11 | 12 | 13 | class TestDictPerformance(unittest.TestCase): 14 | """ 15 | Base class to test performance of different 16 | dict wrapper regarding insertion and lookup. 17 | """ 18 | 19 | # We use a portion of the JSON dump of the Python Reddit page. 20 | 21 | PYTHON_REDDIT = { 22 | "kind": "Listing", 23 | "data": { 24 | "modhash": "", 25 | "dist": 27, 26 | "children": [ 27 | { 28 | "kind": "t3", 29 | "data": { 30 | "approved_at_utc": None, "subreddit": "Python", 31 | "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.", 32 | "author_fullname": "t2_628u", "saved": False, 33 | "mod_reason_title": None, "gilded": 0, "clicked": False, "title": "r/Python Job Board", "link_flair_richtext": [], 34 | "subreddit_name_prefixed": "r/Python", "hidden": False, "pwls": 6, "link_flair_css_class": None, "downs": 0, "hide_score": False, "name": "t3_cmq4jj", 35 | "quarantine": False, "link_flair_text_color": "dark", "author_flair_background_color": "", "subreddit_type": "public", "ups": 11, "total_awards_received": 0, 36 | "media_embed": {}, "author_flair_template_id": None, "is_original_content": False, "user_reports": [], "secure_media": None, 37 | "is_reddit_media_domain": False, "is_meta": False, "category": None, "secure_media_embed": {}, "link_flair_text": None, "can_mod_post": False, 38 | "score": 11, "approved_by": None, "thumbnail": "", "edited": False, "author_flair_css_class": "", "author_flair_richtext": [], "gildings": {}, 39 | "content_categories": None, "is_self": True, "mod_note": None, "created": 1565124336.0, "link_flair_type": "text", "wls": 6, "banned_by": None, 40 | "author_flair_type": "text", "domain": "self.Python", 41 | "allow_live_comments": False, 42 | "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 -->", 43 | "likes": None, "suggested_sort": None, "banned_at_utc": None, "view_count": None, "archived": False, "no_follow": False, "is_crosspostable": False, "pinned": False, 44 | "over_18": False, "all_awardings": [], "media_only": False, "can_gild": False, "spoiler": False, "locked": False, "author_flair_text": "reticulated", 45 | "visited": False, "num_reports": None, "distinguished": None, "subreddit_id": "t5_2qh0y", "mod_reason_by": None, "removal_reason": None, "link_flair_background_color": "", 46 | "id": "cmq4jj", "is_robot_indexable": True, "report_reasons": None, "author": "aphoenix", "num_crossposts": 0, "num_comments": 2, "send_replies": False, "whitelist_status": "all_ads", 47 | "contest_mode": False, "mod_reports": [], "author_patreon_flair": False, "author_flair_text_color": "dark", 48 | "permalink": "/r/Python/comments/cmq4jj/rpython_job_board/", "parent_whitelist_status": "all_ads", "stickied": True, 49 | "url": "https://www.reddit.com/r/Python/comments/cmq4jj/rpython_job_board/", "subreddit_subscribers": 399170, "created_utc": 1565095536.0, 50 | "discussion_type": None, "media": None, "is_video": False 51 | } 52 | } 53 | ] 54 | } 55 | } 56 | 57 | namespace = { 58 | 'Wrapper': dict 59 | } 60 | 61 | def setUp(self): 62 | self.data = deepcopy(self.PYTHON_REDDIT) 63 | self.namespace.update(self=self) 64 | 65 | def execute(self, statement, method): 66 | n = 1000 67 | time = timeit(statement, globals=self.namespace, number=n) 68 | print( 69 | '# ', 70 | self.namespace['Wrapper'], 71 | ' - ', 72 | method, 73 | ': ', 74 | int(60 / (time/n)), 75 | ' ops per second.' 76 | ) 77 | 78 | def test_init(self): 79 | self.execute('Wrapper(self.data)', 'instantiate') 80 | 81 | def test_getitem(self): 82 | self.execute("Wrapper(self.data)['data']['modhash']", 'get') 83 | 84 | def test_getitem_through_list(self): 85 | statement = ( 86 | "Wrapper(self.data)['data']['children'][0]['data']['author']" 87 | ) 88 | self.execute(statement, 'get through list') 89 | 90 | def test_setitem(self): 91 | statement = "Wrapper(self.data)['data']['modhash'] = 'dunno'" 92 | self.execute(statement, 'set') 93 | 94 | def test_setitem_through_list(self): 95 | statement = ( 96 | "Wrapper(self.data)['data']['children'][0]" 97 | "['data']['author'] = 'Captain Obvious'" 98 | ) 99 | self.execute(statement, 'set through list') 100 | 101 | 102 | class TestCutPerformance(TestDictPerformance): 103 | 104 | namespace = { 105 | 'Wrapper': Cut 106 | } 107 | 108 | def test_getitem(self): 109 | self.execute("Wrapper(self.data)['data.modhash']", 'get') 110 | 111 | def test_getitem_through_list(self): 112 | statement = ( 113 | "Wrapper(self.data)['data.children[0].data.author']" 114 | ) 115 | self.execute(statement, 'get through list') 116 | 117 | def test_setitem(self): 118 | statement = "Wrapper(self.data)['data.modhash'] = 'dunno'" 119 | self.execute(statement, 'set') 120 | 121 | def test_setitem_through_list(self): 122 | statement = ( 123 | "Wrapper(self.data)['data.children[0]" 124 | ".data.author'] = 'Captain Obvious'" 125 | ) 126 | self.execute(statement, 'set through list') 127 | 128 | 129 | class TestBoxPerformance(TestDictPerformance): 130 | 131 | namespace = { 132 | 'Wrapper': Box 133 | } 134 | 135 | def test_getitem(self): 136 | self.execute("Wrapper(self.data).data.modhash", 'get - 1st lookup') 137 | self.execute("Wrapper(self.data).data.modhash", 'get - 2nd lookup') 138 | 139 | def test_getitem_through_list(self): 140 | statement = ( 141 | "Wrapper(self.data).data.children[0].data.author" 142 | ) 143 | self.execute(statement, 'get through list - 1st lookup') 144 | self.execute(statement, 'get through list - 2nd lookup') 145 | 146 | def test_setitem(self): 147 | statement = "Wrapper(self.data).data.modhash = 'dunno'" 148 | self.execute(statement, 'set - 1st lookup') 149 | self.execute(statement, 'set - 2nd lookup') 150 | 151 | def test_setitem_through_list(self): 152 | statement = ( 153 | "Wrapper(self.data).data.children[0]" 154 | ".data.author = 'Captain Obvious'" 155 | ) 156 | self.execute(statement, 'set through list - 1st lookup') 157 | self.execute(statement, 'set through list - 2nd lookup') 158 | 159 | 160 | class TestAddictPerformance(TestDictPerformance): 161 | 162 | namespace = { 163 | 'Wrapper': Dict 164 | } 165 | 166 | 167 | if __name__ == '__main__': 168 | unittest.main() 169 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | python_files = tests/* 3 | -------------------------------------------------------------------------------- /scalpl/__init__.py: -------------------------------------------------------------------------------- 1 | from .scalpl import Cut 2 | 3 | __version__ = "0.4.2" 4 | -------------------------------------------------------------------------------- /scalpl/scalpl.py: -------------------------------------------------------------------------------- 1 | """ 2 | A lightweight wrapper to operate on nested dictionaries seamlessly. 3 | """ 4 | from itertools import chain 5 | from typing import ( 6 | ItemsView, 7 | Iterable, 8 | Iterator, 9 | KeysView, 10 | List, 11 | Optional, 12 | Type, 13 | TypeVar, 14 | Union, 15 | ValuesView, 16 | ) 17 | 18 | TCut = TypeVar("TCut", bound="Cut") 19 | TKeyList = List[Union[str, int]] 20 | 21 | 22 | def key_error(failing_key, original_path, raised_error): 23 | return KeyError( 24 | f"Cannot access key '{failing_key}' in path '{original_path}'," 25 | f" because of error: {repr(raised_error)}." 26 | ) 27 | 28 | 29 | def index_error(failing_key, original_path, raised_error): 30 | return IndexError( 31 | f"Cannot access index '{failing_key}' in path '{original_path}'," 32 | f" because of error: {repr(raised_error)}." 33 | ) 34 | 35 | 36 | def type_error(failing_key, original_path, item): 37 | raise TypeError( 38 | f"Cannot access key '{failing_key}' in path '{original_path}': " 39 | f"the element must be a dictionary or a list but is of type '{type(item)}'." 40 | ) 41 | 42 | 43 | def split_path(path: str, key_separator: str) -> TKeyList: 44 | sections = path.split(key_separator) 45 | result = [] # type: TKeyList 46 | 47 | for section in sections: 48 | key, *indexes = section.split("[") 49 | result.append(key) 50 | if not indexes: 51 | continue 52 | 53 | try: 54 | for index in indexes: 55 | index = index[:-1] 56 | result.append(int(index)) 57 | except ValueError: 58 | if index != "" and "]" not in index: 59 | raise ValueError( 60 | f"Unable to access item '{index}' in key '{section}': " 61 | "you can only provide integers to access list items." 62 | ) 63 | else: 64 | raise ValueError(f"Key '{section}' is badly formated.") 65 | 66 | return result 67 | 68 | 69 | def traverse(data: dict, keys: List[Union[str, int]], original_path: str): 70 | value = data 71 | try: 72 | for key in keys: 73 | value = value[key] 74 | except KeyError as error: 75 | raise key_error(key, original_path, error) 76 | except IndexError as error: 77 | raise index_error(key, original_path, error) 78 | except TypeError: 79 | raise type_error(key, original_path, value) 80 | 81 | return value 82 | 83 | 84 | class Cut: 85 | """ 86 | Cut is a simple wrapper over the built-in dict class. 87 | 88 | It enables the standard dict API to operate on nested dictionnaries 89 | and cut accross list item by using dot-separated string keys. 90 | 91 | ex: 92 | query = {...} # Any dict structure 93 | proxy = Cut(query) 94 | proxy['pokemon[0].level'] 95 | proxy['pokemon[0].level'] = 666 96 | """ 97 | 98 | __slots__ = ("data", "sep") 99 | 100 | def __init__(self, data: Optional[dict] = None, sep: str = ".") -> None: 101 | if data is None: 102 | self.data = {} 103 | else: 104 | self.data = data 105 | self.sep = sep 106 | 107 | def __bool__(self) -> bool: 108 | return bool(self.data) 109 | 110 | def __contains__(self, path: str) -> bool: 111 | *keys, last_key = split_path(path, self.sep) 112 | 113 | try: 114 | item = traverse(data=self.data, keys=keys, original_path=path) 115 | except (KeyError, IndexError): 116 | return False 117 | 118 | try: 119 | item[last_key] 120 | return True 121 | except (KeyError, IndexError): 122 | return False 123 | 124 | def __delitem__(self, path: str) -> None: 125 | *keys, last_key = split_path(path, self.sep) 126 | item = traverse(data=self.data, keys=keys, original_path=path) 127 | 128 | try: 129 | del item[last_key] 130 | except KeyError as error: 131 | raise key_error(last_key, path, error) 132 | except IndexError as error: 133 | raise index_error(last_key, path, error) 134 | except TypeError: 135 | raise type_error(last_key, path, item) 136 | 137 | def __eq__(self, other) -> bool: 138 | return self.data == other 139 | 140 | def __getitem__(self, path: str): 141 | *keys, last_key = split_path(path, self.sep) 142 | item = traverse(data=self.data, keys=keys, original_path=path) 143 | 144 | try: 145 | return item[last_key] 146 | except KeyError as error: 147 | raise key_error(last_key, path, error) 148 | except IndexError as error: 149 | raise index_error(last_key, path, error) 150 | except TypeError: 151 | raise type_error(last_key, path, item) 152 | 153 | def __iter__(self) -> Iterator: 154 | return iter(self.data) 155 | 156 | def __len__(self) -> int: 157 | return len(self.data) 158 | 159 | def __ne__(self, other) -> bool: 160 | return not self.data == other 161 | 162 | def __setitem__(self, path: str, value) -> None: 163 | *keys, last_key = split_path(path, self.sep) 164 | item = traverse(data=self.data, keys=keys, original_path=path) 165 | 166 | try: 167 | item[last_key] = value 168 | except IndexError as error: 169 | raise index_error(last_key, path, error) 170 | except TypeError: 171 | raise type_error(last_key, path, item) 172 | 173 | def __str__(self) -> str: 174 | return str(self.data) 175 | 176 | def __repr__(self) -> str: 177 | return f"Cut: {self.data}" 178 | 179 | def all(self: TCut, path: str) -> Iterator[TCut]: 180 | """Wrap each item of an Iterable.""" 181 | items = self[path] 182 | cls = self.__class__ 183 | return (cls(_dict, self.sep) for _dict in items) 184 | 185 | def clear(self) -> None: 186 | return self.data.clear() 187 | 188 | def copy(self) -> dict: 189 | return self.data.copy() 190 | 191 | @classmethod 192 | def fromkeys( 193 | cls: Type[TCut], seq: Iterable, value: Optional[Iterable] = None 194 | ) -> TCut: 195 | return cls(dict.fromkeys(seq, value)) 196 | 197 | def get(self, path: str, default=None): 198 | try: 199 | return self[path] 200 | except (KeyError, IndexError) as error: 201 | return default 202 | 203 | def keys(self) -> KeysView: 204 | return self.data.keys() 205 | 206 | def items(self) -> ItemsView: 207 | return self.data.items() 208 | 209 | def pop(self, path: str, *args): 210 | *keys, last_key = split_path(path, self.sep) 211 | 212 | try: 213 | item = traverse(data=self.data, keys=keys, original_path=path) 214 | except (KeyError, IndexError) as error: 215 | if args: 216 | return args[0] 217 | raise error 218 | 219 | try: 220 | return item.pop(last_key) 221 | except KeyError as error: 222 | if args: 223 | return args[0] 224 | raise key_error(last_key, path, error) 225 | except IndexError as error: 226 | if args: 227 | return args[0] 228 | raise index_error(last_key, path, error) 229 | except AttributeError as error: 230 | raise AttributeError( 231 | f"Unable to pop item '{last_key}' in key '{path}': " 232 | f"the element must be a dictionary or a list but is of type '{type(item)}'." 233 | ) 234 | 235 | def popitem(self): 236 | return self.data.popitem() 237 | 238 | def setdefault(self, path: str, default=None): 239 | *keys, last_key = split_path(path, self.sep) 240 | 241 | item = self.data 242 | for key in keys: 243 | try: 244 | item = item[key] 245 | except KeyError: 246 | item[key] = {} 247 | item = item[key] 248 | except IndexError as error: 249 | raise index_error(key, path, error) 250 | 251 | try: 252 | return item[last_key] 253 | except KeyError: 254 | item[last_key] = default 255 | return default 256 | except IndexError as error: 257 | raise index_error(last_key, path, error) 258 | except TypeError: 259 | raise type_error(last_key, path, item) 260 | 261 | def update(self, data=None, **kwargs): 262 | data = data or {} 263 | try: 264 | data.update(kwargs) 265 | pairs = data.items() 266 | except AttributeError: 267 | pairs = chain(data, kwargs.items()) 268 | 269 | for key, value in pairs: 270 | self.__setitem__(key, value) 271 | 272 | def values(self) -> ValuesView: 273 | return self.data.values() 274 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | with open("README.rst", "r", encoding="utf-8") as f: 4 | readme = f.read() 5 | 6 | setup( 7 | name="scalpl", 8 | packages=["scalpl"], 9 | version="0.4.2", 10 | description=("A lightweight wrapper to operate on nested dictionaries seamlessly."), 11 | long_description=readme, 12 | author="Guillaume Paulet", 13 | author_email="guillaume.paulet@giome.fr", 14 | license="Public Domain", 15 | url="https://github.com/ducdetronquito/scalpl", 16 | download_url=("https://github.com/ducdetronquito/scalpl/archive/" "0.4.2.tar.gz"), 17 | tests_require=[ 18 | "addict", 19 | "mypy", 20 | "pytest", 21 | "pytest-cov", 22 | "python-box", 23 | "requests", 24 | "black", 25 | ], 26 | keywords=[ 27 | "dict", 28 | "nested", 29 | "proxy", 30 | "traversable", 31 | "dictionary", 32 | "box", 33 | "addict", 34 | "munch", 35 | "scalpl", 36 | "scalpel", 37 | "wrapper", 38 | ], 39 | python_requires=">=3.6", 40 | classifiers=[ 41 | "Intended Audience :: Developers", 42 | "License :: Public Domain", 43 | "Operating System :: OS Independent", 44 | "Natural Language :: English", 45 | "Programming Language :: Python :: 3 :: Only", 46 | "Topic :: Software Development :: Libraries :: Python Modules", 47 | ], 48 | ) 49 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ducdetronquito/scalpl/a11a6a6c3e1cabbc8afccbc4aa78b655e20bd413/tests/__init__.py -------------------------------------------------------------------------------- /tests/tests.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict, OrderedDict 2 | from copy import deepcopy 3 | from functools import partial 4 | from scalpl.scalpl import Cut, split_path, traverse 5 | import pytest 6 | from types import GeneratorType 7 | 8 | 9 | @pytest.fixture(params=[dict, OrderedDict, partial(defaultdict, None)]) 10 | def dict_type(request): 11 | return request.param 12 | 13 | 14 | class TestSplitPath: 15 | def setup(self): 16 | self.key_separator = "." 17 | 18 | @pytest.mark.parametrize( 19 | "path,result", 20 | [ 21 | ("", [""]), 22 | ("users", ["users"]), 23 | ("users.names.first-name", ["users", "names", "first-name"]), 24 | ("users[0][1]", ["users", 0, 1]), 25 | ("users[0][1].name", ["users", 0, 1, "name"]), 26 | ("users.names[0][1]", ["users", "names", 0, 1]), 27 | ("users0][1][2][3]", ["users0]", 1, 2, 3]), 28 | ], 29 | ) 30 | def test_split_path(self, path, result): 31 | assert split_path(path, self.key_separator) == result 32 | 33 | @pytest.mark.parametrize( 34 | "path,failing_index", 35 | [ 36 | ("users[name][1][2][3]", "name"), 37 | ("users[0][name][2][3]", "name"), 38 | ("users[0][1][2][name]", "name"), 39 | ], 40 | ) 41 | def test_error_when_index_is_not_an_integer(self, path, failing_index): 42 | with pytest.raises(ValueError) as error: 43 | split_path(path, self.key_separator) 44 | 45 | expected_error = ValueError( 46 | f"Unable to access item '{failing_index}' in key '{path}': " 47 | "you can only provide integers to access list items." 48 | ) 49 | assert str(error.value) == str(expected_error) 50 | 51 | @pytest.mark.parametrize( 52 | "path", 53 | [ 54 | "users[0[1][2][3]", 55 | "users[0]1][2][3]", 56 | "users[0][1[2][3]", 57 | "users[0][1[2]3]", 58 | "users[0][1][2][3", 59 | "users[", 60 | "users[]", 61 | "users[0][][2][3]", 62 | "users[0][1][2][]", 63 | ], 64 | ) 65 | def test_error_when_missing_brackets(self, path): 66 | with pytest.raises(ValueError) as error: 67 | split_path(path, self.key_separator) 68 | 69 | expected_error = ValueError(f"Key '{path}' is badly formated.") 70 | assert str(error.value) == str(expected_error) 71 | 72 | 73 | class TestTraverse: 74 | @pytest.mark.parametrize( 75 | "data,keys,original_path,result", 76 | [ 77 | ({"a": 42}, [], "", {"a": 42}), 78 | ({"a": 42}, ["a"], "a", 42), 79 | ({"a": {"b": {"c": 42}}}, ["a", "b", "c"], "a.b.c", 42), 80 | ({"a": [42]}, ["a", 0], "a[0]", 42), 81 | ({"a": [[21], [42]]}, ["a", 1, 0], "a[1][0]", 42), 82 | ], 83 | ) 84 | def test_traverse(self, data, keys, original_path, result): 85 | assert traverse(data=data, keys=keys, original_path=original_path) == result 86 | 87 | @pytest.mark.parametrize( 88 | "data,keys,original_path,failing_key,failing_item_type", 89 | [ 90 | (42, ["users"], "users", "users", int), 91 | ({"users": 42}, ["users", 0], "users[0]", "0", int), 92 | ], 93 | ) 94 | def test_type_error( 95 | self, data, keys, original_path, failing_key, failing_item_type 96 | ): 97 | with pytest.raises(TypeError) as error: 98 | traverse(data=data, keys=keys, original_path=original_path) 99 | 100 | expected_error = TypeError( 101 | f"Cannot access key '{failing_key}' in path '{original_path}': " 102 | f"the element must be a dictionary or a list but is of type '{failing_item_type}'." 103 | ) 104 | assert str(error.value) == str(expected_error) 105 | 106 | def test_key_error(self): 107 | with pytest.raises(KeyError) as error: 108 | traverse(data={"a": 42}, keys=["b"], original_path="...") 109 | 110 | expected_error = KeyError( 111 | f"Cannot access key 'b' in path '...', because of error: {repr(KeyError('b'))}." 112 | ) 113 | assert str(error.value) == str(expected_error) 114 | 115 | def test_index_error(self): 116 | with pytest.raises(IndexError) as error: 117 | traverse(data={"a": [42]}, keys=["a", 1], original_path="...") 118 | 119 | expected_error = IndexError( 120 | "Cannot access index '1' in path '...', " 121 | f"because of error: {repr(IndexError('list index out of range'))}." 122 | ) 123 | assert str(error.value) == str(expected_error) 124 | 125 | 126 | class TestInit: 127 | def test_without_data(self): 128 | proxy = Cut() 129 | assert proxy.data == {} 130 | 131 | def test_with_an_empty_dict(self, dict_type): 132 | data = dict_type() 133 | proxy = Cut(data) 134 | assert proxy.data is data 135 | 136 | 137 | @pytest.mark.parametrize("data,result", [({"a": 42}, True), ({}, False)]) 138 | def test_bool(dict_type, data, result): 139 | proxy = Cut(dict_type(data)) 140 | assert bool(proxy) is result 141 | 142 | 143 | def test_clear(dict_type): 144 | proxy = Cut(dict_type({"a": 42})) 145 | proxy.clear() 146 | assert len(proxy) == 0 147 | 148 | 149 | def test_copy(dict_type): 150 | data = {"a": 42} 151 | proxy = Cut(dict_type(data)) 152 | assert proxy.copy() == data 153 | 154 | 155 | class TestEquals: 156 | def test_against_another_scalpl_class(self, dict_type): 157 | data = {"a": 42} 158 | proxy = Cut(dict_type(data)) 159 | assert proxy == Cut(dict_type(data)) 160 | 161 | def test_against_another_dict(self, dict_type): 162 | data = {"a": 42} 163 | proxy = Cut(dict_type(data)) 164 | assert proxy == dict(data) 165 | 166 | def test_against_another_ordered_dict(self, dict_type): 167 | data = {"a": 42} 168 | proxy = Cut(dict_type(data)) 169 | assert proxy == OrderedDict(data) 170 | 171 | def test_against_another_default_dict(self, dict_type): 172 | data = {"a": 42} 173 | proxy = Cut(dict_type(data)) 174 | assert proxy == defaultdict(None, data) 175 | 176 | 177 | class TestFromkeys: 178 | def test_fromkeys(self): 179 | assert Cut.fromkeys(["Bulbasaur", "Charmander", "Squirtle"]) == Cut( 180 | {"Bulbasaur": None, "Charmander": None, "Squirtle": None} 181 | ) 182 | 183 | def test_fromkeys_with_default_value(self): 184 | assert Cut.fromkeys(["Bulbasaur", "Charmander", "Squirtle"], "captured") == Cut( 185 | {"Bulbasaur": "captured", "Charmander": "captured", "Squirtle": "captured"} 186 | ) 187 | 188 | 189 | def test_items(dict_type): 190 | proxy = Cut(dict_type({"a": 1, "b": 2, "c": 3})) 191 | assert sorted(proxy.items()) == [("a", 1), ("b", 2), ("c", 3)] 192 | 193 | 194 | def test_iter(dict_type): 195 | proxy = Cut(dict_type({"a": 1, "b": 2, "c": 3})) 196 | assert sorted([key for key in proxy]) == ["a", "b", "c"] 197 | 198 | 199 | def test_keys(dict_type): 200 | proxy = Cut(dict_type({"a": 1, "b": 2, "c": 3})) 201 | assert sorted(proxy.keys()) == ["a", "b", "c"] 202 | 203 | 204 | def test_len(dict_type): 205 | proxy = Cut(dict_type({"a": 1, "b": 2, "c": 3})) 206 | assert len(proxy) == 3 207 | 208 | 209 | def test_popitem(dict_type): 210 | proxy = Cut(dict_type({"a": 1, "b": 2, "c": 3})) 211 | proxy.popitem() 212 | assert len(proxy) == 2 213 | 214 | 215 | class TestAll: 216 | def test_return_generator(self, dict_type): 217 | proxy = Cut(dict_type({"users": [{"name": "a"}, {"name": "b"}]})) 218 | result = proxy.all("users") 219 | assert isinstance(result, GeneratorType) is True 220 | 221 | def test_all(self, dict_type): 222 | proxy = Cut(dict_type({"users": [{"name": "a"}, {"name": "b"}]})) 223 | values = [item for item in proxy.all("users")] 224 | assert values == [Cut({"name": "a"}), Cut({"name": "b"})] 225 | 226 | def test_keep_the_same_operator(self, dict_type): 227 | proxy = Cut(dict_type({"users": [{"name": "a"}, {"name": "b"}]}), sep="/") 228 | separators = [] 229 | 230 | assert all(item.sep == "/" for item in proxy.all("users")) 231 | 232 | 233 | class TestGetitem: 234 | @pytest.mark.parametrize( 235 | "data,key,result", 236 | [ 237 | ({"a": 42}, "a", 42), 238 | ({"a": {"b": 42}}, "a.b", 42), 239 | ({"a": {"b": {"c": 42}}}, "a.b.c", 42), 240 | ({"a": [42]}, "a[0]", 42), 241 | ({"a": [{"b": 42}]}, "a[0].b", 42), 242 | ], 243 | ) 244 | def test_getitem(self, dict_type, data, key, result): 245 | proxy = Cut(dict_type(data)) 246 | assert proxy[key] == result 247 | 248 | def test_key_error(self, dict_type): 249 | proxy = Cut(dict_type({"a": {"b": 42}})) 250 | with pytest.raises(KeyError) as error: 251 | proxy["a.c"] 252 | 253 | expected_error = KeyError( 254 | f"Cannot access key 'c' in path 'a.c', because of error: {repr(KeyError('c'))}." 255 | ) 256 | assert str(error.value) == str(expected_error) 257 | 258 | def test_index_error(self, dict_type): 259 | proxy = Cut(dict_type({"a": [42]})) 260 | with pytest.raises(IndexError) as error: 261 | proxy["a[1]"] 262 | 263 | expected_error = IndexError( 264 | "Cannot access index '1' in path 'a[1]', " 265 | f"because of error: {repr(IndexError('list index out of range'))}." 266 | ) 267 | assert str(error.value) == str(expected_error) 268 | 269 | def test_type_error(self, dict_type): 270 | proxy = Cut(dict_type({"a": 42})) 271 | with pytest.raises(TypeError) as error: 272 | proxy["a[1]"] 273 | 274 | expected_error = TypeError( 275 | f"Cannot access key '1' in path 'a[1]': " 276 | f"the element must be a dictionary or a list but is of type ''." 277 | ) 278 | assert str(error.value) == str(expected_error) 279 | 280 | 281 | class TestSetitem: 282 | @pytest.mark.parametrize( 283 | "data,key,result", 284 | [ 285 | ({"a": 1}, "a", 42), 286 | ({"a": 1}, "b", 42), 287 | ({"a": {"b": 1}}, "a.b", 42), 288 | ({"a": {"b": {"c": 1}}}, "a.b.c", 42), 289 | ({"a": [1]}, "a[0]", 42), 290 | ({"a": [{"b": 1}]}, "a[0].b", 42), 291 | ], 292 | ) 293 | def test_setitem(self, dict_type, data, key, result): 294 | proxy = Cut(dict_type(data)) 295 | proxy[key] = result 296 | assert proxy[key] == result 297 | 298 | def test_index_error(self, dict_type): 299 | proxy = Cut(dict_type({"a": [1]})) 300 | with pytest.raises(IndexError) as error: 301 | proxy["a[1]"] = 42 302 | 303 | expected_error = IndexError( 304 | "Cannot access index '1' in path 'a[1]', " 305 | f"because of error: {repr(IndexError('list assignment index out of range'))}." 306 | ) 307 | assert str(error.value) == str(expected_error) 308 | 309 | def test_type_error(self, dict_type): 310 | proxy = Cut(dict_type({"a": 1})) 311 | with pytest.raises(TypeError) as error: 312 | proxy["a[1]"] = 42 313 | 314 | expected_error = TypeError( 315 | f"Cannot access key '1' in path 'a[1]': " 316 | f"the element must be a dictionary or a list but is of type ''." 317 | ) 318 | assert str(error.value) == str(expected_error) 319 | 320 | 321 | class TestGet: 322 | @pytest.mark.parametrize( 323 | "data,key,result", 324 | [ 325 | ({"a": 42}, "a", 42), 326 | ({"a": {"b": 42}}, "a.b", 42), 327 | ({"a": {"b": {"c": 42}}}, "a.b.c", 42), 328 | ({"a": [42]}, "a[0]", 42), 329 | ({"a": [{"b": 42}]}, "a[0].b", 42), 330 | ({"a": 42}, "b", None), 331 | ({"a": {"b": 42}}, "a.c", None), 332 | ({"a": {"b": {"c": 42}}}, "a.b.d", None), 333 | ({"a": [42]}, "a[1]", None), 334 | ({"a": [{"b": 42}]}, "a[1].b", None), 335 | ], 336 | ) 337 | def test_get(self, dict_type, data, key, result): 338 | proxy = Cut(dict_type(data)) 339 | assert proxy.get(key) == result 340 | 341 | @pytest.mark.parametrize( 342 | "data,key,default", 343 | [ 344 | ({}, "b", None), 345 | ({"a": 42}, "b", "default"), 346 | ({"a": {"b": 42}}, "a.c", "default"), 347 | ({"a": {"b": {"c": 42}}}, "a.b.d", "default"), 348 | ({"a": [42]}, "a[1]", "default"), 349 | ({"a": [{"b": 42}]}, "a[1].b", "default"), 350 | ], 351 | ) 352 | def test_with_default(self, dict_type, data, key, default): 353 | proxy = Cut(dict_type(data)) 354 | assert proxy.get(key, default) == default 355 | 356 | 357 | class TestDelitem: 358 | @pytest.mark.parametrize( 359 | "data,key", 360 | [ 361 | ({"a": 42}, "a"), 362 | ({"a": {"b": 42}}, "a.b"), 363 | ({"a": {"b": {"c": 42}}}, "a.b.c"), 364 | ({"a": [42]}, "a[0]"), 365 | ({"a": [{"b": 42}]}, "a[0].b"), 366 | ], 367 | ) 368 | def test_delitem(self, dict_type, data, key): 369 | proxy = Cut(dict_type(deepcopy(data))) 370 | del proxy[key] 371 | assert key not in proxy 372 | 373 | def test_key_error(self, dict_type): 374 | proxy = Cut(dict_type({"a": {"b": 42}})) 375 | with pytest.raises(KeyError) as error: 376 | del proxy["a.c"] 377 | 378 | expected_error = KeyError( 379 | f"Cannot access key 'c' in path 'a.c', because of error: {repr(KeyError('c'))}." 380 | ) 381 | assert str(error.value) == str(expected_error) 382 | 383 | def test_index_error(self, dict_type): 384 | proxy = Cut(dict_type({"a": [42]})) 385 | with pytest.raises(IndexError) as error: 386 | del proxy["a[1]"] 387 | 388 | expected_error = IndexError( 389 | "Cannot access index '1' in path 'a[1]', " 390 | f"because of error: {repr(IndexError('list assignment index out of range'))}." 391 | ) 392 | assert str(error.value) == str(expected_error) 393 | 394 | def test_type_error(self, dict_type): 395 | proxy = Cut(dict_type({"a": 42})) 396 | with pytest.raises(TypeError) as error: 397 | del proxy["a[1]"] 398 | 399 | expected_error = TypeError( 400 | f"Cannot access key '1' in path 'a[1]': " 401 | f"the element must be a dictionary or a list but is of type ''." 402 | ) 403 | assert str(error.value) == str(expected_error) 404 | 405 | 406 | class TestContains: 407 | @pytest.mark.parametrize( 408 | "data,key,result", 409 | [ 410 | ({"a": 42}, "a", True), 411 | ({"a": {"b": 42}}, "a.b", True), 412 | ({"a": {"b": 42}}, "c.b", False), 413 | ({"a": {"b": {"c": 42}}}, "a.b.c", True), 414 | ({"a": [42]}, "a[0]", True), 415 | ({"a": [{"b": 42}]}, "a[0].b", True), 416 | ({"a": 42}, "b", False), 417 | ({"a": {"b": 42}}, "a.c", False), 418 | ({"a": {"b": {"c": 42}}}, "a.b.d", False), 419 | ({"a": [42]}, "a[1]", False), 420 | ({"a": [{"b": 42}]}, "a[0].c", False), 421 | ({"a": [{"b": 42}]}, "a[1].b", False), 422 | ], 423 | ) 424 | def test_contains(self, dict_type, data, key, result): 425 | proxy = Cut(dict_type(data)) 426 | assert (key in proxy) is result 427 | 428 | 429 | class TestPop: 430 | @pytest.mark.parametrize( 431 | "data,key,result", 432 | [ 433 | ({"a": 42}, "a", 42), 434 | ({"a": {"b": 42}}, "a.b", 42), 435 | ({"a": {"b": {"c": 42}}}, "a.b.c", 42), 436 | ({"a": [42]}, "a[0]", 42), 437 | ({"a": [{"b": 42}]}, "a[0].b", 42), 438 | ], 439 | ) 440 | def test_pop(self, dict_type, data, key, result): 441 | proxy = Cut(dict_type(deepcopy(data))) 442 | assert proxy.pop(key) == result 443 | assert key not in proxy 444 | 445 | @pytest.mark.parametrize( 446 | "data,key,default", 447 | [ 448 | ({}, "b", None), 449 | ({"a": 1}, "b", 42), 450 | ({"a": {"b": 1}}, "c.b", 42), 451 | ({"a": {"b": 1}}, "a.c", 42), 452 | ({"a": {"b": {"c": 1}}}, "a.d.c", 42), 453 | ({"a": {"b": {"c": 1}}}, "a.b.d", 42), 454 | ({"a": [[1]]}, "a[1][0]", 42), 455 | ({"a": [[1]]}, "a[0][1]", 42), 456 | ({"a": [{"b": 1}]}, "a[1].b", 42), 457 | ({"a": [{"b": 1}]}, "a[0].c", 42), 458 | ], 459 | ) 460 | def test_with_default(self, dict_type, data, key, default): 461 | proxy = Cut(dict_type(deepcopy(data))) 462 | assert proxy.pop(key, default) == default 463 | 464 | @pytest.mark.parametrize( 465 | "data,path,failing_key", 466 | [ 467 | ({"a": 1}, "b", "b"), 468 | ({"a": {"b": 1}}, "c.b", "c"), 469 | ({"a": {"b": 1}}, "a.c", "c"), 470 | ({"a": {"b": {"c": 1}}}, "a.d.c", "d"), 471 | ({"a": {"b": {"c": 1}}}, "a.b.d", "d"), 472 | ], 473 | ) 474 | def test_key_error_when_no_default_provided( 475 | self, dict_type, data, path, failing_key 476 | ): 477 | proxy = Cut(dict_type(deepcopy(data))) 478 | with pytest.raises(KeyError) as error: 479 | proxy.pop(path) 480 | 481 | expected_error = KeyError( 482 | f"Cannot access key '{failing_key}' in path '{path}', " 483 | f"because of error: {repr(KeyError(failing_key))}." 484 | ) 485 | assert str(error.value) == str(expected_error) 486 | 487 | @pytest.mark.parametrize( 488 | "data,path,failing_index", 489 | [ 490 | ({"a": [[1]]}, "a[1][0]", 1), 491 | ({"a": [{"b": 1}]}, "a[1].b", 1), 492 | ], 493 | ) 494 | def test_list_index_error_when_no_default_provided( 495 | self, dict_type, data, path, failing_index 496 | ): 497 | proxy = Cut(dict_type(deepcopy(data))) 498 | with pytest.raises(IndexError) as error: 499 | proxy.pop(path) 500 | 501 | expected_error = IndexError( 502 | f"Cannot access index '{failing_index}' in path '{path}', " 503 | f"because of error: {repr(IndexError('list index out of range'))}." 504 | ) 505 | assert str(error.value) == str(expected_error) 506 | 507 | def test_pop_index_error_when_no_default_provided(self, dict_type): 508 | proxy = Cut(dict_type({"a": [[1]]})) 509 | with pytest.raises(IndexError) as error: 510 | proxy.pop("a[0][1]") 511 | 512 | expected_error = IndexError( 513 | f"Cannot access index '1' in path 'a[0][1]', " 514 | f"because of error: {repr(IndexError('pop index out of range'))}." 515 | ) 516 | assert str(error.value) == str(expected_error) 517 | 518 | def test_attribute_error(self, dict_type): 519 | proxy = Cut(dict_type({"a": 42})) 520 | with pytest.raises(AttributeError) as error: 521 | proxy.pop("a.b") 522 | 523 | expected_error = AttributeError( 524 | "Unable to pop item 'b' in key 'a.b': " 525 | "the element must be a dictionary or a list but is of type ''." 526 | ) 527 | assert str(error.value) == str(expected_error) 528 | 529 | 530 | class TestUpdate: 531 | def test_from_dict(self, dict_type): 532 | proxy = Cut(dict_type({"a": {"b": 1}})) 533 | proxy.update({"a.b": 42}) 534 | assert proxy["a"]["b"] == 42 535 | 536 | def test_from_dict_and_keyword_args(self, dict_type): 537 | proxy = Cut(dict_type({"a": {"b": 1}})) 538 | from_other = {"a.b": 42} 539 | proxy.update(from_other, c=666) 540 | assert proxy["a"]["b"] == 42 541 | assert proxy["c"] == 666 542 | 543 | def test_from_list(self, dict_type): 544 | proxy = Cut(dict_type({"a": {"b": 1}})) 545 | from_other = [("a.b", 42)] 546 | proxy.update(from_other) 547 | assert proxy["a"]["b"] == 42 548 | 549 | def test_from_list_and_keyword_args(self, dict_type): 550 | proxy = Cut(dict_type({"a": {"b": 1}})) 551 | from_other = [("a.b", 42)] 552 | proxy.update(from_other, c=666) 553 | assert proxy["a"]["b"] == 42 554 | assert proxy["c"] == 666 555 | 556 | 557 | class TestSetdefault: 558 | @pytest.mark.parametrize( 559 | "data,key,result", 560 | [ 561 | ({"a": 42}, "a", 42), 562 | ({"a": {"b": 42}}, "a.b", 42), 563 | ({"a": {"b": {"c": 42}}}, "a.b.c", 42), 564 | ({"a": [42]}, "a[0]", 42), 565 | ({"a": [{"b": 42}]}, "a[0].b", 42), 566 | ({"a": 1}, "b", None), 567 | ({"a": {"b": 1}}, "a.c", None), 568 | ({"a": {"b": {"c": 1}}}, "a.b.d", None), 569 | ({"a": [{"b": 1}]}, "a[0].c", None), 570 | ({"a": {"b": {"c": 42}}}, "a.d.e.f", None), 571 | ], 572 | ) 573 | def test_setdefault(self, dict_type, data, key, result): 574 | proxy = Cut(dict_type(deepcopy(data))) 575 | assert proxy.setdefault(key) == result 576 | assert proxy[key] == result 577 | 578 | @pytest.mark.parametrize( 579 | "data,key,default", 580 | [ 581 | ({}, "b", None), 582 | ({"a": 1}, "b", "default"), 583 | ({"a": {"b": 1}}, "a.c", "default"), 584 | ({"a": {"b": {"c": 1}}}, "a.b.d", "default"), 585 | ({"a": [{"b": 1}]}, "a[0].c", "default"), 586 | ({"a": {"b": {"c": 42}}}, "a.d.e.f", "default"), 587 | ], 588 | ) 589 | def test_with_default(self, dict_type, data, key, default): 590 | proxy = Cut(dict_type(deepcopy(data))) 591 | assert proxy.setdefault(key, default) == default 592 | assert proxy[key] == default 593 | 594 | def test_type_error(self, dict_type): 595 | proxy = Cut(dict_type({"a": 1})) 596 | with pytest.raises(TypeError) as error: 597 | proxy.setdefault("a[1]") 598 | 599 | expected_error = TypeError( 600 | f"Cannot access key '1' in path 'a[1]': " 601 | f"the element must be a dictionary or a list but is of type ''." 602 | ) 603 | assert str(error.value) == str(expected_error) 604 | 605 | @pytest.mark.parametrize( 606 | "data,key,error_message", 607 | [ 608 | ( 609 | {"a": [42]}, 610 | "a[1]", 611 | f"Cannot access index '1' in path 'a[1]', because of error:", 612 | ), 613 | ( 614 | {"a": [{"b": 1}]}, 615 | "a[1].c", 616 | f"Cannot access index '1' in path 'a[1].c', because of error:", 617 | ), 618 | ], 619 | ) 620 | def test_nested_index_error(self, dict_type, data, key, error_message): 621 | proxy = Cut(dict_type(data)) 622 | with pytest.raises(IndexError) as error: 623 | proxy.setdefault(key, 42) 624 | 625 | expected_error_message = ( 626 | f"{error_message} {repr(IndexError('list index out of range'))}." 627 | ) 628 | 629 | assert str(error.value) == expected_error_message 630 | --------------------------------------------------------------------------------