├── .gitignore ├── .travis.yml ├── LICENSE.rst ├── Makefile ├── README.rst ├── doc ├── conf.py └── index.rst ├── lightning-talk ├── lightning-talk.odp └── lightning-talk.pdf ├── pytest.ini ├── requirements-dev.txt ├── requirements-test.txt ├── setup.cfg ├── setup.py ├── src └── sanest │ ├── __init__.py │ └── sanest.py ├── test_sanest.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | /.cache/ 3 | /.coverage 4 | /*.egg-info/ 5 | /.pytest_cache/ 6 | /.tox/ 7 | /build/ 8 | /dist/ 9 | /doc/build/ 10 | /htmlcov/ 11 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "3.6" 4 | - "3.5" 5 | - "3.4" 6 | - "3.3" 7 | # - "pypy3" # version too old on travis 8 | install: 9 | - pip install . 10 | - pip install -r requirements-test.txt 11 | script: py.test 12 | -------------------------------------------------------------------------------- /LICENSE.rst: -------------------------------------------------------------------------------- 1 | *(This is the OSI approved 3-clause "New BSD License".)* 2 | 3 | Copyright © 2017, wouter bolsterlee 4 | 5 | All rights reserved. 6 | 7 | Redistribution and use in source and binary forms, with or without 8 | modification, are permitted provided that the following conditions are met: 9 | 10 | * Redistributions of source code must retain the above copyright notice, this 11 | list of conditions and the following disclaimer. 12 | 13 | * Redistributions in binary form must reproduce the above copyright notice, this 14 | list of conditions and the following disclaimer in the documentation and/or 15 | other materials provided with the distribution. 16 | 17 | * Neither the name of the author nor the names of the contributors may be used 18 | to endorse or promote products derived from this software without specific 19 | prior written permission. 20 | 21 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 22 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 23 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 24 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 25 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 26 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 27 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 28 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 29 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 30 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: release 2 | 3 | release: 4 | tox 5 | python setup.py sdist bdist_wheel 6 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ====== 2 | sanest 3 | ====== 4 | 5 | *sane nested dictionaries and lists* 6 | 7 | .. image:: https://travis-ci.org/wbolster/sanest.svg?branch=master 8 | :target: https://travis-ci.org/wbolster/sanest 9 | 10 | *sanest* is a python (3+) library that makes it easy to work with nested 11 | dictionaries and lists, such as those commonly used in json formats, 12 | without losing your sanity. 13 | 14 | * Documentation: https://sanest.readthedocs.io/ 15 | 16 | * Python Package Index (PyPI): https://pypi.python.org/pypi/sanest/ 17 | 18 | * Source code and issue tracker: https://github.com/wbolster/sanest 19 | -------------------------------------------------------------------------------- /doc/conf.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | master_doc = 'index' 4 | project = 'sanest' 5 | copyright = 'wouter bolsterlee (@wbolster)' 6 | 7 | extensions = [ 8 | 'sphinx.ext.autodoc', 9 | ] 10 | 11 | if 'READTHEDOCS' not in os.environ: 12 | import sphinx_rtd_theme 13 | html_theme = 'sphinx_rtd_theme' 14 | html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] 15 | -------------------------------------------------------------------------------- /doc/index.rst: -------------------------------------------------------------------------------- 1 | ====== 2 | sanest 3 | ====== 4 | 5 | *sane nested dictionaries and lists* 6 | 7 | .. centered:: ❦ 8 | 9 | Sample JSON input: 10 | 11 | .. code-block:: json 12 | 13 | { 14 | "data": { 15 | "users": [ 16 | {"id": 12, "name": "alice"}, 17 | {"id": 34, "name": "bob"} 18 | ] 19 | } 20 | } 21 | 22 | Without ``sanest``:: 23 | 24 | d = json.loads(...) 25 | for user in d['data']['users']: 26 | print(user['name']) 27 | 28 | With ``sanest``:: 29 | 30 | d = json.loads(...) 31 | wrapped = sanest.dict.wrap(d) 32 | for user in wrapped['data', 'users':[dict]]: 33 | print(user['name':str]) 34 | 35 | The code is now 36 | `type-safe `_ 37 | and will 38 | `fail fast `_ 39 | on unexpected input data. 40 | 41 | .. centered:: ❦ 42 | 43 | .. rubric:: Table of contents 44 | 45 | .. contents:: 46 | :local: 47 | :depth: 1 48 | :backlinks: none 49 | 50 | .. centered:: ❦ 51 | 52 | Overview 53 | ======== 54 | 55 | ``sanest`` is a Python library 56 | that makes it easy to consume, produce, or modify 57 | nested JSON structures in a strict and type-safe way. 58 | It provides two container data structures, 59 | specifically designed for the JSON data model: 60 | 61 | * :py:class:`sanest.dict` 62 | * :py:class:`sanest.list` 63 | 64 | These are thin wrappers around 65 | the built-in ``dict`` and ``list``, 66 | with minimal overhead and an almost identical API, 67 | but with a few new features 68 | that the built-in containers do not have: 69 | 70 | * nested operations 71 | * type checking 72 | * data model restrictions 73 | 74 | These features are very easy to use: 75 | with minimal code additions, otherwise implicit assumptions 76 | about the nesting structure and the data types 77 | can be made explicit, adding type-safety and robustness. 78 | 79 | ``sanest`` is *not* a validation library. 80 | It aims for the sweet spot between 81 | ‘let's hope everything goes well’ 82 | (if not, unexpected crashes or undetected buggy behaviour ensues) 83 | and rigorous schema validation 84 | (lots of work, much more code). 85 | 86 | In practice, ``sanest`` is especially useful 87 | when crafting requests for and processing responses from 88 | third-party JSON-based APIs, 89 | but is by no means limited to this use case. 90 | 91 | .. centered:: ❦ 92 | 93 | Installation 94 | ============ 95 | 96 | Use ``pip`` to install ``sanest`` into a ``virtualenv``:: 97 | 98 | pip install sanest 99 | 100 | ``sanest`` requires Python 3.3+ and has no additional dependencies. 101 | 102 | .. centered:: ❦ 103 | 104 | Why ``sanest``? 105 | =============== 106 | 107 | Consider this JSON data structure, 108 | which is a stripped-down version of 109 | the example JSON response from the 110 | `GitHub issues API documentation 111 | `_: 112 | 113 | .. code-block:: json 114 | 115 | { 116 | "id": 1, 117 | "state": "open", 118 | "title": "Found a bug", 119 | "user": { 120 | "login": "octocat", 121 | "id": 1, 122 | }, 123 | "labels": [ 124 | { 125 | "id": 208045946, 126 | "name": "bug" 127 | } 128 | ], 129 | "milestone": { 130 | "id": 1002604, 131 | "state": "open", 132 | "title": "v1.0", 133 | "creator": { 134 | "login": "octocat", 135 | "id": 1, 136 | }, 137 | "open_issues": 4, 138 | "closed_issues": 8, 139 | } 140 | } 141 | 142 | The following code prints all labels assigned to this issue, 143 | using only lowercase letters:: 144 | 145 | >>> issue = json.load(...) 146 | >>> for label in issue['labels']: 147 | ... print(label['name'].lower()) 148 | bug 149 | 150 | **Hidden asssumptions.      ** 151 | The above code is very straight-forward 152 | and will work fine for valid input documents. 153 | It can fail in many subtle ways though 154 | on input data does not have the exact same structure 155 | as the example document, 156 | since this code makes 157 | quite a few implicit assumptions about its input: 158 | 159 | * The result of ``json.load()`` is a dictionary. 160 | * The ``labels`` field exists. 161 | * The ``labels`` field points to a list. 162 | * This list contains zero or more dictionaries. 163 | * These dictionaries have a ``name`` field. 164 | * The ``name`` field points to a string. 165 | 166 | When presented with input data 167 | fow which these assumptions do not hold, 168 | various things can happen. For instance: 169 | 170 | * Accessing ``d['labels']`` raises ``KeyError`` 171 | when the field is missing. 172 | 173 | * Accessing ``d['labels']`` raises ``TypeError`` 174 | if it is not a ``dict``. 175 | 176 | The actual exception messages vary and can be confusing: 177 | 178 | * ``TypeError: string indices must be integers`` 179 | * ``TypeError: list indices must be integers or slices, not str`` 180 | * ``TypeError: 'NoneType' object is not subscriptable`` 181 | 182 | * If the ``labels`` field is not a list, 183 | the ``for`` loop may raise a ``TypeError``, 184 | but not in all cases. 185 | 186 | If ``labels`` contained a string or a dictionary, 187 | the ``for`` loop will succeed, 188 | since strings and dictionaries are iterable, 189 | and loop over the individual characters of this string 190 | or over the keys of the dictionary. 191 | This was not intended, 192 | but will not raise an exception. 193 | 194 | In this example, the next line will crash, 195 | since the ``label['name']`` lookup will fail 196 | with a ``TypeError`` telling that 197 | ``string indices must be integers``, 198 | but depending on the code everything may seem fine 199 | even though it really is not. 200 | 201 | The above is not an exhaustive list of 202 | things that can go wrong with this code, 203 | but it gives a pretty good overview. 204 | 205 | **Validation.      ** 206 | One approach of safe-guarding 207 | against the issues outlined above 208 | would be to write validation code. 209 | There are many validation libraries, such as 210 | `jsonschema `_, 211 | `Marshmallow `_, 212 | `Colander, `_ 213 | `Django REST framework `_, 214 | and many others, that are perfectly suitable for this task. 215 | 216 | The downside is that 217 | writing the required schema definitions 218 | is a lot of work. 219 | A strict validation step will also make the code much larger 220 | and hence more complex. 221 | Especially when dealing with data formats 222 | that are not ‘owned’ by the application, 223 | e.g. when interacting with a third-party REST API, 224 | this may be a prohibitive amount of effort. 225 | 226 | In the end, 227 | rather than going through all this extra effort, 228 | it may be simpler to just use the code above as-is 229 | and hope for the best. 230 | 231 | **The sane approach.      ** 232 | However, there are more options than 233 | full schema validation and no validation at all. 234 | This is what ``sanest`` aims for: 235 | a sane safety net, 236 | without going overboard with upfront validation. 237 | 238 | Here is the equivalent code using ``sanest``:: 239 | 240 | >>> issue = sanest.dict.wrap(json.loads(...)) # 1 241 | >>> for user in issue['labels':[dict]]: # 2 242 | ... print(label['name':str].lower()) # 3 243 | bug 244 | 245 | While the usage of slice syntax for dictionary lookups and 246 | using the built-in types directly (e.g. ``str`` and ``dict``) 247 | may look a little surprising at first, 248 | the code is actually very readable and explicit. 249 | 250 | Here is what it does: 251 | 252 | 1. Create a thin ``dict`` wrapper. 253 | 254 | This ensures that the input is a dictionary, 255 | and enables the type checking lookups 256 | used in the following lines of code. 257 | 258 | 2. Look up the ``labels`` field. 259 | 260 | This ensures that the field contains 261 | a list of dictionaries. 262 | ‘List of dictionaries’ is condensely expressed as ``[dict]``, 263 | and passed to the ``d[…]`` lookup 264 | using slice syntax (with a colon). 265 | 266 | 3. Print the lowercase value of the ``name`` field. 267 | 268 | This checks that the value is a string 269 | before calling ``.lower()`` on it. 270 | 271 | This code still raises ``KeyError`` for missing fields, 272 | but any failed check will immediately raise a very clear exception 273 | with a meaningful message detailing what went wrong. 274 | 275 | .. centered:: ❦ 276 | 277 | Data model 278 | ========== 279 | 280 | The JSON data model is restricted, 281 | and ``sanest`` strictly adheres to it. 282 | ``sanest`` uses very strict type checks 283 | and will reject any values 284 | not conforming to this data model. 285 | 286 | **Containers.      ** 287 | There are two container types, 288 | which can have arbitrary nesting 289 | to build more complex structures: 290 | 291 | * :py:class:`sanest.dict` is an unordered collection of named items. 292 | 293 | * :py:class:`sanest.list` is an ordered collection of values. 294 | 295 | In a dictionary, each item is a ``(key, value)`` pair, 296 | in which the key is a unique string (``str``). 297 | In a list, values have an associated index, 298 | which is an integer counting from zero. 299 | 300 | **Leaf values.      ** 301 | Leaf values are restricted to: 302 | 303 | * strings (``str``) 304 | * integer numbers (``int``) 305 | * floating point numbers (``float``) 306 | * booleans (``bool``) 307 | * ``None`` (no value, encoded as ``null`` in JSON) 308 | 309 | .. centered:: ❦ 310 | 311 | Basic usage 312 | =========== 313 | 314 | ``sanest`` provides two classes, 315 | :py:class:`sanest.dict` and :py:class:`sanest.list`, 316 | that behave very much like 317 | the built-in ``dict`` and ``list``, 318 | supporting all the regular operations 319 | such as getting, setting, and deleting items. 320 | 321 | To get started, import the ``sanest`` module:: 322 | 323 | import sanest 324 | 325 | **Dictionary.      ** 326 | The :py:class:`sanest.dict` constructor behaves 327 | like the built-in ``dict`` constructor:: 328 | 329 | d = sanest.dict(regular_dict_or_mapping) 330 | d = sanest.dict(iterable_with_key_value_pairs) 331 | d = sanest.dict(a=1, b=2) 332 | 333 | Usage examples (see API docs for details):: 334 | 335 | d = sanest.dict(a=1, b=2) 336 | d['a'] 337 | d['c'] = 3 338 | d.update(d=4) 339 | d.get('e', 5) 340 | d.pop('f', 6) 341 | del d['a'] 342 | for v in d.values(): 343 | print(v) 344 | d.clear() 345 | 346 | **List.      ** 347 | The :py:class:`sanest.list` constructor behaves 348 | like the built-in ``list`` constructor:: 349 | 350 | l = sanest.list(regular_list_or_sequence) 351 | l = sanest.list(iterable) 352 | 353 | Usage examples (see API docs for details):: 354 | 355 | l = sanest.list([1, 2]) 356 | l[0] 357 | l.append(3) 358 | l.extend([4, 5]) 359 | del l[0] 360 | for v in l(): 361 | print(v) 362 | l.pop() 363 | l.count(2) 364 | l.sort() 365 | l.clear() 366 | 367 | **Container values.      ** 368 | Operations that return a nested dictionary or list 369 | will always be returned as a 370 | :py:class:`sanest.dict` or :py:class:`sanest.list`:: 371 | 372 | >>> issue['user'] 373 | sanest.dict({"login": "octocat", "id": 1}) 374 | 375 | Operations that accept a container value 376 | from the application, will accept 377 | regular ``dict`` and ``list`` instances, 378 | as well as 379 | :py:class:`sanest.dict` and :py:class:`sanest.list` instances:: 380 | 381 | >>> d = sanest.dict() 382 | >>> d['x'] = {'a': 1, 'b': 2} 383 | >>> d['y'] = sanest.dict({'a': 1, 'b': 2}) 384 | 385 | .. centered:: ❦ 386 | 387 | Nested operations 388 | ================= 389 | 390 | In addition to normal dictionary keys (``str``) and list indices (``int``), 391 | :py:class:`sanest.dict` and :py:class:`sanest.list` 392 | can operate directly on values in a nested structure. 393 | Nested operations work like normal container operations, 394 | but instead of a single key or index, 395 | they use a path that points into nested dictionaries and lists. 396 | 397 | **Path syntax.      ** 398 | A path is simply 399 | a sequence of strings (dictionary keys) and integers (list indices). 400 | Here are some examples for 401 | the Github issue JSON example from a previous section:: 402 | 403 | 'user', 'login' 404 | 'labels', 0, 'name' 405 | 'milestone', 'creator', 'login' 406 | 407 | A string-only syntax for paths (such as ``a.b.c`` or ``a/b/c``) 408 | is not supported, since all conceivable syntaxes have drawbacks, 409 | and it is not up to ``sanest`` to make choices here. 410 | 411 | **Getting, setting, deleting.      ** 412 | For getting, setting, and deleting items, 413 | paths can be used directly inside square brackets:: 414 | 415 | >>> d = sanest.dict(...) 416 | >>> d['a', 'b', 'c'] = 123 417 | >>> d['a', 'b', 'c'] 418 | 123 419 | >>> del d['a', 'b', 'c'] 420 | 421 | Alternatively, paths can be specified as a list or tuple 422 | instead of the inline syntax:: 423 | 424 | >>> path = ['a', 'b', 'c'] 425 | >>> d[path] = 123 426 | >>> path = ('a', 'b', 'c') 427 | >>> d[path] 428 | 123 429 | 430 | **Other operations.      ** 431 | For the method based container operations taking a key or index, 432 | such as :py:meth:`sanest.dict.get` or :py:meth:`sanest.dict.pop`, 433 | paths must always be passed as a list or tuple:: 434 | 435 | >>> d.get(['a', 'b', 'c'], "default value") 436 | 437 | **Containment checks.      ** 438 | The ``in`` operator that checks whether a dictionary key exists, 439 | also works with paths:: 440 | 441 | >>> ['milestone', 'creator', 'login'] in issue 442 | True 443 | >>> ['milestone', 'creator', 'xyz'] in issue 444 | False 445 | >>> ['labels', 0] in issue 446 | True 447 | >>> ['labels', 123] in issue 448 | False 449 | 450 | **Automatic creation of nested structures.      ** 451 | When setting a nested dictionary key that does not yet exist, 452 | the structure is automatically created 453 | by instantiating a fresh dictionary at each level of the path. 454 | This is sometimes known as *autovivification*:: 455 | 456 | >>> d = sanest.dict() 457 | >>> d['a', 'b', 'c'] = 123 458 | >>> d 459 | sanest.dict({'a': {'b': {'c': 123}}}) 460 | >>> d.setdefault(['a', 'e', 'f'], 456) 461 | 456 462 | >>> d 463 | sanest.dict({'a': {'b': {'c': 123}, 'e': {'f': 456}}}) 464 | 465 | This only works for paths pointing to a dictionary key, 466 | not for lists (since padding with `None` values is seldom useful), 467 | but of course it will traverse existing lists just fine:: 468 | 469 | >>> d = sanest.dict({'items': [{'name': "a"}, {'name': "b"}]}) 470 | >>> d['items', 1, 'x', 'y', 'z'] = 123 471 | >>> d['items', 1] 472 | sanest.dict({'x': {'y': {'z': 123}}, 'name': 'b'}) 473 | 474 | .. centered:: ❦ 475 | 476 | Type checking 477 | ============= 478 | 479 | In addition to the basic validation 480 | to ensure that all values adhere to the JSON data model, 481 | almost all :py:class:`sanest.dict` and :py:class:`sanest.list` operations 482 | support explicit *type checks*. 483 | 484 | **Getting, setting, deleting.      ** 485 | For getting, setting, and deleting items, 486 | type checking uses slice syntax 487 | to indicate the expected data type:: 488 | 489 | >>> issue['id':int] 490 | 1 491 | >>> issue['state':str] 492 | 'open' 493 | 494 | Path lookups can be combined with type checking:: 495 | 496 | >>> issue['user', 'login':str] 497 | 'octocat' 498 | >>> path = ['milestone', 'creator', 'id'] 499 | >>> issue[path:int] 500 | 1 501 | 502 | **Other operations.      ** 503 | Other methods use a more conventional approach 504 | by accepting a `type` argument:: 505 | 506 | >>> issue.get('id', type=int) 507 | 1 508 | >>> issue.get(['user', 'login'], type=str) 509 | 'octocat' 510 | 511 | **Containment checks.      ** 512 | The ``in`` operator does not allow for slice syntax, 513 | so instead it uses a normal list 514 | with the type as the last item:: 515 | 516 | >>> ['id', int] in issue 517 | True 518 | >>> ['id', str] in issue 519 | False 520 | 521 | This also works with paths:: 522 | 523 | >>> ['user', 'login', str] in issue 524 | True 525 | >>> path = ['milestone', 'creator', 'id'] 526 | >>> [path, int] in issue 527 | True 528 | >>> [path, bool] in issue 529 | False 530 | 531 | **Extended types.      ** 532 | In its simplest form, 533 | the *type* argument is just the built-in type: 534 | ``bool``, ``float``, ``int``, ``str``, 535 | ``dict``, ``list``. 536 | This works well for simple types, 537 | but for containers, 538 | only stating that ‘the application expects a list’ 539 | is often not good enough. 540 | 541 | Typically lists are homogeneous, 542 | meaning that all values have the same type, 543 | and ``sanest`` can check this in one go. 544 | The syntax for checking the types of list values is 545 | a list containing a type, such as ``[dict]`` or ``[str]``. 546 | For example, 547 | to ensure that a field contains a list of dictionaries:: 548 | 549 | >>> issue['labels':[dict]] 550 | sanest.list([{"id": 208045946, "name": "bug"}]) 551 | 552 | To keep it sane, this approach cannot be used recursively, 553 | but then, nested lists are not that common anyway. 554 | 555 | For dictionaries, ``sanest`` offers similar functionality. 556 | Its usefulness is limited, since it is not very common 557 | for dictionary values to all have the same type. 558 | (Note that dictionary keys are always strings.) 559 | The syntax is a literal dictionary with one key/value pair, 560 | in which the key is *always* the literal ``str``, 561 | such as ``{str: int}`` or ``{str: bool}``. 562 | For example, 563 | to ensure that all values in the dictionary 564 | pointed to by the path ``'a', 'b', 'c'`` 565 | are integers:: 566 | 567 | d['a', 'b', 'c':{str: int}] 568 | 569 | **Checking container values.      ** 570 | To explicitly check that all values in a container have the same type, 571 | use :py:meth:`sanest.list.check_types` or :py:meth:`sanest.dict.check_types`, 572 | which take a *type* argument:: 573 | 574 | l = sanest.list() 575 | l.append(1) 576 | l.append(2) 577 | l.append(3) 578 | l.check_types(type=int) 579 | 580 | Such explicit type checks may also help increasing code clarity, 581 | since it decouples type checking from container operations. 582 | For example, this combined lookup and type check:: 583 | 584 | >>> labels = issue['labels':[dict]] 585 | 586 | …can also be written as: 587 | 588 | >>> labels = issue['labels':list] 589 | >>> labels.check_types(type=dict) 590 | 591 | **Type-safe iteration.      ** 592 | It is very common to iterate over a list of values 593 | that all have the same type, e.g. a list of strings. 594 | One way to do this would be:: 595 | 596 | >>> l = sanest.list(...) 597 | >>> l.check_types(type=str) 598 | >>> for value in l: 599 | ... pass 600 | 601 | The :py:meth:`sanest.list.iter()` method offers 602 | a more concise way to do the same:: 603 | 604 | >>> l = sanest.list(...) 605 | >>> for value in l.iter(type=str): 606 | ... pass 607 | 608 | If the list was obtained from a lookup in another container, 609 | the type check can be combined with the lookup:: 610 | 611 | >>> for value in parent['values':list].iter(type=str): 612 | ... pass 613 | 614 | …or even shorter: 615 | 616 | >>> for value in parent['values':[str]]: 617 | ... pass 618 | 619 | For dictionaries with homogeneously typed values, 620 | :py:meth:`sanest.dict.values` and :py:meth:`sanest.dict.items` 621 | offer the same functionality. 622 | For example, 623 | 624 | :: 625 | 626 | >>> d = sanest.dict(...) 627 | >>> d.check_types(type=int) 628 | >>> for value in d.values(): 629 | ... pass 630 | >>> for key, value in d.items(): 631 | ... pass 632 | 633 | …can be shortened to the equivalent:: 634 | 635 | >>> d = sanest.dict(...) 636 | >>> for value in d.values(type=int): 637 | ... pass 638 | >>> for key, value in d.items(type=int): 639 | ... pass 640 | 641 | .. centered:: ❦ 642 | 643 | Wrapping 644 | ======== 645 | 646 | Both :py:class:`sanest.dict` and :py:class:`sanest.list` are 647 | thin wrappers around a regular ``dict`` or ``list``. 648 | All container operations (getting, setting, and so on) 649 | accept both regular containers and ``sanest`` containers 650 | when those are passed in by the application, 651 | and transparently ‘wrap’ any lists or dictionaries 652 | returned to the application. 653 | 654 | For nested structures, 655 | only the outermost ``dict`` or ``list`` is wrapped: 656 | the nested structure is not changed in any way. 657 | In practice this means that the overhead of 658 | using ``sanest`` is very small, 659 | since internally all nested structures are 660 | just as they would be in regular Python. 661 | 662 | **Wrapping existing containers.      ** 663 | The :py:class:`sanest.dict` and :py:class:`sanest.list` constructors 664 | create a new container, 665 | and make a shallow copy 666 | when an existing ``dict`` or ``list`` is passed to it, 667 | analogous to the behaviour of the built-in ``dict`` and ``list``. 668 | 669 | ``sanest`` can also wrap an existing ``dict`` or ``list`` 670 | without making a copy, using the *classmethods* 671 | :py:meth:`sanest.dict.wrap` and :py:meth:`sanest.list.wrap`, 672 | that can be used as alternate constructors:: 673 | 674 | d = sanest.dict.wrap(existing_dict) 675 | l = sanest.list.wrap(existing_list) 676 | 677 | By default, ``wrap()`` recursively validates 678 | that the data structure matches the JSON data model. 679 | In some cases, 680 | these checks are not necessary, 681 | and can be skipped for performance reasons. 682 | A typical example is freshly deserialised JSON data:: 683 | 684 | d = sanest.dict.wrap(json.loads(...), check=False) 685 | l = sanest.list.wrap(json.loads(...), check=False) 686 | 687 | **Unwrapping.      ** 688 | The reverse process is *unwrapping*: 689 | to obtain a plain ``dict`` or ``list``, 690 | use :py:meth:`sanest.dict.unwrap` or :py:meth:`sanest.list.unwrap`, 691 | which will return the original objects:: 692 | 693 | normal_dict = d.unwrap() 694 | normal_list = l.unwrap() 695 | 696 | Unwrapping is typically done at the end of a piece of code, 697 | when a regular ``dict`` or ``list`` is required, 698 | e.g. right before serialisation:: 699 | 700 | json.dumps(d.unwrap()) 701 | 702 | Unwrapping is a very cheap operation 703 | and does not make any copies. 704 | 705 | **Localised use.      ** 706 | Wrapping an existing ``dict`` or ``list`` 707 | is also a very useful way to use ``sanest`` 708 | only in selected places in an application, 709 | e.g. in a function that modifies a regular ``dict`` 710 | that is passed to it, 711 | without any other part of the application 712 | being aware of ``sanest`` at all:: 713 | 714 | def set_fields(some_dict, num, flag): 715 | """ 716 | Set a few fields in `some_dict`. This modifies `some_dict` in-place. 717 | """ 718 | wrapped = sanest.dict.wrap(some_dict) 719 | wrapped["foo", "bar":int] = num * 2 720 | wrapped.setdefault(["x", "y"], type=bool) = flag 721 | 722 | .. centered:: ❦ 723 | 724 | Error handling 725 | ============== 726 | 727 | ``sanest`` has very strict error handling, 728 | and raises predictable exceptions with a clear error message 729 | whenever an operation cannot be completed successfully. 730 | 731 | In general, an operation can fail because 732 | of three reasons: 733 | 734 | * Missing or incomplete data, e.g. a key does not exist. 735 | * Problematic data, e.g wrong structure or an unexpected data type. 736 | * Problematic code, e.g. a malformed path. 737 | 738 | **Exceptions for missing data.      ** 739 | It is normal for applications to deal with missing values, 740 | for instance by falling back to a default value. 741 | For missing data, ``sanest`` uses the same exceptions 742 | as the regular Python dictionaries and lists: 743 | 744 | * Dictionary lookups may raise ``KeyError``. 745 | * List lookups may raise ``IndexError``. 746 | 747 | Python also provides the not so widely used ``LookupError``, 748 | which is a parent class of both. 749 | The exception hierarchy is: 750 | 751 | * ``Exception`` (built-in exception) 752 | 753 | * ``LookupError`` (built-in exception) 754 | 755 | * ``KeyError`` (built-in exception) 756 | * ``IndexError`` (built-in exception) 757 | 758 | Below are some examples for the Github issue JSON example. 759 | Note that the error messages contain the (partial) path 760 | where the error occurred. 761 | 762 | :: 763 | 764 | >>> issue['labels', 0, 'name'] 765 | 'bug' 766 | 767 | >>> issue['xyz', 'a', 'b', 'c'] 768 | Traceback (most recent call last): 769 | ... 770 | KeyError: ['xyz'] 771 | 772 | :: 773 | 774 | >>> issue['labels', 0, 'xyz'] 775 | Traceback (most recent call last): 776 | ... 777 | KeyError: ['labels', 0, 'xyz'] 778 | 779 | :: 780 | 781 | >>> issue['labels', 123, 'name'] 782 | Traceback (most recent call last): 783 | ... 784 | IndexError: ['labels', 123] 785 | 786 | To catch either ``KeyError`` or ``IndexError``, 787 | use ``LookupError``. Example:: 788 | 789 | try: 790 | first_label_name = issue['labels', 0, 'name':str] 791 | except LookupError: 792 | ... 793 | 794 | This ``except`` clause handles the following cases: 795 | 796 | * The ``labels`` field is missing. 797 | * The ``labels`` field exists, but is empty. 798 | * The ``name`` field is missing from the first dictionary in the ``labels`` list. 799 | 800 | 801 | **Exceptions for problematic data.      ** 802 | ``sanest`` can be used for basic input validation. 803 | When data does not match 804 | what the code expects, 805 | this typically means input data is malformed, 806 | and applications could for instance 807 | return an error response from an exception handler. 808 | 809 | Data errors indicate 810 | either an invalid structure, 811 | or an invalid value. 812 | ``sanest`` uses two exceptions here: 813 | :py:exc:`sanest.InvalidStructureError` 814 | and :py:exc:`sanest.InvalidValueError`. 815 | Both share a common ancestor, 816 | :py:exc:`sanest.DataError`, 817 | which in turns inherits from 818 | the standard Python ``ValueError``. 819 | The exception hierarchy is: 820 | 821 | * ``Exception`` (built-in exception) 822 | 823 | * ``ValueError`` (built-in exception) 824 | 825 | * :py:exc:`sanest.DataError` 826 | 827 | * :py:exc:`sanest.InvalidStructureError` 828 | * :py:exc:`sanest.InvalidValueError` 829 | 830 | Below are some examples for the Github issue JSON sample. 831 | 832 | :: 833 | 834 | >>> issue['milestone', 'creator', 'login'] 835 | 'octocat' 836 | 837 | >>> issue['milestone', 'creator', 'login':int] 838 | Traceback (most recent call last): 839 | ... 840 | InvalidValueError: expected int, got str at path ['milestone', 'creator', 'login']: 'octocat' 841 | 842 | :: 843 | 844 | >>> issue['title':str] = ["This", "is", "a", {"malformed": "title"}] 845 | Traceback (most recent call last): 846 | ... 847 | InvalidValueError: expected str, got list: ['This', 'is', 'a', {'malformed': 'title'}] 848 | 849 | :: 850 | 851 | >>> issue['labels'] 852 | sanest.list([{'name': 'bug', 'id': 208045946}]) 853 | 854 | >>> issue['labels', 'xyz'] 855 | Traceback (most recent call last): 856 | ... 857 | InvalidStructureError: expected dict, got list at subpath ['labels'] of ['labels', 'xyz'] 858 | 859 | The generic :py:exc:`sanest.DataError` 860 | is never raised directly, 861 | but can be caught 862 | if the application does not care 863 | whether the source of the problem was 864 | an invalid structure or an invalid value:: 865 | 866 | try: 867 | first_label_name = issue['labels', 0, 'name':str] 868 | except sanest.DataError: # or just ValueError 869 | ... 870 | 871 | Since :py:exc:`sanest.DataError` inherits from the 872 | built-in ``ValueError``, 873 | applications can also catch ``ValueError`` 874 | instead of exceptions specific to ``sanest``, 875 | which, depending on how the application code is organised, 876 | means that some modules may not require any ``sanest`` imports at all. 877 | 878 | **Exceptions for problematic code.      ** 879 | The following exceptions are typically 880 | the result of incorrect code, 881 | and hence should generally not be caught. 882 | The hierarchy is: 883 | 884 | * ``Exception`` (built-in exception) 885 | 886 | * :py:exc:`sanest.InvalidPathError` 887 | * :py:exc:`sanest.InvalidTypeError` 888 | 889 | Examples: 890 | 891 | :: 892 | 893 | >>> path = [True, True, True] 894 | >>> d[path] 895 | Traceback (most recent call last): 896 | ... 897 | InvalidPathError: path must contain only str or int: [True, True, True] 898 | 899 | :: 900 | 901 | >>> d.get('title', 'This is the default.', type="oops") 902 | Traceback (most recent call last): 903 | ... 904 | InvalidTypeError: expected dict, list, bool, float, int, str, [...] (for lists) or {str: ...} (for dicts), got 'oops' 905 | 906 | .. centered:: ❦ 907 | 908 | API 909 | === 910 | 911 | .. currentmodule:: sanest 912 | 913 | **Dictionary** 914 | 915 | .. autoclass:: sanest.dict 916 | :no-members: 917 | 918 | .. automethod:: wrap 919 | .. automethod:: unwrap 920 | 921 | .. automethod:: dict.fromkeys 922 | 923 | .. py:method:: d[path_like] 924 | .. automethod:: __getitem__ 925 | .. automethod:: get 926 | 927 | .. py:method:: d[path_like] = value 928 | .. automethod:: __setitem__ 929 | .. automethod:: setdefault 930 | .. automethod:: update 931 | 932 | .. py:method:: del d[path_like] 933 | .. automethod:: __delitem__ 934 | .. automethod:: pop 935 | .. automethod:: popitem 936 | .. automethod:: clear 937 | 938 | .. py:method:: path_like in d 939 | .. automethod:: __contains__ 940 | 941 | .. py:method:: len(d) 942 | .. automethod:: __len__ 943 | 944 | .. py:method:: iter(d) 945 | .. automethod:: __iter__ 946 | 947 | .. automethod:: keys 948 | .. automethod:: values 949 | .. automethod:: items 950 | 951 | .. automethod:: copy 952 | 953 | .. automethod:: check_types 954 | 955 | .. py:method:: d == other 956 | .. automethod:: __eq__ 957 | 958 | .. py:method:: d != other 959 | .. automethod:: __ne__ 960 | 961 | **List** 962 | 963 | .. autoclass:: sanest.list 964 | :no-members: 965 | 966 | .. automethod:: wrap 967 | .. automethod:: unwrap 968 | .. py:method:: l[path_like] 969 | .. automethod:: __getitem__ 970 | .. automethod:: index 971 | .. automethod:: count 972 | 973 | .. py:method:: l[path_like] = value 974 | .. automethod:: __setitem__ 975 | .. automethod:: insert 976 | .. automethod:: append 977 | .. py:method:: l + other 978 | .. automethod:: __add__ 979 | .. py:method:: l += other 980 | .. automethod:: __iadd__ 981 | .. automethod:: extend 982 | .. py:method:: l * n 983 | .. automethod:: __mul__ 984 | 985 | .. py:method:: del l[path_like] 986 | .. automethod:: __delitem__ 987 | .. automethod:: pop 988 | .. automethod:: remove 989 | .. automethod:: clear 990 | 991 | .. py:method:: path_like in l 992 | .. automethod:: __contains__ 993 | .. automethod:: contains 994 | 995 | .. py:method:: len(l) 996 | .. automethod:: __len__ 997 | 998 | .. py:method:: iter(l) 999 | .. automethod:: __iter__ 1000 | 1001 | .. py:method:: reversed(l) 1002 | .. automethod:: __reversed__ 1003 | 1004 | .. automethod:: sort 1005 | 1006 | .. automethod:: copy 1007 | 1008 | .. automethod:: check_types 1009 | 1010 | .. py:method:: l == other 1011 | .. automethod:: __eq__ 1012 | 1013 | .. py:method:: l != other 1014 | .. automethod:: __ne__ 1015 | 1016 | .. py:method:: l1 < l2 1017 | .. py:method:: l1 > l2 1018 | .. py:method:: l1 <= l2 1019 | .. py:method:: l1 >= l2 1020 | 1021 | Compare lists. 1022 | 1023 | **Exceptions** 1024 | 1025 | .. autoexception:: sanest.DataError 1026 | :show-inheritance: 1027 | 1028 | .. autoexception:: sanest.InvalidStructureError 1029 | :show-inheritance: 1030 | 1031 | .. autoexception:: sanest.InvalidValueError 1032 | :show-inheritance: 1033 | 1034 | .. autoexception:: sanest.InvalidPathError 1035 | :show-inheritance: 1036 | 1037 | .. autoexception:: sanest.InvalidTypeError 1038 | :show-inheritance: 1039 | 1040 | .. centered:: ❦ 1041 | 1042 | Version history 1043 | =============== 1044 | 1045 | * 0.1.0 (2017-07-02) 1046 | 1047 | * Initial release. 1048 | 1049 | Contributing 1050 | ============ 1051 | 1052 | The source code and issue tracker for this package can be found on Github: 1053 | 1054 | https://github.com/wbolster/sanest 1055 | 1056 | ``sanest`` has an extensive test suite 1057 | that covers the complete code base. 1058 | Please provide minimal examples 1059 | to demonstrate potential problems. 1060 | 1061 | .. centered:: ❦ 1062 | 1063 | License 1064 | ======= 1065 | 1066 | .. include:: ../LICENSE.rst 1067 | -------------------------------------------------------------------------------- /lightning-talk/lightning-talk.odp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wbolster/sanest/b794073012d54403ac0df9afd796f9bcf9ba5895/lightning-talk/lightning-talk.odp -------------------------------------------------------------------------------- /lightning-talk/lightning-talk.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wbolster/sanest/b794073012d54403ac0df9afd796f9bcf9ba5895/lightning-talk/lightning-talk.pdf -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = 3 | --verbose 4 | --verbose 5 | --tb short 6 | --cov sanest 7 | --cov-branch 8 | --cov-report term 9 | --cov-report html 10 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | -r requirements-test.txt 2 | sphinx 3 | sphinx-rtd-theme 4 | -------------------------------------------------------------------------------- /requirements-test.txt: -------------------------------------------------------------------------------- 1 | flake8 2 | IPython>=4.2.1 3 | pytest>=3 4 | pytest-cov 5 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | license_file = LICENSE.rst 3 | 4 | [build_sphinx] 5 | source-dir = doc/ 6 | build-dir = doc/build/ 7 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | 3 | from setuptools import find_packages, setup 4 | 5 | with open(os.path.join(os.path.dirname(__file__), 'README.rst')) as fp: 6 | long_description = fp.read() 7 | 8 | setup( 9 | name='sanest', 10 | version='0.1.0', 11 | description="sane nested dictionaries and lists", 12 | long_description=long_description, 13 | author="wouter bolsterlee", 14 | author_email="wouter@bolsterl.ee", 15 | url='https://github.com/wbolster/sanest', 16 | packages=find_packages(where='src'), 17 | package_dir={"": 'src'}, 18 | license="BSD", 19 | classifiers=[ 20 | 'Development Status :: 5 - Production/Stable', 21 | 'Intended Audience :: Developers', 22 | 'License :: OSI Approved :: BSD License', 23 | 'Programming Language :: Python', 24 | 'Programming Language :: Python :: 3', 25 | 'Programming Language :: Python :: 3 :: Only', 26 | 'Topic :: Software Development :: Libraries', 27 | 'Topic :: Software Development :: Libraries :: Python Modules', 28 | ] 29 | ) 30 | -------------------------------------------------------------------------------- /src/sanest/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | sanest, sane nested dictionaries and lists 3 | """ 4 | 5 | from .sanest import ( # noqa: F401 6 | dict, 7 | list, 8 | DataError, 9 | InvalidPathError, 10 | InvalidStructureError, 11 | InvalidTypeError, 12 | InvalidValueError, 13 | ) 14 | 15 | # Pretend that all public API is defined at the package level, 16 | # which changes the repr() of classes/functions to match intended use. 17 | for x in locals().copy().values(): 18 | if hasattr(x, '__module__'): 19 | x.__module__ = __name__ 20 | del x 21 | -------------------------------------------------------------------------------- /src/sanest/sanest.py: -------------------------------------------------------------------------------- 1 | import abc 2 | import builtins 3 | import collections 4 | import collections.abc 5 | import copy 6 | import pprint 7 | import reprlib 8 | import sys 9 | 10 | try: 11 | # Python 3.6+ 12 | from collections.abc import Collection as collections_abc_Collection 13 | except ImportError: # pragma: no cover 14 | # Python 3.5 and earlier 15 | class collections_abc_Collection( 16 | collections.abc.Sized, 17 | collections.abc.Iterable, 18 | collections.abc.Container): 19 | __slots__ = () 20 | 21 | ATOMIC_TYPES = (bool, float, int, str) 22 | CONTAINER_TYPES = (builtins.dict, builtins.list) 23 | TYPES = CONTAINER_TYPES + ATOMIC_TYPES 24 | PATH_TYPES = (builtins.tuple, builtins.list) 25 | STRING_LIKE_TYPES = (str, bytes, bytearray) 26 | 27 | typeof = builtins.type 28 | 29 | 30 | class Missing: 31 | def __repr__(self): 32 | return '' 33 | 34 | 35 | MISSING = Missing() 36 | 37 | 38 | class reprstr(str): 39 | """ 40 | String with a repr() identical to str(). 41 | 42 | This is a hack to "undo" an unwanted repr() made by code that 43 | cannot be changed. Practically, this prevents quote characters 44 | around the string. 45 | """ 46 | def __repr__(self): 47 | return self 48 | 49 | 50 | class InvalidPathError(Exception): 51 | """ 52 | Exception raised when a path is invalid. 53 | 54 | This indicates problematic code that uses an incorrect API. 55 | """ 56 | pass 57 | 58 | 59 | class InvalidTypeError(Exception): 60 | """ 61 | Exception raised when a specified type is invalid. 62 | 63 | This indicates problematic code that uses an incorrect API. 64 | """ 65 | pass 66 | 67 | 68 | class DataError(ValueError): 69 | """ 70 | Exception raised for data errors, such as invalid values and 71 | unexpected nesting structures. 72 | 73 | This is the base class for ``InvalidStructureError`` and 74 | ``InvalidValueError``, and can be caught instead of the more 75 | specific exception types. 76 | 77 | This is a subclass of the built-in ``ValueError``. 78 | """ 79 | 80 | 81 | class InvalidStructureError(DataError): 82 | """ 83 | Exception raised when a nested structure does not match the request. 84 | 85 | This is a subclass of ``DataError`` and the built-in ``ValueError``, 86 | since this indicates malformed data. 87 | """ 88 | pass 89 | 90 | 91 | class InvalidValueError(DataError): 92 | """ 93 | Exception raised when requesting or providing an invalid value. 94 | 95 | This is a subclass of ``DataError`` and the built-in ``ValueError``. 96 | """ 97 | pass 98 | 99 | 100 | def validate_path(path): 101 | """Validate that ``path`` is a valid path.""" 102 | if not path: 103 | raise InvalidPathError("empty path: {!r}".format(path)) 104 | if any(type(k) not in (int, str) for k in path): 105 | raise InvalidPathError( 106 | "path must contain only str or int: {!r}".format(path)) 107 | 108 | 109 | def validate_type(type): 110 | """ 111 | Validate that ``type`` is a valid argument for type checking purposes. 112 | """ 113 | if type in TYPES: 114 | return 115 | if typeof(type) is builtins.list and len(type) == 1 and type[0] in TYPES: 116 | # e.g. [str], [dict] 117 | return 118 | if typeof(type) is builtins.dict and len(type) == 1: 119 | # e.g. {str: int}, {str: [list]} 120 | key, value = next(iter(type.items())) 121 | if key is str and value in TYPES: 122 | return 123 | raise InvalidTypeError( 124 | "expected {}, [...] (for lists) or {{str: ...}} (for dicts), got {}" 125 | .format(', '.join(t.__name__ for t in TYPES), reprlib.repr(type))) 126 | 127 | 128 | def validate_value(value): 129 | """ 130 | Validate that ``value`` is a valid value. 131 | """ 132 | if value is None: 133 | return 134 | if type(value) not in TYPES: 135 | raise InvalidValueError( 136 | "invalid value of type {.__name__}: {}" 137 | .format(type(value), reprlib.repr(value))) 138 | if type(value) is builtins.dict: 139 | collections.deque(validated_items(value.items()), 0) # fast looping 140 | elif type(value) is builtins.list: 141 | collections.deque(validated_values(value), 0) # fast looping 142 | 143 | 144 | def validated_items(iterable): 145 | """ 146 | Validate that the pairs in ``iterable`` are valid dict items. 147 | """ 148 | for key, value in iterable: 149 | if type(key) is not str: 150 | raise InvalidPathError("invalid dict key: {!r}".format(key)) 151 | validate_value(value) 152 | yield key, value 153 | 154 | 155 | def validated_values(iterable): 156 | """ 157 | Validate the values in ``iterable``. 158 | """ 159 | for value in iterable: 160 | validate_value(value) 161 | yield value 162 | 163 | 164 | def pairs(*args, **kwargs): 165 | """ 166 | Yield key/value pairs, handling args like the ``dict()`` built-in does. 167 | 168 | Checks that keys are sane. 169 | """ 170 | if args: 171 | other, *rest = args 172 | if rest: 173 | raise TypeError( 174 | "expected at most 1 argument, got {0:d}".format(len(args))) 175 | if isinstance(other, collections.abc.Mapping): 176 | yield from other.items() 177 | elif hasattr(other, "keys"): # dict-like 178 | for key in other.keys(): 179 | yield key, other[key] 180 | else: 181 | yield from other # sequence of pairs 182 | yield from kwargs.items() 183 | 184 | 185 | def is_regular_list_slice(sl): 186 | """ 187 | Tells whether ``sl`` looks like a regular ``list`` slice. 188 | """ 189 | return ( 190 | (sl.start is None or type(sl.start) is int) 191 | and (sl.stop is None or type(sl.stop) is int) 192 | and (sl.step is None or type(sl.step) is int)) 193 | 194 | 195 | def wrap(value, *, check=True): 196 | """ 197 | Wrap a container (dict or list) without making a copy. 198 | """ 199 | if type(value) is builtins.dict: 200 | return sanest_dict.wrap(value, check=check) 201 | if type(value) is builtins.list: 202 | return sanest_list.wrap(value, check=check) 203 | raise TypeError("not a dict or list: {!r}".format(value)) 204 | 205 | 206 | def parse_path_like(path): 207 | """ 208 | Parse a "path-like": a key, an index, or a path of these. 209 | """ 210 | if type(path) in (str, int): 211 | return path, [path] 212 | if type(path) in PATH_TYPES: 213 | validate_path(path) 214 | return None, path 215 | raise InvalidPathError("invalid path: {!r}".format(path)) 216 | 217 | 218 | def parse_path_like_with_type(x, *, allow_slice=True): 219 | """ 220 | Parse a "path-like": a key, an index, or a path of these, 221 | with an optional type. 222 | """ 223 | sl = None 224 | if typeof(x) in (int, str): 225 | # e.g. d['a'] and d[2] 226 | key_or_index = x 227 | path = [key_or_index] 228 | type = None 229 | elif allow_slice and typeof(x) is slice: 230 | sl = x 231 | if typeof(sl.start) in PATH_TYPES: 232 | # e.g. d[path:str] 233 | key_or_index = None 234 | path = sl.start 235 | validate_path(path) 236 | else: 237 | # e.g. d['a':str] and d[2:str] 238 | key_or_index = sl.start 239 | path = [key_or_index] 240 | elif typeof(x) in PATH_TYPES: 241 | # e.g. d['a', 'b'] and d[path] and d['a', 'b':str] 242 | key_or_index = None 243 | path = builtins.list(x) # makes a copy 244 | type = None 245 | if path: 246 | if allow_slice and typeof(path[-1]) is slice: 247 | # e.g. d['a', 'b':str] 248 | sl = path.pop() 249 | if typeof(sl.start) in PATH_TYPES: 250 | raise InvalidPathError( 251 | "mixed path syntaxes: {!r}".format(x)) 252 | path.append(sl.start) 253 | elif not allow_slice: 254 | # e.g. ['a', 'b', str] 255 | try: 256 | validate_type(path[-1]) 257 | except InvalidTypeError: 258 | pass 259 | else: 260 | type = path.pop() 261 | if len(path) == 1 and typeof(path[0]) in PATH_TYPES: 262 | # e.g. [path, str] 263 | path = path[0] 264 | validate_path(path) 265 | else: 266 | raise InvalidPathError("invalid path: {!r}".format(x)) 267 | if sl is not None: 268 | if sl.stop is None: 269 | raise InvalidPathError( 270 | "type is required for slice syntax: {!r}".format(x)) 271 | type = sl.stop 272 | if sl.step is not None: 273 | raise InvalidPathError( 274 | "step value not allowed for slice syntax: {!r}".format(x)) 275 | if type is not None: 276 | validate_type(type) 277 | return key_or_index, path, type 278 | 279 | 280 | def repr_for_type(type): 281 | """ 282 | Return a friendly repr() for a type checking argument. 283 | """ 284 | if typeof(type) is builtins.list: 285 | # e.g. [int] 286 | return '[{}]'.format(type[0].__name__) 287 | if typeof(type) is builtins.dict: 288 | # e.g. {str: bool} 289 | return '{{str: {}}}'.format(next(iter(type.values())).__name__) 290 | if isinstance(type, builtins.type): 291 | # e.g. str 292 | return type.__name__ 293 | raise ValueError("invalid type: {!r}".format(type)) 294 | 295 | 296 | def check_type(value, *, type, path=None): 297 | """ 298 | Check that the type of ``value`` matches what ``type`` prescribes. 299 | """ 300 | # note: type checking is extremely strict: it avoids isinstance() 301 | # to avoid booleans passing as integers, and to avoid subclasses of 302 | # built-in types which will likely cause json serialisation errors 303 | # anyway. 304 | if type in TYPES and typeof(value) is type: 305 | # e.g. str, int 306 | return 307 | if typeof(type) is typeof(value) is builtins.list: 308 | # e.g. [str], [int] 309 | contained_type = type[0] 310 | if all(typeof(v) is contained_type for v in value): 311 | return 312 | actual = "non-conforming list" 313 | elif typeof(type) is typeof(value) is builtins.dict: 314 | # e.g. {str: bool} 315 | contained_type = type[next(iter(type))] # first dict value 316 | if all(typeof(v) is contained_type for v in value.values()): 317 | return 318 | actual = "non-conforming dict" 319 | else: 320 | actual = typeof(value).__name__ 321 | raise InvalidValueError("expected {}, got {}{}: {}".format( 322 | repr_for_type(type), 323 | actual, 324 | '' if path is None else ' at path {}'.format(path), 325 | reprlib.repr(value))) 326 | 327 | 328 | def clean_value(value, *, type=None): 329 | """ 330 | Obtain a clean value by checking types and unwrapping containers. 331 | 332 | This function performs basic input validation for container methods 333 | accepting a value argument from their caller. 334 | """ 335 | if type is not None: 336 | validate_type(type) 337 | if typeof(value) in SANEST_CONTAINER_TYPES: 338 | value = value._data 339 | elif value is not None: 340 | validate_value(value) 341 | if type is not None: 342 | check_type(value, type=type) 343 | return value 344 | 345 | 346 | def resolve_path(obj, path, *, partial=False, create=False): 347 | """ 348 | Resolve a ``path`` into ``obj``. 349 | 350 | When ``partial`` is ``True``, the last path component will not be 351 | resolved but returned instead, so that the caller can decide 352 | which operation to perform. 353 | 354 | Whecn ``create`` is ``True``, paths into non-existing dictionaries 355 | (but not into non-existing lists) are automatically created. 356 | """ 357 | if type(path[0]) is int and type(obj) is builtins.dict: 358 | raise InvalidPathError( 359 | "dict path must start with str: {!r}".format(path)) 360 | elif type(path[0]) is str and type(obj) is builtins.list: 361 | raise InvalidPathError( 362 | "list path must start with int: {!r}".format(path)) 363 | for n, key_or_index in enumerate(path): 364 | if type(key_or_index) is str and type(obj) is not builtins.dict: 365 | raise InvalidStructureError( 366 | "expected dict, got {.__name__} at subpath {!r} of {!r}" 367 | .format(type(obj), path[:n], path)) 368 | if type(key_or_index) is int and type(obj) is not builtins.list: 369 | raise InvalidStructureError( 370 | "expected list, got {.__name__} at subpath {!r} of {!r}" 371 | .format(type(obj), path[:n], path)) 372 | if partial and len(path) - 1 == n: 373 | break 374 | try: 375 | obj = obj[key_or_index] # may raise KeyError or IndexError 376 | except KeyError: # for dicts 377 | if create: 378 | obj[key_or_index] = obj = {} # autovivification 379 | else: 380 | raise KeyError(path[:n+1]) from None 381 | except IndexError: # for lists 382 | raise IndexError(path[:n+1]) from None 383 | tail = path[-1] 384 | if partial: 385 | return obj, tail 386 | else: 387 | return obj 388 | 389 | 390 | class FinalABCMeta(abc.ABCMeta): 391 | """ 392 | Meta-class to prevent subclassing. 393 | """ 394 | def __new__(cls, name, bases, classdict): 395 | for b in bases: 396 | if isinstance(b, FinalABCMeta): 397 | raise TypeError( 398 | "type '{}.{}' is not an acceptable base type" 399 | .format(b.__module__, b.__name__)) 400 | return super().__new__(cls, name, bases, builtins.dict(classdict)) 401 | 402 | 403 | class Collection(collections_abc_Collection): 404 | """ 405 | Base class for ``sanest.rodict`` and ``sanest.rolist``. 406 | """ 407 | __slots__ = ('_data',) 408 | 409 | @abc.abstractmethod 410 | def wrap(cls, data, *, check=True): 411 | raise NotImplementedError # pragma: no cover 412 | 413 | @abc.abstractmethod 414 | def unwrap(self): 415 | raise NotImplementedError # pragma: no cover 416 | 417 | def __len__(self): 418 | """ 419 | Return the number of items in this container. 420 | """ 421 | return len(self._data) 422 | 423 | def __getitem__(self, path_like): 424 | """ 425 | Look up the item that ``path_like`` (with optional type) points to. 426 | """ 427 | if typeof(path_like) is self._key_or_index_type: # fast path 428 | try: 429 | value = self._data[path_like] 430 | except LookupError as exc: 431 | raise typeof(exc)([path_like]) from None 432 | else: 433 | key_or_index, path, type = parse_path_like_with_type(path_like) 434 | value = resolve_path(self._data, path) 435 | if type is not None: 436 | check_type(value, type=type, path=path) 437 | if typeof(value) in CONTAINER_TYPES: 438 | value = wrap(value, check=False) 439 | return value 440 | 441 | def __eq__(self, other): 442 | """ 443 | Determine whether this container and ``other`` have the same values. 444 | """ 445 | if self is other: 446 | return True 447 | if type(other) is type(self): 448 | if self._data is other._data: 449 | return True 450 | return self._data == other._data 451 | if type(other) in CONTAINER_TYPES: 452 | return self._data == other 453 | return NotImplemented 454 | 455 | def __ne__(self, other): 456 | """ 457 | Determine whether this container and ``other`` have different values. 458 | """ 459 | return not self == other 460 | 461 | def __repr__(self): 462 | return '{}.{.__name__}({!r})'.format( 463 | self.__module__, type(self), self._data) 464 | 465 | def _truncated_repr(self): 466 | """Helper for the repr() of dictionary views.""" 467 | return '{.__module__}.{.__name__}({})'.format( 468 | self, type(self), reprlib.repr(self._data)) 469 | 470 | def _repr_pretty_(self, p, cycle): 471 | """Helper for pretty-printing in IPython.""" 472 | opening = '{.__module__}.{.__name__}('.format(self, type(self)) 473 | if cycle: # pragma: no cover 474 | p.text(opening + '...)') 475 | else: 476 | with p.group(len(opening), opening, ')'): 477 | p.pretty(self._data) 478 | 479 | def __copy__(self): 480 | cls = type(self) 481 | obj = cls.__new__(cls) 482 | obj._data = self._data.copy() 483 | return obj 484 | 485 | def __deepcopy__(self, memo): 486 | cls = type(self) 487 | obj = cls.__new__(cls) 488 | obj._data = copy.deepcopy(self._data, memo) 489 | return obj 490 | 491 | def copy(self, *, deep=False): 492 | """ 493 | Make a copy of this container. 494 | 495 | By default this return a shallow copy. 496 | When `deep` is ``True``, this returns a deep copy. 497 | 498 | :param deep bool: whether to make a deep copy 499 | """ 500 | fn = copy.deepcopy if deep else copy.copy 501 | return fn(self) 502 | 503 | 504 | class MutableCollection(Collection): 505 | """ 506 | Base class for ``sanest.dict`` and ``sanest.list``. 507 | """ 508 | __slots__ = () 509 | 510 | def __setitem__(self, path_like, value): 511 | """ 512 | Set the item that ``path_like`` (with optional type) points to. 513 | """ 514 | if typeof(path_like) is self._key_or_index_type: # fast path 515 | obj = self._data 516 | key_or_index = path_like 517 | path = [key_or_index] 518 | value = clean_value(value) 519 | else: 520 | key_or_index, path, type = parse_path_like_with_type(path_like) 521 | value = clean_value(value, type=type) 522 | obj, key_or_index = resolve_path( 523 | self._data, path, partial=True, create=True) 524 | try: 525 | obj[key_or_index] = value 526 | except IndexError as exc: # list assignment can fail 527 | raise IndexError(path) from None 528 | 529 | def __delitem__(self, path_like): 530 | """ 531 | Delete the item that ``path_like`` (with optional type) points to. 532 | """ 533 | key_or_index, path, type = parse_path_like_with_type(path_like) 534 | obj, key_or_index = resolve_path(self._data, path, partial=True) 535 | try: 536 | if type is not None: 537 | value = obj[key_or_index] 538 | check_type(value, type=type, path=path) 539 | del obj[key_or_index] 540 | except LookupError as exc: 541 | raise typeof(exc)(path) from None 542 | 543 | 544 | def pprint_sanest_collection( 545 | self, object, stream, indent, allowance, context, level): 546 | """ 547 | Pretty-printing helper for use by the built-in pprint module. 548 | """ 549 | opening = '{.__module__}.{.__name__}('.format(object, type(object)) 550 | stream.write(opening) 551 | if type(object._data) is builtins.dict: 552 | f = self._pprint_dict 553 | else: 554 | f = self._pprint_list 555 | f(object._data, stream, indent + len(opening), allowance, context, level) 556 | stream.write(')') 557 | 558 | 559 | # This is a hack that changes the internals of the pprint module, 560 | # which has no public API to register custom formatter routines. 561 | try: 562 | dispatch_table = pprint.PrettyPrinter._dispatch 563 | except Exception: # pragma: no cover 564 | pass # Python 3.4 and older do not have a dispatch table. 565 | else: 566 | dispatch_table[Collection.__repr__] = pprint_sanest_collection 567 | 568 | 569 | class Mapping(Collection, collections.abc.Mapping): 570 | __slots__ = () 571 | 572 | _key_or_index_type = str 573 | 574 | @classmethod 575 | def wrap(cls, d, *, check=True): 576 | """ 577 | Wrap an existing dictionary without making a copy. 578 | 579 | :param d: existing dictionary 580 | :param check bool: whether to perform basic validation 581 | """ 582 | if type(d) is cls: 583 | return d # already wrapped 584 | if type(d) is not builtins.dict: 585 | raise TypeError("not a dict") 586 | if check: 587 | collections.deque(validated_items(d.items()), 0) # fast looping 588 | obj = cls.__new__(cls) 589 | obj._data = d 590 | return obj 591 | 592 | def unwrap(self): 593 | """ 594 | Return a regular ``dict`` without making a copy. 595 | 596 | This ``sanest.dict`` can be safely used afterwards as long 597 | as the returned dictionary is not modified in an incompatible way. 598 | """ 599 | return self._data 600 | 601 | def check_types(self, *, type): 602 | """ 603 | Check the type of all values in this dictionary. 604 | 605 | :param type: expected type 606 | """ 607 | validate_type(type) 608 | for key, value in self._data.items(): 609 | check_type(value, type=type, path=[key]) 610 | 611 | def __iter__(self): 612 | """ 613 | Iterate over the keys of this dictionary. 614 | """ 615 | return iter(self._data) 616 | 617 | def get(self, path_like, default=None, *, type=None): 618 | """ 619 | Get a value or a default value; like ``dict.get()``. 620 | 621 | :param path_like: key or path to look up 622 | :param default: default value to return for failed lookups 623 | :param type: expected type 624 | """ 625 | if type is not None: 626 | validate_type(type) 627 | key, path = parse_path_like(path_like) 628 | if typeof(path[-1]) is not str: 629 | raise InvalidPathError("path must lead to dict key") 630 | try: 631 | if typeof(key) is str: 632 | value = self._data[key] 633 | else: 634 | value = resolve_path(self._data, path) 635 | except LookupError: 636 | return default 637 | if type is not None: 638 | check_type(value, type=type, path=path) 639 | if typeof(value) in CONTAINER_TYPES: 640 | value = wrap(value, check=False) 641 | return value 642 | 643 | def __contains__(self, path_like): 644 | """ 645 | Check whether ``path_like`` (with optional type) points to an 646 | existing value. 647 | """ 648 | if typeof(path_like) is str: # fast path 649 | # e.g. 'a' in d 650 | return path_like in self._data 651 | # e.g. ['a', 'b'] and ['a', 'b', int] (slice syntax not possible) 652 | _, path, type = parse_path_like_with_type(path_like, allow_slice=False) 653 | try: 654 | if type is None: 655 | self[path] 656 | else: 657 | self[path:type] 658 | except (LookupError, DataError): 659 | return False 660 | else: 661 | return True 662 | 663 | def keys(self): 664 | """ 665 | Return a dictionary view over the keys; like ``dict.keys()``. 666 | """ 667 | return DictKeysView(self) 668 | 669 | def values(self, *, type=None): 670 | """ 671 | Return a dictionary view over the values; like ``dict.values()``. 672 | 673 | :param type: expected type 674 | """ 675 | if type is not None: 676 | self.check_types(type=type) 677 | return DictValuesView(self) 678 | 679 | def items(self, *, type=None): 680 | """ 681 | Return a dictionary view over the items; like ``dict.items()``. 682 | 683 | :param type: expected type 684 | """ 685 | if type is not None: 686 | self.check_types(type=type) 687 | return DictItemsView(self) 688 | 689 | 690 | class MutableMapping( 691 | Mapping, 692 | MutableCollection, 693 | collections.abc.MutableMapping): 694 | __slots__ = () 695 | 696 | def __init__(self, *args, **kwargs): 697 | self._data = {} 698 | if args or kwargs: 699 | self.update(*args, **kwargs) 700 | 701 | @classmethod 702 | def fromkeys(cls, iterable, value=None): 703 | """ 704 | Like ``dict.fromkeys()``. 705 | 706 | :param iterable: iterable of keys 707 | :param value: initial value 708 | """ 709 | return cls((key, value) for key in iterable) 710 | 711 | def setdefault(self, path_like, default=None, *, type=None): 712 | """ 713 | Get a value or set (and return) a default; like ``dict.setdefault()``. 714 | 715 | :param path_like: key or path 716 | :param default: default value to return for failed lookups 717 | :param type: expected type 718 | """ 719 | value = self.get(path_like, MISSING, type=type) 720 | if value is MISSING: 721 | # default value validation is done by set() 722 | key, path = parse_path_like(path_like) 723 | if type is None: 724 | self[path] = default 725 | else: 726 | self[path:type] = default 727 | value = default 728 | if typeof(value) in CONTAINER_TYPES: 729 | value = wrap(value, check=False) 730 | else: 731 | # check default value even if an existing value was found, 732 | # so that this method is strict regardless of dict contents. 733 | clean_value(default, type=type) 734 | return value 735 | 736 | def update(self, *args, **kwargs): 737 | """ 738 | Update with new items; like ``dict.update()``. 739 | """ 740 | self._data.update(validated_items(pairs(*args, **kwargs))) 741 | 742 | def pop(self, path_like, default=MISSING, *, type=None): 743 | """ 744 | Remove an item and return its value; like ``dict.pop()``. 745 | 746 | :param path_like: key or path 747 | :param default: default value to return for failed lookups 748 | :param type: expected type 749 | """ 750 | if type is not None: 751 | validate_type(type) 752 | if typeof(path_like) is str: # fast path 753 | d = self._data 754 | key = path_like 755 | path = [key] 756 | value = d.get(key, MISSING) 757 | else: 758 | _, path = parse_path_like(path_like) 759 | if typeof(path[-1]) is not str: 760 | raise InvalidPathError("path must lead to dict key") 761 | try: 762 | d, key = resolve_path(self._data, path, partial=True) 763 | except LookupError: 764 | if default is MISSING: 765 | raise # contains partial path in exception message 766 | value = MISSING 767 | else: 768 | value = d.get(key, MISSING) 769 | if value is MISSING: 770 | if default is MISSING: 771 | raise KeyError(path) from None 772 | return default 773 | if type is not None: 774 | check_type(value, type=type, path=path) 775 | del d[key] 776 | if typeof(value) in CONTAINER_TYPES: 777 | value = wrap(value, check=False) 778 | return value 779 | 780 | def popitem(self, *, type=None): 781 | """ 782 | Remove and return a random item; like ``dict.popitem()``. 783 | 784 | :param type: expected type 785 | """ 786 | try: 787 | key = next(iter(self._data)) 788 | except StopIteration: 789 | raise KeyError(reprstr("dictionary is empty")) from None 790 | value = self.pop(key, type=type) 791 | return key, value 792 | 793 | def clear(self): 794 | """ 795 | Remove all items; like ``dict.clear()``. 796 | """ 797 | self._data.clear() 798 | 799 | 800 | class rodict(Mapping, metaclass=FinalABCMeta): 801 | """ 802 | Read-only dict-like container supporting nested lookups and type checking. 803 | """ 804 | __slots__ = () 805 | 806 | 807 | class dict(MutableMapping, metaclass=FinalABCMeta): 808 | """ 809 | dict-like container supporting nested lookups and type checking. 810 | """ 811 | __slots__ = () 812 | 813 | 814 | class DictKeysView(collections.abc.KeysView): 815 | __slots__ = () 816 | 817 | def __repr__(self): 818 | return '{}.keys()'.format(self._mapping._truncated_repr()) 819 | 820 | 821 | class DictValuesView(collections.abc.ValuesView): 822 | __slots__ = ('_sanest_dict') 823 | 824 | def __init__(self, d): 825 | self._sanest_dict = d 826 | super().__init__(d) 827 | 828 | def __repr__(self): 829 | return '{}.values()'.format(self._mapping._truncated_repr()) 830 | 831 | def __contains__(self, value): 832 | value = clean_value(value) 833 | return any( # pragma: no branch 834 | v is value or v == value 835 | for v in self._sanest_dict._data.values()) 836 | 837 | def __iter__(self): 838 | for value in self._sanest_dict._data.values(): 839 | if type(value) in CONTAINER_TYPES: 840 | value = wrap(value, check=False) 841 | yield value 842 | 843 | 844 | class DictItemsView(collections.abc.ItemsView): 845 | __slots__ = ('_sanest_dict') 846 | 847 | def __init__(self, d): 848 | self._sanest_dict = d 849 | super().__init__(d) 850 | 851 | def __repr__(self): 852 | return '{}.items()'.format(self._mapping._truncated_repr()) 853 | 854 | def __contains__(self, item): 855 | key, value = item 856 | value = clean_value(value) 857 | try: 858 | v = self._sanest_dict[key] 859 | except KeyError: 860 | return False 861 | else: 862 | return v is value or v == value 863 | 864 | def __iter__(self): 865 | for key, value in self._sanest_dict._data.items(): 866 | if type(value) in CONTAINER_TYPES: 867 | value = wrap(value, check=False) 868 | yield key, value 869 | 870 | 871 | class Sequence(Collection, collections.abc.Sequence): 872 | __slots__ = () 873 | 874 | _key_or_index_type = int 875 | 876 | @classmethod 877 | def wrap(cls, l, *, check=True): 878 | """ 879 | Wrap an existing list without making a copy. 880 | 881 | :param l: existing list 882 | :param check bool: whether to perform basic validation 883 | """ 884 | if type(l) is cls: 885 | return l # already wrapped 886 | if type(l) is not builtins.list: 887 | raise TypeError("not a list") 888 | if check: 889 | collections.deque(validated_values(l), 0) # fast looping 890 | obj = cls.__new__(cls) 891 | obj._data = l 892 | return obj 893 | 894 | def unwrap(self): 895 | """ 896 | Return a regular ``list`` without making a copy. 897 | 898 | This ``sanest.list`` can be safely used afterwards as long 899 | as the returned list is not modified in an incompatible way. 900 | """ 901 | return self._data 902 | 903 | def check_types(self, *, type): 904 | """ 905 | Check the type of all values in this list. 906 | 907 | :param type: expected type 908 | """ 909 | validate_type(type) 910 | for index, value in enumerate(self._data): 911 | check_type(value, type=type, path=[index]) 912 | 913 | def __iter__(self): 914 | """ 915 | Iterate over the values in this list. 916 | """ 917 | for value in self._data: 918 | if type(value) in CONTAINER_TYPES: 919 | value = wrap(value, check=False) 920 | yield value 921 | 922 | def iter(self, *, type=None): 923 | """ 924 | Iterate over this list after checking the type of its values. 925 | 926 | Without a ``type`` argument this is the same as ``iter(list)``. 927 | 928 | :param type: expected type 929 | """ 930 | if type is not None: 931 | self.check_types(type=type) 932 | return iter(self) 933 | 934 | def __getitem__(self, path_like): 935 | if type(path_like) is slice and is_regular_list_slice(path_like): 936 | return sanest_list.wrap(self._data[path_like], check=False) 937 | return super().__getitem__(path_like) 938 | 939 | __getitem__.__doc__ = Collection.__getitem__.__doc__ 940 | 941 | def __lt__(self, other): 942 | if type(other) is type(self): 943 | return self._data < other._data 944 | if type(other) is builtins.list: 945 | return self._data < other 946 | return NotImplemented 947 | 948 | def __le__(self, other): 949 | if type(other) is type(self): 950 | return self._data <= other._data 951 | if type(other) is builtins.list: 952 | return self._data <= other 953 | return NotImplemented 954 | 955 | def __gt__(self, other): 956 | if type(other) is type(self): 957 | return self._data > other._data 958 | if type(other) is builtins.list: 959 | return self._data > other 960 | return NotImplemented 961 | 962 | def __ge__(self, other): 963 | if type(other) is type(self): 964 | return self._data >= other._data 965 | if type(other) is builtins.list: 966 | return self._data >= other 967 | return NotImplemented 968 | 969 | def __contains__(self, value): 970 | """ 971 | Check whether ``value`` is contained in this list. 972 | """ 973 | return clean_value(value) in self._data 974 | 975 | def contains(self, value, *, type=None): 976 | """ 977 | Check whether ``value`` is contained in this list. 978 | 979 | This is the same as ``value in l`` but allows for a type check. 980 | 981 | :param type: expected type 982 | """ 983 | try: 984 | return clean_value(value, type=type) in self._data 985 | except InvalidValueError: 986 | return False 987 | 988 | def index(self, value, start=0, stop=None, *, type=None): 989 | """ 990 | Get the index of ``value``; like ``list.index()``. 991 | 992 | :param value: value to look up 993 | :param start: start index 994 | :param stop: stop index 995 | :param type: expected type 996 | """ 997 | if stop is None: 998 | stop = sys.maxsize 999 | return self._data.index(clean_value(value, type=type), start, stop) 1000 | 1001 | def count(self, value, *, type=None): 1002 | """ 1003 | Count how often ``value`` occurs; like ``list.count()``. 1004 | 1005 | :param value: value to count 1006 | :param type: expected type 1007 | """ 1008 | return self._data.count(clean_value(value, type=type)) 1009 | 1010 | def __reversed__(self): 1011 | """ 1012 | Return an iterator in reversed order. 1013 | """ 1014 | for value in reversed(self._data): 1015 | if type(value) in CONTAINER_TYPES: 1016 | value = wrap(value, check=False) 1017 | yield value 1018 | 1019 | def __add__(self, other): 1020 | """ 1021 | Return a new list with the concatenation of this list and ``other``. 1022 | """ 1023 | if type(other) not in (type(self), builtins.list): 1024 | raise TypeError( 1025 | "expected list, got {.__name__}".format(type(other))) 1026 | result = self.copy() 1027 | result.extend(other) 1028 | return result 1029 | 1030 | def __radd__(self, other): 1031 | return other + self._data 1032 | 1033 | def __mul__(self, n): 1034 | """ 1035 | Return a new list containing ``n`` copies of this list. 1036 | """ 1037 | return type(self).wrap(self._data * n, check=False) 1038 | 1039 | __rmul__ = __mul__ 1040 | 1041 | 1042 | class MutableSequence( 1043 | Sequence, 1044 | MutableCollection, 1045 | collections.abc.MutableSequence): 1046 | __slots__ = () 1047 | 1048 | def __init__(self, *args): 1049 | self._data = [] 1050 | if args: 1051 | iterable, *rest = args 1052 | if rest: 1053 | raise TypeError( 1054 | "expected at most 1 argument, got {0:d}".format(len(args))) 1055 | self.extend(iterable) 1056 | 1057 | def __setitem__(self, path_like, value): 1058 | if type(path_like) is slice and is_regular_list_slice(path_like): 1059 | # slice assignment takes any iterable, like .extend() 1060 | if isinstance(value, STRING_LIKE_TYPES): 1061 | raise TypeError( 1062 | "expected iterable that is not string-like, " 1063 | "got {.__name__}".format(type(value))) 1064 | if type(value) in SANEST_CONTAINER_TYPES: 1065 | value = value._data 1066 | else: 1067 | value = validated_values(value) 1068 | self._data[path_like] = value 1069 | else: 1070 | return super().__setitem__(path_like, value) 1071 | 1072 | __setitem__.__doc__ = MutableCollection.__setitem__.__doc__ 1073 | 1074 | def __delitem__(self, path_like): 1075 | if type(path_like) is slice and is_regular_list_slice(path_like): 1076 | del self._data[path_like] 1077 | else: 1078 | return super().__delitem__(path_like) 1079 | 1080 | __delitem__.__doc__ = MutableCollection.__delitem__.__doc__ 1081 | 1082 | def insert(self, index, value, *, type=None): 1083 | """ 1084 | Insert a value; like ``list.insert()``. 1085 | 1086 | :param index: position to insert at 1087 | :param value: value to insert 1088 | :param type: expected type 1089 | """ 1090 | self._data.insert(index, clean_value(value, type=type)) 1091 | 1092 | def append(self, value, *, type=None): 1093 | """ 1094 | Append a value; like ``list.append()``. 1095 | 1096 | :param value: value to append 1097 | :param type: expected type 1098 | """ 1099 | self._data.append(clean_value(value, type=type)) 1100 | 1101 | def extend(self, iterable, *, type=None): 1102 | """ 1103 | Extend with values from ``iterable``; like ``list.extend()``. 1104 | 1105 | :param iterable: iterable of values to append 1106 | :param type: expected type 1107 | """ 1108 | if typeof(iterable) is typeof(self): 1109 | self._data.extend(iterable._data) 1110 | elif isinstance(iterable, STRING_LIKE_TYPES): 1111 | raise TypeError( 1112 | "expected iterable that is not string-like, got {.__name__}" 1113 | .format(typeof(iterable))) 1114 | else: 1115 | for value in iterable: 1116 | self.append(value, type=type) 1117 | 1118 | def __iadd__(self, other): 1119 | self.extend(other) 1120 | return self 1121 | 1122 | def pop(self, path_like=-1, *, type=None): 1123 | """ 1124 | Remove and return an item; like ``list.pop()``. 1125 | 1126 | :param path_like: position to look up 1127 | :param type: expected type 1128 | """ 1129 | if type is not None: 1130 | validate_type(type) 1131 | if typeof(path_like) is int: # fast path 1132 | ll = self._data 1133 | index = path_like 1134 | path = [index] 1135 | else: 1136 | index, path = parse_path_like(path_like) 1137 | if typeof(path[-1]) is not int: 1138 | raise InvalidPathError("path must lead to list index") 1139 | ll, index = resolve_path(self._data, path, partial=True) 1140 | if not ll: 1141 | raise IndexError("pop from empty list") 1142 | try: 1143 | value = ll[index] 1144 | except IndexError: 1145 | raise IndexError(path) from None 1146 | if type is not None: 1147 | check_type(value, type=type, path=path) 1148 | del ll[index] 1149 | if typeof(value) in CONTAINER_TYPES: 1150 | value = wrap(value, check=False) 1151 | return value 1152 | 1153 | def remove(self, value, *, type=None): 1154 | """ 1155 | Remove an item; like ``list.remove()``. 1156 | 1157 | :param value: value to remove 1158 | :param type: expected type 1159 | """ 1160 | value = clean_value(value, type=type) 1161 | try: 1162 | self._data.remove(value) 1163 | except ValueError: 1164 | raise ValueError("{!r} is not in list".format(value)) from None 1165 | 1166 | def clear(self): 1167 | """ 1168 | Remove all items; like ``list.clear()``. 1169 | """ 1170 | self._data.clear() 1171 | 1172 | def reverse(self): 1173 | """ 1174 | Reverse in-place; like ``list.reverse()``. 1175 | """ 1176 | self._data.reverse() 1177 | 1178 | def sort(self, key=None, reverse=False): 1179 | """ 1180 | Sort in-place; like ``list.sort()``. 1181 | 1182 | :param key: callable to make a sort key 1183 | :param reverse: whether to sort in reverse order 1184 | """ 1185 | self._data.sort(key=key, reverse=reverse) 1186 | 1187 | 1188 | class rolist(Sequence, metaclass=FinalABCMeta): 1189 | """ 1190 | Read-only list-like container supporting nested lookups and type checking. 1191 | """ 1192 | __slots__ = () 1193 | 1194 | 1195 | class list(MutableSequence, metaclass=FinalABCMeta): 1196 | """ 1197 | list-like container supporting nested lookups and type checking. 1198 | """ 1199 | __slots__ = () 1200 | 1201 | 1202 | # internal aliases to make the code above less confusing 1203 | sanest_dict = dict 1204 | sanest_rodict = rodict 1205 | sanest_list = list 1206 | sanest_rolist = rolist 1207 | 1208 | SANEST_CONTAINER_TYPES = (sanest_dict, sanest_list) 1209 | -------------------------------------------------------------------------------- /test_sanest.py: -------------------------------------------------------------------------------- 1 | """ 2 | tests for sanest 3 | """ 4 | 5 | import builtins 6 | import copy 7 | import pickle 8 | import pprint 9 | import sys 10 | import textwrap 11 | 12 | import IPython.lib.pretty 13 | import pytest 14 | 15 | import sanest 16 | from sanest import sanest as _sanest # internal module 17 | 18 | 19 | class MyClass: 20 | def __repr__(self): 21 | return '' 22 | 23 | 24 | # 25 | # path parsing 26 | # 27 | 28 | class WithGetItem: 29 | def __getitem__(self, thing): 30 | return _sanest.parse_path_like_with_type(thing) 31 | 32 | 33 | def test_parse_path_like_with_type_as_slice(): 34 | x = WithGetItem() 35 | path = ['a', 'b'] 36 | assert x['a'] == ('a', ['a'], None) 37 | assert x[2] == (2, [2], None) 38 | assert x['a':str] == ('a', ['a'], str) 39 | assert x[2:str] == (2, [2], str) 40 | assert x[path] == (None, ['a', 'b'], None) 41 | assert x[path:str] == (None, ['a', 'b'], str) 42 | assert x['a', 'b'] == (None, ['a', 'b'], None) 43 | assert x['a', 'b':str] == (None, ['a', 'b'], str) 44 | with pytest.raises(sanest.InvalidPathError) as excinfo: 45 | empty_path = [] 46 | x[empty_path] 47 | assert str(excinfo.value) == "empty path: []" 48 | with pytest.raises(sanest.InvalidPathError) as excinfo: 49 | x[1.23] 50 | assert str(excinfo.value) == "invalid path: 1.23" 51 | with pytest.raises(sanest.InvalidPathError) as excinfo: 52 | x['x', path:int] 53 | assert str(excinfo.value).startswith("mixed path syntaxes: ") 54 | with pytest.raises(sanest.InvalidPathError) as excinfo: 55 | x[path, 'a':int] 56 | assert str(excinfo.value).startswith("path must contain only str or int: ") 57 | with pytest.raises(sanest.InvalidPathError) as excinfo: 58 | x['a':int:str] 59 | assert str(excinfo.value).startswith( 60 | "step value not allowed for slice syntax: ") 61 | with pytest.raises(sanest.InvalidPathError) as excinfo: 62 | x['a':None] 63 | assert str(excinfo.value).startswith("type is required for slice syntax: ") 64 | 65 | 66 | def test_parse_path_like_with_type_in_list(): 67 | f = _sanest.parse_path_like_with_type 68 | assert f('a', allow_slice=False) == ('a', ['a'], None) 69 | assert f(['a', str], allow_slice=False) == (None, ['a'], str) 70 | assert f(['a', 'b'], allow_slice=False) == (None, ['a', 'b'], None) 71 | assert f(['a', 'b', str], allow_slice=False) == (None, ['a', 'b'], str) 72 | path = ['a', 'b'] 73 | assert f([path, str], allow_slice=False) == (None, ['a', 'b'], str) 74 | 75 | 76 | # 77 | # type checking 78 | # 79 | 80 | @pytest.mark.parametrize('t', [ 81 | str, 82 | bool, 83 | int, 84 | float, 85 | dict, 86 | list, 87 | [str], 88 | [int], 89 | [dict], 90 | {str: int}, 91 | {str: str}, 92 | {str: list}, 93 | ]) 94 | def test_validate_type(t): 95 | _sanest.validate_type(t) 96 | 97 | 98 | @pytest.mark.parametrize('t', [ 99 | None, 100 | bytes, 101 | object, 102 | 'xyz', 103 | [None], 104 | [bytes], 105 | [object], 106 | ['xyz'], 107 | {'a': 'b'}, 108 | {int: str}, 109 | {str: bytes}, 110 | ]) 111 | def test_validate_type_invalid(t): 112 | with pytest.raises(sanest.InvalidTypeError) as excinfo: 113 | _sanest.validate_type(t) 114 | assert str(excinfo.value).startswith('expected dict, list, ') 115 | 116 | 117 | def test_type_checking_success(): 118 | d = { 119 | 'a': [ 120 | {'b': 'c'}, 121 | {'d': 'e'}, 122 | ] 123 | } 124 | _sanest.check_type(d, type=dict) 125 | _sanest.check_type(d['a'], type=list) 126 | _sanest.check_type(d['a'], type=[dict]) 127 | _sanest.check_type(d['a'][0], type=dict) 128 | _sanest.check_type(d['a'][0], type={str: str}) 129 | 130 | 131 | @pytest.mark.parametrize(('value', 'type', 'message'), [ 132 | ('a', int, "expected int, got str"), 133 | (123, str, "expected str, got int"), 134 | (True, int, "expected int, got bool"), 135 | ([123, 'a', 'b'], [int], "expected [int], got non-conforming list"), 136 | ({'a': 123}, {str: str}, "expected {str: str}, got non-conforming dict"), 137 | ]) 138 | def test_type_checking_fails(value, type, message): 139 | _sanest.validate_type(type) 140 | with pytest.raises(sanest.InvalidValueError) as excinfo: 141 | _sanest.check_type(value, type=type) 142 | assert str(excinfo.value).startswith("{}: ".format(message)) 143 | 144 | 145 | @pytest.mark.parametrize(('type', 'expected'), [ 146 | (int, 'int'), 147 | (str, 'str'), 148 | ([dict], '[dict]'), 149 | ([int], '[int]'), 150 | ({str: str}, '{str: str}'), 151 | ({str: dict}, '{str: dict}'), 152 | ]) 153 | def test_type_repr(type, expected): 154 | actual = _sanest.repr_for_type(type) 155 | assert actual == expected 156 | 157 | 158 | def test_type_repr_invalid(): 159 | with pytest.raises(ValueError) as excinfo: 160 | _sanest.repr_for_type('foobar') 161 | assert str(excinfo.value) == "invalid type: 'foobar'" 162 | 163 | 164 | # 165 | # dicts 166 | # 167 | 168 | def test_pairs(): 169 | actual = list(_sanest.pairs(a=1)) 170 | expected = [("a", 1)] 171 | assert actual == expected 172 | 173 | actual = list(_sanest.pairs({'a': 1}, b=2)) 174 | expected = [("a", 1), ("b", 2)] 175 | assert actual == expected 176 | 177 | actual = list(_sanest.pairs([("a", 1), ("b", 2)])) 178 | expected = [("a", 1), ("b", 2)] 179 | assert actual == expected 180 | 181 | class WithKeys: 182 | def keys(self): 183 | yield "a" 184 | yield "b" 185 | 186 | def __getitem__(self, key): 187 | return "x" 188 | 189 | actual = list(_sanest.pairs(WithKeys())) 190 | expected = [("a", "x"), ("b", "x")] 191 | assert actual == expected 192 | 193 | g = _sanest.pairs({}, {}, {}) 194 | with pytest.raises(TypeError) as excinfo: 195 | next(g) 196 | assert str(excinfo.value) == "expected at most 1 argument, got 3" 197 | 198 | 199 | def test_dict_basics(): 200 | d = sanest.dict() 201 | d['a'] = 1 202 | assert d['a'] == 1 203 | d['a'] = 2 204 | d['b'] = 3 205 | assert d['a'] == 2 206 | assert d['b'] == 3 207 | 208 | 209 | def test_dict_comparison(): 210 | d1 = sanest.dict({'a': 1}) 211 | d2 = sanest.dict({'a': 1}) 212 | d3 = {'a': 1} 213 | d4 = sanest.dict({'b': 2}) 214 | assert d1 == d2 215 | assert d1 == d3 216 | assert d1 == d1 217 | assert d4 != d1 218 | assert d4 != d3 219 | assert d1 != object() 220 | 221 | 222 | def test_dict_constructor(): 223 | regular_dict = {'a': 1, 'b': 2} 224 | d = sanest.dict(regular_dict) 225 | assert d == regular_dict 226 | d = sanest.dict(regular_dict, c=3) 227 | regular_dict['c'] = 3 228 | assert d == regular_dict 229 | 230 | 231 | def test_dict_length_and_truthiness(): 232 | d = sanest.dict() 233 | assert len(d) == 0 234 | assert not d 235 | assert not bool(d) 236 | d['a'] = 'aaa' 237 | assert len(d) == 1 238 | assert d 239 | assert bool(d) 240 | d['a'] = 'abc' 241 | assert len(d) == 1 242 | assert d 243 | d['b'] = 'bbb' 244 | assert len(d) == 2 245 | assert d 246 | 247 | 248 | def test_dict_clear(): 249 | d = sanest.dict() 250 | d['a'] = 1 251 | assert len(d) == 1 252 | d.clear() 253 | assert 'a' not in d 254 | assert len(d) == 0 255 | assert not d 256 | 257 | 258 | @pytest.mark.parametrize('key', [ 259 | 123.456, 260 | None, 261 | b"foo", 262 | True, 263 | [], 264 | ]) 265 | def test_dict_string_keys_only(key): 266 | d = sanest.dict() 267 | with pytest.raises(sanest.InvalidPathError): 268 | d[key] 269 | with pytest.raises(sanest.InvalidPathError): 270 | d.get(key) 271 | with pytest.raises(sanest.InvalidPathError): 272 | key in d 273 | with pytest.raises(sanest.InvalidPathError): 274 | d[key] = key 275 | with pytest.raises(sanest.InvalidPathError): 276 | del d[key] 277 | with pytest.raises(sanest.InvalidPathError): 278 | d.pop(key) 279 | 280 | 281 | def test_dict_getitem(): 282 | d = sanest.dict() 283 | 284 | d['a'] = 1 285 | assert d['a'] == 1 286 | 287 | with pytest.raises(KeyError) as excinfo: 288 | d['x'] 289 | assert str(excinfo.value) == "['x']" 290 | 291 | 292 | def test_dict_getitem_with_type(): 293 | d = sanest.dict() 294 | d['a'] = 'aaa' 295 | d['b'] = 2 296 | 297 | assert d['a':str] == 'aaa' 298 | assert d['b':int] == 2 299 | 300 | with pytest.raises(sanest.InvalidValueError) as excinfo: 301 | d['a':int] 302 | assert str(excinfo.value) == "expected int, got str at path ['a']: 'aaa'" 303 | 304 | with pytest.raises(KeyError) as excinfo: 305 | d['c':int] 306 | assert str(excinfo.value) == "['c']" 307 | 308 | 309 | def test_dict_get(): 310 | d = sanest.dict() 311 | d['a'] = 'aaa' 312 | assert d.get('a') == 'aaa' 313 | assert d.get('b') is None 314 | assert d.get('c', 'x') == 'x' 315 | 316 | 317 | def test_dict_get_with_type(): 318 | d = sanest.dict() 319 | d['a'] = 'aaa' 320 | 321 | assert d.get('a', type=str) == 'aaa' 322 | assert d.get('c', type=str) is None 323 | 324 | with pytest.raises(sanest.InvalidValueError) as excinfo: 325 | d.get('a', type=int) 326 | assert str(excinfo.value) == "expected int, got str at path ['a']: 'aaa'" 327 | 328 | 329 | def test_dict_get_with_default_and_type(): 330 | d = sanest.dict() 331 | value = 123 332 | d['a'] = value 333 | assert d.get('a', type=int) is value 334 | 335 | with pytest.raises(sanest.InvalidValueError) as excinfo: 336 | # here the default is identical to the actual value. type 337 | # checking should prevent a non-string return value. 338 | d.get('a', value, type=str) 339 | assert str(excinfo.value) == "expected str, got int at path ['a']: 123" 340 | 341 | 342 | def test_dict_get_default_arg_is_not_type_checked(): 343 | d = sanest.dict() 344 | assert d.get('b', type=int) is None 345 | assert d.get('b', 234, type=int) == 234 346 | assert d.get('b', 'not an int', type=int) == 'not an int' 347 | 348 | 349 | def test_dict_typed_getitem_with_invalid_slice(): 350 | d = sanest.dict() 351 | with pytest.raises(sanest.InvalidPathError) as excinfo: 352 | d['a':int:str] 353 | assert str(excinfo.value).startswith( 354 | "step value not allowed for slice syntax: ") 355 | 356 | 357 | def test_dict_getitem_with_path(): 358 | d = sanest.dict() 359 | d['a'] = sanest.dict() 360 | d['a']['aa'] = 123 361 | d['b'] = 456 362 | assert d['a', 'aa'] == 123 363 | path = ['a', 'aa'] 364 | assert d[path] == 123 365 | 366 | with pytest.raises(KeyError) as excinfo: 367 | d['a', 'x'] # a exists, but x does not 368 | assert str(excinfo.value) == "['a', 'x']" 369 | 370 | with pytest.raises(KeyError) as excinfo: 371 | d['x', 'y', 'z'] # x does not exist 372 | assert str(excinfo.value) == "['x']" 373 | 374 | with pytest.raises(KeyError) as excinfo: 375 | path = ['x'] 376 | d[path] 377 | assert str(excinfo.value) == "['x']" 378 | 379 | with pytest.raises(KeyError) as excinfo: 380 | path = ['x', 'y', 'z'] 381 | d[path] 382 | assert str(excinfo.value) == "['x']" 383 | 384 | with pytest.raises(sanest.InvalidPathError) as excinfo: 385 | d['a', 123, True] 386 | assert str(excinfo.value) == ( 387 | "path must contain only str or int: ['a', 123, True]") 388 | 389 | with pytest.raises(sanest.InvalidStructureError) as excinfo: 390 | d['b', 'c', 'd'] 391 | assert str(excinfo.value) == ( 392 | "expected dict, got int at subpath ['b'] of ['b', 'c', 'd']") 393 | 394 | 395 | def test_dict_getitem_with_path_and_type(): 396 | d = sanest.dict() 397 | d['a'] = sanest.dict() 398 | d['a']['b'] = 123 399 | assert d['a', 'b':int] == 123 400 | path = ['a', 'b'] 401 | assert d[path:int] == 123 402 | assert d['a':dict] 403 | path = ['a'] 404 | assert d[path:dict] 405 | 406 | with pytest.raises(KeyError) as excinfo: 407 | d['x', 'y'] 408 | assert str(excinfo.value) == "['x']" 409 | 410 | 411 | def test_dict_contains(): 412 | d = sanest.dict() 413 | d['a'] = 1 414 | assert 'a' in d 415 | assert 'b' not in d 416 | 417 | 418 | def test_dict_contains_with_type(): 419 | d = sanest.dict() 420 | d['a'] = 123 421 | d['b'] = [1, 2, 3] 422 | assert ['a', int] in d 423 | assert ['a', str] not in d 424 | assert ['b', [int]] in d 425 | assert not ['b', [str]] in d 426 | 427 | 428 | def test_dict_contains_with_path(): 429 | d = sanest.dict() 430 | d['a', 'b'] = 123 431 | assert ('a', 'b') in d # tuple 432 | assert ['a', 'b'] in d # list 433 | assert ['c', 'd'] not in d 434 | with pytest.raises(sanest.InvalidPathError): 435 | ['a', None] in d 436 | 437 | 438 | def test_dict_contains_with_path_and_type(): 439 | d = sanest.dict() 440 | d['a', 'b'] = 123 441 | assert ['a', 'b', int] in d 442 | assert ('a', 'b', int) in d 443 | assert ('a', 'b', str) not in d 444 | assert ('a', 'b', 'c') not in d 445 | assert ('a', 'b', 'c', int) not in d 446 | 447 | 448 | def test_dict_slice_syntax_limited_use(): 449 | """ 450 | Slice syntax is only valid for d[a,b:int], not in other places. 451 | """ 452 | d = sanest.dict() 453 | x = ['a', slice('b', int)] # this is what d['a', 'b':int)] results in 454 | with pytest.raises(sanest.InvalidPathError): 455 | d.get(x) 456 | with pytest.raises(sanest.InvalidPathError): 457 | x in d 458 | with pytest.raises(sanest.InvalidPathError): 459 | d.setdefault(x, 123) 460 | 461 | 462 | def test_dict_get_with_path(): 463 | d = sanest.dict() 464 | d['a'] = sanest.dict() 465 | d['a']['b'] = 123 466 | assert d.get(('a', 'b')) == 123 467 | assert d.get(['a', 'c']) is None 468 | assert d.get(['b', 'c'], 456) == 456 469 | 470 | 471 | def test_dict_iteration(): 472 | d = sanest.dict() 473 | assert list(iter(d)) == [] 474 | d['a'] = 1 475 | assert list(iter(d)) == ['a'] 476 | 477 | 478 | def test_dict_setitem(): 479 | d = sanest.dict() 480 | d['a'] = 'b' 481 | assert d['a'] == 'b' 482 | 483 | 484 | def test_dict_setitem_with_type(): 485 | d = sanest.dict() 486 | d['a':int] = 123 487 | assert d['a'] == 123 488 | 489 | 490 | def test_dict_setitem_with_path(): 491 | d = sanest.dict() 492 | d['a', 'b'] = 123 493 | assert d['a', 'b'] == 123 494 | path = ['a', 'b'] 495 | d[path] = 456 496 | assert d[path] == 456 497 | 498 | 499 | def test_dict_setitem_with_path_and_type(): 500 | d = sanest.dict() 501 | d['a', 'b':int] = 123 502 | assert d == {'a': {'b': 123}} 503 | assert d['a', 'b':int] == 123 504 | path = ['a', 'b', 'c'] 505 | with pytest.raises(sanest.InvalidValueError) as excinfo: 506 | d[path:int] = 'not an int' 507 | assert str(excinfo.value) == ( 508 | "expected int, got str: 'not an int'") 509 | path = [''] 510 | 511 | 512 | def test_dict_empty_path(): 513 | d = sanest.dict() 514 | 515 | with pytest.raises(sanest.InvalidPathError) as excinfo: 516 | path = [] 517 | d[path] 518 | assert str(excinfo.value) == "empty path: []" 519 | 520 | with pytest.raises(sanest.InvalidPathError) as excinfo: 521 | path = [] 522 | d[path:str] 523 | assert str(excinfo.value) == "empty path: []" 524 | 525 | with pytest.raises(sanest.InvalidPathError) as excinfo: 526 | d.get([], type=str) 527 | assert str(excinfo.value) == "empty path: []" 528 | 529 | 530 | def test_dict_setdefault(): 531 | d = sanest.dict() 532 | d['a'] = 1 533 | assert d.setdefault('a', 2) == 1 534 | assert d.setdefault(['b', 'c'], 'foo', type=str) == 'foo' 535 | assert d['a'] == 1 536 | assert d['b', 'c'] == 'foo' 537 | d.setdefault('d') 538 | assert d['d'] is None 539 | d.setdefault('e', None) 540 | assert d['e'] is None 541 | 542 | with pytest.raises(sanest.InvalidValueError) as excinfo: 543 | d.setdefault(['b', 'c'], 'not an int', type=int) 544 | assert str(excinfo.value) == ( 545 | "expected int, got str at path ['b', 'c']: 'foo'") 546 | 547 | with pytest.raises(sanest.InvalidValueError) as excinfo: 548 | d.setdefault('x', 'not an int', type=int) 549 | assert str(excinfo.value) == ( 550 | "expected int, got str: 'not an int'") 551 | assert 'x' not in d 552 | 553 | with pytest.raises(sanest.InvalidValueError) as excinfo: 554 | d.setdefault('a', 'not an int', type=int) 555 | assert str(excinfo.value) == ( 556 | "expected int, got str: 'not an int'") 557 | 558 | d2 = d.setdefault('xy', {'x': 'y'}) 559 | assert isinstance(d2, sanest.dict) 560 | assert d2 == {'x': 'y'} 561 | 562 | 563 | def test_dict_update(): 564 | d = sanest.dict() 565 | d['a'] = 1 566 | d.update({'a': 2}, b=3) 567 | assert d == {'a': 2, 'b': 3} 568 | 569 | 570 | def test_dict_value_atomic_type(): 571 | d1 = sanest.dict() 572 | d2 = {} 573 | for d in [d1, d2]: 574 | d['a'] = 1 575 | d['b'] = 1.23 576 | d['c'] = "foo" 577 | d['d'] = True 578 | assert d1 == d2 579 | 580 | 581 | def test_dict_value_container_type(): 582 | d = sanest.dict() 583 | nested = {'b': 123, 'c': {'c1': True, 'c2': False}} 584 | d['a'] = nested 585 | assert isinstance(d.get('a'), sanest.dict) 586 | assert isinstance(d['a'], sanest.dict) 587 | assert d['a'] == nested 588 | assert d['a']['b':int] == 123 589 | d2 = d['a', 'c':dict] 590 | assert isinstance(d2, sanest.dict) 591 | assert d2['c1':bool] is True 592 | 593 | 594 | def test_dict_delitem(): 595 | d = sanest.dict() 596 | with pytest.raises(KeyError) as excinfo: 597 | del d['a'] 598 | assert str(excinfo.value) == "['a']" 599 | d['a'] = 3 600 | assert 'a' in d 601 | del d['a'] 602 | assert 'a' not in d 603 | with pytest.raises(KeyError) as excinfo: 604 | del d['a'] 605 | assert str(excinfo.value) == "['a']" 606 | 607 | 608 | def test_dict_delitem_with_type(): 609 | d = sanest.dict({'a': 1, 'b': 2}) 610 | del d['a':int] 611 | assert 'a' not in d 612 | with pytest.raises(sanest.InvalidValueError) as excinfo: 613 | del d['b':str] 614 | assert str(excinfo.value) == "expected str, got int at path ['b']: 2" 615 | assert d['b'] == 2 616 | 617 | 618 | def test_dict_delitem_with_path(): 619 | d = sanest.dict({'a': {'b': 2}}) 620 | with pytest.raises(KeyError) as excinfo: 621 | del d['a', 'x'] 622 | assert str(excinfo.value) == "['a', 'x']" 623 | del d['a', 'b'] 624 | assert d['a'] == {} 625 | 626 | 627 | def test_dict_delitem_with_path_and_type(): 628 | original = {'a': {'b': 2}} 629 | d = sanest.dict(original) 630 | with pytest.raises(sanest.InvalidValueError) as excinfo: 631 | del d['a', 'b':str] 632 | assert str(excinfo.value) == "expected str, got int at path ['a', 'b']: 2" 633 | assert d == original 634 | del d['a', 'b':int] 635 | assert d['a'] == {} 636 | 637 | 638 | def test_dict_pop(): 639 | d = sanest.dict({'a': 1, 'b': 2}) 640 | 641 | # existing key 642 | assert d.pop('a') == 1 643 | assert 'a' not in d 644 | 645 | # missing key 646 | with pytest.raises(KeyError) as excinfo: 647 | d.pop('a') 648 | assert str(excinfo.value) == "['a']" 649 | 650 | # existing key, with default arg 651 | assert d.pop('b', 22) == 2 652 | assert not d 653 | 654 | # missing key, with default arg 655 | assert d.pop('b', 22) == 22 656 | 657 | 658 | def test_dict_pop_with_type(): 659 | d = sanest.dict({'a': 1, 'b': 2}) 660 | 661 | # existing key, correct type 662 | assert d.pop('a', type=int) == 1 663 | 664 | # existing key, wrong type 665 | with pytest.raises(sanest.InvalidValueError) as excinfo: 666 | d.pop('b', type=str) 667 | assert str(excinfo.value) == "expected str, got int at path ['b']: 2" 668 | assert d['b'] == 2 669 | 670 | # existing key, with default arg, wrong type 671 | with pytest.raises(sanest.InvalidValueError) as excinfo: 672 | assert d.pop('b', 22, type=str) 673 | assert str(excinfo.value) == "expected str, got int at path ['b']: 2" 674 | assert d['b'] == 2 675 | 676 | # existing key, with default arg, correct type 677 | assert d.pop('b', 22, type=int) == 2 678 | 679 | # missing key 680 | with pytest.raises(KeyError) as excinfo: 681 | d.pop('x', type=str) 682 | assert str(excinfo.value) == "['x']" 683 | assert excinfo.value.__cause__ is None 684 | assert excinfo.value.__suppress_context__ 685 | 686 | # missing key, with default arg: not type checked, just like .get() 687 | assert d.pop('x', 99, type=int) == 99 688 | assert d.pop('x', 'not an int', type=int) == 'not an int' 689 | 690 | assert not d 691 | 692 | 693 | def test_dict_pop_default_arg_not_wrapped(): 694 | default = {'a': 1} 695 | d = sanest.dict().pop('foo', default) 696 | assert d is default 697 | 698 | 699 | def test_dict_pop_with_path(): 700 | d = sanest.dict({ 701 | 'a': { 702 | 'b': 2, 703 | 'c': 3, 704 | }, 705 | 'd': { 706 | 'e': { 707 | 'f': { 708 | 'g': 'hello', 709 | }, 710 | }, 711 | }}) 712 | assert d.pop(['a', 'b']) == 2 713 | assert ['a', 'b'] not in d 714 | assert d.pop(['a', 'c'], 33) == 3 715 | assert ['a', 'c'] not in d 716 | assert d['a'] == {} 717 | assert d.pop(['a', 'x'], 99, type=str) == 99 718 | with pytest.raises(KeyError) as excinfo: 719 | d.pop(['a', 'x']) 720 | assert str(excinfo.value) == "['a', 'x']" 721 | assert excinfo.value.__cause__ is None 722 | assert excinfo.value.__suppress_context__ 723 | with pytest.raises(KeyError) as excinfo: 724 | d.pop(['d', 'e', 'x', 'y', 'z']) 725 | assert str(excinfo.value) == "['d', 'e', 'x']" 726 | assert d.pop(['d', 'e', 'x', 'y', 'z'], 'hi') == 'hi' 727 | d.pop(['d', 'e', 'f']) == {'g': 'hello'} 728 | 729 | 730 | def test_dict_methods_with_path_pointing_to_list_item(): 731 | """ 732 | dict specific methods that take a path to work on a nested structure 733 | should enforce that the path actually leads to a nested dict, and 734 | not to a list. 735 | """ 736 | d = sanest.dict({'a': [{}, {}]}) 737 | with pytest.raises(sanest.InvalidPathError) as excinfo: 738 | d.get(['a', 0]) 739 | assert str(excinfo.value) == "path must lead to dict key" 740 | with pytest.raises(sanest.InvalidPathError) as excinfo: 741 | d.pop(['a', 0]) 742 | assert str(excinfo.value) == "path must lead to dict key" 743 | with pytest.raises(sanest.InvalidPathError) as excinfo: 744 | d.setdefault(['a', 0], 'x') 745 | assert str(excinfo.value) == "path must lead to dict key" 746 | 747 | 748 | def test_dict_pop_with_path_and_type(): 749 | d = sanest.dict({'a': {'b': 2}}) 750 | with pytest.raises(sanest.InvalidValueError) as excinfo: 751 | assert d.pop(['a', 'b'], type=str) 752 | assert str(excinfo.value) == "expected str, got int at path ['a', 'b']: 2" 753 | assert d.pop(['a', 'b'], 22, type=int) == 2 754 | assert d == {'a': {}} 755 | assert d.pop(['a', 'x'], 99, type=str) == 99 756 | 757 | 758 | def test_dict_popitem(): 759 | d = sanest.dict({'a': 1}) 760 | assert d.popitem() == ('a', 1) 761 | assert d == {} 762 | d['b'] = 2 763 | assert d.popitem() == ('b', 2) 764 | with pytest.raises(KeyError) as excinfo: 765 | assert d.popitem() 766 | assert str(excinfo.value) == "dictionary is empty" 767 | assert excinfo.value.__cause__ is None 768 | assert excinfo.value.__suppress_context__ 769 | 770 | 771 | def test_dict_popitem_with_type(): 772 | d = sanest.dict({'a': 1}) 773 | with pytest.raises(sanest.InvalidValueError) as excinfo: 774 | assert d.popitem(type=str) 775 | assert str(excinfo.value) == "expected str, got int at path ['a']: 1" 776 | assert d['a'] == 1 777 | assert d.popitem(type=int) == ('a', 1) 778 | with pytest.raises(KeyError) as excinfo: 779 | assert d.popitem(type=str) 780 | assert str(excinfo.value) == "dictionary is empty" 781 | assert excinfo.value.__cause__ is None 782 | assert excinfo.value.__suppress_context__ 783 | 784 | 785 | def test_dict_convert_to_regular_dict(): 786 | original = {'a': {'b': 123}, "c": True} 787 | d = sanest.dict(original) 788 | as_dict = d.unwrap() 789 | assert type(as_dict) is dict 790 | assert as_dict == original 791 | 792 | 793 | def test_dict_repr(): 794 | d = sanest.dict({'a': {'b': {'c': 123}}}) 795 | assert repr(d) == "sanest.dict({'a': {'b': {'c': 123}}})" 796 | assert eval(repr(d)) == d 797 | 798 | 799 | def test_dict_shallow_copy(): 800 | d1 = sanest.dict({'a': 1, 'b': {'b1': 21, 'b2': 22}}) 801 | d2 = sanest.dict({'a': 1, 'b': {'b1': 21, 'b2': 22}}) 802 | copies = [ 803 | (d1, d1.copy()), 804 | (d2, copy.copy(d2)), 805 | ] 806 | for original, other in copies: 807 | assert other == original 808 | assert other is not original 809 | # change shallow field: original is unchanged 810 | other['a'] = 111 811 | assert original['a'] == 1 812 | # change nested field: copy reflects the change 813 | original['b', 'b2'] = 2222 814 | assert other['b', 'b2'] == 2222 815 | 816 | 817 | def test_dict_deep_copy(): 818 | d1 = sanest.dict({'a': 1, 'b': {'b1': 21, 'b2': 22}}) 819 | d2 = sanest.dict({'a': 1, 'b': {'b1': 21, 'b2': 22}}) 820 | copies = [ 821 | (d1, d1.copy(deep=True)), 822 | (d2, copy.deepcopy(d2)), 823 | ] 824 | for original, other in copies: 825 | assert other == original 826 | assert other is not original 827 | # change shallow field: original is unchanged 828 | other['a'] = 111 829 | assert original['a'] == 1 830 | # change nested field: copy is unchanged change 831 | original['b', 'b2'] = 2222 832 | assert other['b', 'b2'] == 22 833 | 834 | 835 | def test_dict_pickle(): 836 | d1 = sanest.dict({'a': 1, 'b': {'b1': 21, 'b2': 22}}) 837 | s = pickle.dumps(d1) 838 | d2 = pickle.loads(s) 839 | assert d1 == d2 840 | assert d2['b', 'b1'] == 21 841 | 842 | 843 | def test_dict_fromkeys(): 844 | keys = ['a', 'b'] 845 | d = sanest.dict.fromkeys(keys) 846 | assert d == {'a': None, 'b': None} 847 | d = sanest.dict.fromkeys(keys, 123) 848 | assert d == {'a': 123, 'b': 123} 849 | 850 | 851 | def test_dict_wrap(): 852 | original = {'a': {'b': 12}} 853 | d = sanest.dict.wrap(original) 854 | assert d['a', 'b'] == 12 855 | assert d.unwrap() is original 856 | assert sanest.dict.wrap(original) == sanest.dict.wrap(original) 857 | 858 | 859 | def test_dict_wrap_invalid(): 860 | with pytest.raises(TypeError) as excinfo: 861 | sanest.dict.wrap(123) 862 | assert str(excinfo.value) == "not a dict" 863 | 864 | 865 | def test_dict_wrap_twice(): 866 | original = {'a': {'b': 12}} 867 | d = sanest.dict.wrap(original) 868 | d2 = sanest.dict.wrap(d) 869 | assert d is d2 870 | 871 | 872 | def test_dict_constructor_validation(): 873 | with pytest.raises(sanest.InvalidPathError) as excinfo: 874 | sanest.dict({True: False}) 875 | assert str(excinfo.value) == "invalid dict key: True" 876 | with pytest.raises(sanest.InvalidPathError) as excinfo: 877 | sanest.dict({123: 123}) 878 | assert str(excinfo.value) == "invalid dict key: 123" 879 | with pytest.raises(sanest.InvalidValueError) as excinfo: 880 | sanest.dict({'a': MyClass()}) 881 | assert str(excinfo.value) == "invalid value of type MyClass: " 882 | 883 | 884 | def test_dict_validate_assigned_values(): 885 | d = sanest.dict() 886 | with pytest.raises(sanest.InvalidValueError) as excinfo: 887 | d['a'] = MyClass() 888 | assert str(excinfo.value) == "invalid value of type MyClass: " 889 | 890 | d = sanest.dict() 891 | with pytest.raises(sanest.InvalidValueError) as excinfo: 892 | d['a', 'b'] = MyClass() 893 | assert str(excinfo.value) == "invalid value of type MyClass: " 894 | 895 | 896 | def test_dict_wrap_validation(): 897 | with pytest.raises(sanest.InvalidPathError) as excinfo: 898 | sanest.dict.wrap({123: True}) 899 | assert str(excinfo.value) == ( 900 | "invalid dict key: 123") 901 | 902 | with pytest.raises(sanest.InvalidValueError) as excinfo: 903 | sanest.dict.wrap({"foo": MyClass()}) 904 | assert str(excinfo.value) == "invalid value of type MyClass: " 905 | 906 | 907 | def test_dict_wrap_skip_validation(): 908 | invalid_dict = {True: False} 909 | wrapped = sanest.dict.wrap(invalid_dict, check=False) 910 | unwrapped = wrapped.unwrap() 911 | assert unwrapped is invalid_dict 912 | 913 | 914 | def test_dict_validate(): 915 | d = sanest.dict({'a': 1, 'b': 2}) 916 | d.check_types(type=int) 917 | d = sanest.dict({'a': [1, 2]}) 918 | d.check_types(type=[int]) 919 | with pytest.raises(sanest.InvalidValueError) as excinfo: 920 | d.check_types(type=str) 921 | assert str(excinfo.value) == "expected str, got list at path ['a']: [1, 2]" 922 | 923 | 924 | def test_dict_view_repr(): 925 | d = sanest.dict({'a': 123}) 926 | assert repr(d.keys()) == "sanest.dict({'a': 123}).keys()" 927 | assert repr(d.items()) == "sanest.dict({'a': 123}).items()" 928 | assert repr(d.values()) == "sanest.dict({'a': 123}).values()" 929 | 930 | 931 | def test_dict_keys_view(): 932 | d = sanest.dict({'a': {'b': 123}}) 933 | keys_view = d.keys() 934 | assert len(keys_view) == len(d) == 1 935 | assert 'a' in keys_view 936 | assert ['a'] in keys_view 937 | assert ['a', dict] in keys_view 938 | assert ['a', 'b'] in keys_view 939 | assert ['a', 'b', int] in keys_view 940 | assert ['a', 'x'] not in keys_view 941 | assert keys_view & {'a', 'q'} == {'a'} 942 | assert list(iter(keys_view)) == ['a'] 943 | 944 | 945 | def test_dict_values_view_contains(): 946 | d = sanest.dict({'a': 1, 'b': 2, 'c': [3, 4, 5]}) 947 | values_view = d.values() 948 | assert len(values_view) == len(d) == 3 949 | assert 1 in values_view 950 | assert [3, 4, 5] in values_view 951 | assert sanest.list([3, 4, 5]) in values_view 952 | with pytest.raises(sanest.InvalidValueError) as excinfo: 953 | MyClass() in values_view 954 | assert str(excinfo.value) == "invalid value of type MyClass: " 955 | 956 | 957 | def test_dict_values_view_iteration(): 958 | d = sanest.dict({'a': 'b'}) 959 | assert list(d.values()) == ['b'] 960 | d = sanest.dict({'a': [1, 2]}) 961 | values_view = d.values(type=[int]) 962 | values = list(values_view) 963 | assert len(values) == 1 964 | value = values[0] 965 | assert value == [1, 2] 966 | assert isinstance(value, sanest.list) 967 | with pytest.raises(sanest.InvalidValueError) as excinfo: 968 | d.values(type=bool) 969 | assert str(excinfo.value) == ( 970 | "expected bool, got list at path ['a']: [1, 2]") 971 | 972 | 973 | def test_dict_items_view_contains(): 974 | d = sanest.dict({'a': {'b': 2}}) 975 | items_view = d.items() 976 | key = 'a' 977 | value = {'b': 2} 978 | assert (key, value) in items_view 979 | key = 'a' 980 | value = sanest.dict({'b': 2}) 981 | assert (key, value) in items_view 982 | key = ['a', 'b'] 983 | value = 2 984 | assert (key, value) in items_view 985 | key = ['a', 'x'] 986 | value = 123 987 | assert not (key, value) in items_view 988 | 989 | 990 | def test_dict_items_view_iteration(): 991 | d = sanest.dict({'a': 1}) 992 | assert list(d.items()) == [('a', 1)] 993 | d = sanest.dict({'a': {'b': 2}}) 994 | items_view = d.items(type={str: int}) 995 | assert list(items_view) == [('a', {'b': 2})] 996 | with pytest.raises(sanest.InvalidValueError) as excinfo: 997 | d.values(type=bool) 998 | assert str(excinfo.value) == ( 999 | "expected bool, got dict at path ['a']: {'b': 2}") 1000 | 1001 | 1002 | # 1003 | # lists 1004 | # 1005 | 1006 | def test_list_basics(): 1007 | d = sanest.list() 1008 | d.append('a') 1009 | assert d[0] == 'a' 1010 | d.append('b') 1011 | d.append('c') 1012 | assert d[1] == 'b' 1013 | assert d[2] == 'c' 1014 | 1015 | 1016 | def test_list_constructor(): 1017 | regular_list = ['a', 'b'] 1018 | ll = sanest.list(regular_list) 1019 | assert len(ll) == 2 1020 | with pytest.raises(TypeError) as excinfo: 1021 | sanest.list([1, 2, 3], [4, 5], [6, 7]) 1022 | assert str(excinfo.value) == "expected at most 1 argument, got 3" 1023 | 1024 | 1025 | def test_list_comparison_equality(): 1026 | l1 = sanest.list([1, 2]) 1027 | l2 = sanest.list([1, 2]) 1028 | normal_list = [1, 2] 1029 | assert l1 == normal_list 1030 | assert l1 == l1 1031 | assert l1 == l2 1032 | assert l1 != [2, 1] 1033 | assert l1 != [3] 1034 | assert l1 != object() 1035 | 1036 | 1037 | def test_list_comparison_ordering(): 1038 | l1 = sanest.list([1, 2]) 1039 | l2 = sanest.list([2, 3, 4]) 1040 | normal_list = [1, 2] 1041 | assert l1 < l2 1042 | assert l1 <= l2 1043 | assert l2 > l1 1044 | assert l2 >= l1 1045 | assert not l1 < normal_list 1046 | assert not l1 > normal_list 1047 | assert l1 <= normal_list 1048 | assert l1 >= normal_list 1049 | assert l2 > normal_list 1050 | assert normal_list < l2 1051 | if sys.version_info >= (3, 6): 1052 | pattern = r" not supported between instances " 1053 | else: 1054 | pattern = r"^unorderable types: " 1055 | with pytest.raises(TypeError) as excinfo: 1056 | l1 < object() 1057 | assert excinfo.match(pattern) 1058 | with pytest.raises(TypeError) as excinfo: 1059 | l1 <= object() 1060 | assert excinfo.match(pattern) 1061 | with pytest.raises(TypeError) as excinfo: 1062 | l1 > object() 1063 | assert excinfo.match(pattern) 1064 | with pytest.raises(TypeError) as excinfo: 1065 | l1 >= object() 1066 | assert excinfo.match(pattern) 1067 | 1068 | 1069 | def test_list_repr(): 1070 | ll = sanest.list([1, 2, [3, 4]]) 1071 | assert repr(ll) == "sanest.list([1, 2, [3, 4]])" 1072 | assert eval(repr(ll)) == ll 1073 | 1074 | 1075 | def test_list_pickle(): 1076 | l1 = sanest.list([1, 2, 3]) 1077 | s = pickle.dumps(l1) 1078 | l2 = pickle.loads(s) 1079 | assert l1 == l2 1080 | 1081 | 1082 | def test_list_wrap(): 1083 | original = ['a', 'b', ['c1', 'c2'], None] 1084 | ll = sanest.list.wrap(original) 1085 | assert ll[2, 0] == 'c1' 1086 | assert ll.unwrap() is original 1087 | assert sanest.list.wrap(original) == sanest.list.wrap(original) 1088 | 1089 | 1090 | def test_list_wrap_invalid(): 1091 | with pytest.raises(TypeError) as excinfo: 1092 | sanest.list.wrap(123) 1093 | assert str(excinfo.value) == "not a list" 1094 | 1095 | 1096 | def test_list_wrap_twice(): 1097 | original = [1, 2, 3] 1098 | l1 = sanest.list.wrap(original) 1099 | l2 = sanest.list.wrap(l1) 1100 | assert l1 is l2 1101 | 1102 | 1103 | def test_list_wrap_validation(): 1104 | original = [MyClass(), MyClass()] 1105 | with pytest.raises(sanest.InvalidValueError) as excinfo: 1106 | sanest.list.wrap(original) 1107 | assert str(excinfo.value) == "invalid value of type MyClass: " 1108 | ll = sanest.list.wrap(original, check=False) 1109 | assert len(ll) == 2 1110 | 1111 | 1112 | def test_list_validate(): 1113 | ll = sanest.list([1, 2, 3]) 1114 | ll.check_types(type=int) 1115 | ll = sanest.list([{'a': 1}, {'a': 2}, {'a': 3}]) 1116 | ll.check_types(type={str: int}) 1117 | with pytest.raises(sanest.InvalidValueError) as excinfo: 1118 | ll.check_types(type=str) 1119 | assert str(excinfo.value) == "expected str, got dict at path [0]: {'a': 1}" 1120 | 1121 | 1122 | def test_list_getitem(): 1123 | ll = sanest.list(['a', 'b']) 1124 | assert ll[0] == 'a' 1125 | assert ll[1] == 'b' 1126 | with pytest.raises(IndexError) as excinfo: 1127 | ll[2] 1128 | assert str(excinfo.value) == "[2]" 1129 | 1130 | 1131 | def test_list_getitem_with_type(): 1132 | ll = sanest.list(['a', {}]) 1133 | assert ll[0:str] == 'a' 1134 | with pytest.raises(sanest.InvalidValueError) as excinfo: 1135 | assert ll[0:bool] == 'a' 1136 | assert str(excinfo.value) == "expected bool, got str at path [0]: 'a'" 1137 | assert isinstance(ll[1], sanest.dict) 1138 | 1139 | 1140 | def test_list_getitem_with_path(): 1141 | ll = sanest.list(['a', ['b1', 'b2']]) 1142 | assert ll[1, 0] == 'b1' 1143 | path = (1, 0) 1144 | assert ll[path] == 'b1' 1145 | path = [1, 0] 1146 | assert ll[path] == 'b1' 1147 | with pytest.raises(IndexError) as excinfo: 1148 | ll[1, 2, 3, 4] 1149 | assert str(excinfo.value) == "[1, 2]" 1150 | with pytest.raises(sanest.InvalidStructureError) as excinfo: 1151 | ll[0, 9] 1152 | assert str(excinfo.value) == ( 1153 | "expected list, got str at subpath [0] of [0, 9]") 1154 | 1155 | 1156 | def test_list_getitem_with_path_and_type(): 1157 | ll = sanest.list(['a', ['b1', 'b2']]) 1158 | assert ll[1, 0:str] == "b1" 1159 | path = [1, 0] 1160 | assert ll[path:str] == "b1" 1161 | with pytest.raises(sanest.InvalidValueError) as excinfo: 1162 | ll[1, 1:bool] 1163 | assert str(excinfo.value) == "expected bool, got str at path [1, 1]: 'b2'" 1164 | 1165 | 1166 | def test_list_setitem(): 1167 | ll = sanest.list(['a', 'b']) 1168 | ll[0] = 'b' 1169 | ll[1] = sanest.list() 1170 | assert ll == ['b', []] 1171 | with pytest.raises(IndexError) as excinfo: 1172 | ll[5] = 'a' 1173 | assert str(excinfo.value) == "[5]" 1174 | assert ll == ['b', []] 1175 | 1176 | 1177 | def test_list_setitem_with_type(): 1178 | ll = sanest.list(['a']) 1179 | assert ll[0:str] == 'a' 1180 | with pytest.raises(sanest.InvalidValueError) as excinfo: 1181 | ll[0:bool] = 'a' 1182 | assert str(excinfo.value) == "expected bool, got str: 'a'" 1183 | 1184 | 1185 | def test_list_setitem_with_path(): 1186 | ll = sanest.list(['a', ['b', 'c', 'd']]) 1187 | ll[1, 0] = 'e' 1188 | path = (1, 1) 1189 | ll[path] = 'f' 1190 | path = [1, 2] 1191 | ll[path] = 'g' 1192 | assert ll == ['a', ['e', 'f', 'g']] 1193 | with pytest.raises(IndexError) as excinfo: 1194 | ll[5, 4, 3] = 'h' 1195 | assert str(excinfo.value) == "[5]" 1196 | assert ll == ['a', ['e', 'f', 'g']] 1197 | 1198 | 1199 | def test_list_setitem_with_path_and_type(): 1200 | ll = sanest.list(['a', ['b', 'c']]) 1201 | ll[1, 0:str] = "d" 1202 | path = [1, 1] 1203 | ll[path:str] = "e" 1204 | assert ll == ['a', ['d', 'e']] 1205 | with pytest.raises(sanest.InvalidValueError) as excinfo: 1206 | ll[1, 1:bool] = 'x' 1207 | assert str(excinfo.value) == "expected bool, got str: 'x'" 1208 | assert ll == ['a', ['d', 'e']] 1209 | 1210 | 1211 | def test_list_contains(): 1212 | ll = sanest.list([ 1213 | 1, 1214 | 'a', 1215 | [2, 3], 1216 | {'c': 'd'}, 1217 | None, 1218 | ]) 1219 | assert 1 in ll 1220 | assert 'a' in ll 1221 | assert [2, 3] in ll 1222 | assert sanest.list([2, 3]) in ll 1223 | assert {'c': 'd'} in ll 1224 | assert sanest.dict({'c': 'd'}) in ll 1225 | assert None in ll 1226 | with pytest.raises(sanest.InvalidValueError) as excinfo: 1227 | MyClass() in ll 1228 | assert str(excinfo.value) == "invalid value of type MyClass: " 1229 | 1230 | 1231 | def test_list_contains_with_type(): 1232 | ll = sanest.list([1, 'a', {'c': 'd'}]) 1233 | assert ll.contains(1, type=int) 1234 | assert ll.contains('a', type=str) 1235 | assert ll.contains({'c': 'd'}, type=dict) 1236 | assert not ll.contains(1, type=str) 1237 | assert not ll.contains(2, type=str) 1238 | assert not ll.contains({'x': 'y'}, type=dict) 1239 | assert not ll.contains({'x': 'y'}, type=int) 1240 | 1241 | 1242 | def test_list_iteration(): 1243 | ll = sanest.list([ 1244 | {'a': 1}, 1245 | [2, 3], 1246 | 'x', 1247 | ]) 1248 | first, second, third = ll 1249 | assert isinstance(first, sanest.dict) 1250 | assert first == {'a': 1} 1251 | assert isinstance(second, sanest.list) 1252 | assert second == [2, 3] 1253 | assert third == 'x' 1254 | 1255 | 1256 | def test_list_iteration_with_type(): 1257 | ll = sanest.list(['a', 'a']) 1258 | assert list(ll.iter()) == ['a', 'a'] 1259 | assert list(ll.iter(type=str)) == ['a', 'a'] 1260 | ll = sanest.list([1, 2, 'oops']) 1261 | with pytest.raises(sanest.InvalidValueError) as excinfo: 1262 | ll.iter(type=int) # eager validation, not during yielding 1263 | assert str(excinfo.value) == "expected int, got str at path [2]: 'oops'" 1264 | ll = sanest.list([{}]) 1265 | assert isinstance(next(ll.iter(type=dict)), sanest.dict) 1266 | 1267 | 1268 | def test_list_index(): 1269 | ll = sanest.list([ 1270 | 'a', # 0 1271 | {'b': 'c'}, # 1 1272 | None, # 2 1273 | None, # 3 1274 | 'a', # 4 1275 | None, # 5 1276 | None, # 6 1277 | ]) 1278 | assert ll.index('a') == 0 1279 | assert ll.index('a', type=str) == 0 1280 | assert ll.index('a', 2) == 4 1281 | assert ll.index('a', 2, type=str) == 4 1282 | assert ll.index('a', 2, 6) == 4 1283 | assert ll.index(None, 2) == 2 1284 | assert ll.index(None, 4) == 5 1285 | assert ll.index({'b': 'c'}) == 1 1286 | assert ll.index(sanest.dict({'b': 'c'})) == 1 1287 | with pytest.raises(ValueError) as excinfo: 1288 | ll.index('a', 5) 1289 | assert str(excinfo.value) == "'a' is not in list" 1290 | with pytest.raises(ValueError) as excinfo: 1291 | ll.index('a', 2, 3) 1292 | assert str(excinfo.value) == "'a' is not in list" 1293 | with pytest.raises(sanest.InvalidValueError) as excinfo: 1294 | ll.index(2, type=str) 1295 | assert str(excinfo.value) == "expected str, got int: 2" 1296 | 1297 | 1298 | def test_list_count(): 1299 | ll = sanest.list([1, 2, 3, 1, 1, 2, 3, {'a': 'b'}]) 1300 | assert ll.count(1) == 3 1301 | assert ll.count(1, type=int) == 3 1302 | with pytest.raises(sanest.InvalidValueError) as excinfo: 1303 | ll.count(1, type=str) 1304 | assert str(excinfo.value) == "expected str, got int: 1" 1305 | assert ll.count({'a': 'b'}) == 1 1306 | assert ll.count(sanest.dict({'a': 'b'})) == 1 1307 | 1308 | 1309 | def test_list_insert(): 1310 | ll = sanest.list(range(5)) 1311 | assert ll == [0, 1, 2, 3, 4] 1312 | ll.insert(0, 'a') 1313 | with pytest.raises(sanest.InvalidValueError) as excinfo: 1314 | ll.insert(0, 'a', type=int) 1315 | assert str(excinfo.value) == "expected int, got str: 'a'" 1316 | assert ll == ['a', 0, 1, 2, 3, 4] 1317 | ll.insert(2, 'b') 1318 | assert ll == ['a', 0, 'b', 1, 2, 3, 4] 1319 | ll.insert(20, 'c') 1320 | assert ll == ['a', 0, 'b', 1, 2, 3, 4, 'c'] 1321 | ll.insert(-3, 'd') 1322 | assert ll == ['a', 0, 'b', 1, 2, 'd', 3, 4, 'c'] 1323 | 1324 | 1325 | def test_list_append(): 1326 | ll = sanest.list() 1327 | ll.append(1) 1328 | with pytest.raises(sanest.InvalidValueError) as excinfo: 1329 | ll.append('a', type=int) 1330 | assert str(excinfo.value) == "expected int, got str: 'a'" 1331 | assert ll == [1] 1332 | ll.append(2) 1333 | ll.append([3, 4]) 1334 | ll.append(sanest.list([5, 6])) 1335 | assert len(ll) == 4 1336 | assert ll == [1, 2, [3, 4], [5, 6]] 1337 | 1338 | 1339 | def test_list_extend(): 1340 | ll = sanest.list([1, 2]) 1341 | ll.extend(sanest.list([3, 4])) 1342 | ll.extend([5, 6], type=int) 1343 | assert ll == [1, 2, 3, 4, 5, 6] 1344 | with pytest.raises(sanest.InvalidValueError) as excinfo: 1345 | ll.extend(['a', 'b'], type=int) 1346 | assert str(excinfo.value) == "expected int, got str: 'a'" 1347 | assert ll == [1, 2, 3, 4, 5, 6] 1348 | with pytest.raises(sanest.InvalidValueError) as excinfo: 1349 | ll.extend([MyClass()]) 1350 | assert str(excinfo.value) == "invalid value of type MyClass: " 1351 | ll.extend(n for n in [7, 8]) 1352 | assert ll == [1, 2, 3, 4, 5, 6, 7, 8] 1353 | 1354 | 1355 | @pytest.mark.parametrize( 1356 | ('value', 'type_name'), 1357 | [ 1358 | ('foo', 'str'), 1359 | (b'foo', 'bytes'), 1360 | (bytearray(b'foo'), 'bytearray'), 1361 | ]) 1362 | def test_list_guard_against_implicit_string_splitting(value, type_name): 1363 | ll = sanest.list() 1364 | expected_message = ( 1365 | "expected iterable that is not string-like, got {}" 1366 | .format(type_name)) 1367 | with pytest.raises(TypeError) as excinfo: 1368 | ll.extend(value) 1369 | assert str(excinfo.value) == expected_message 1370 | with pytest.raises(TypeError) as excinfo: 1371 | ll += value 1372 | assert str(excinfo.value) == expected_message 1373 | with pytest.raises(TypeError) as excinfo: 1374 | ll[:] = value 1375 | assert str(excinfo.value) == expected_message 1376 | 1377 | 1378 | def test_list_extend_nested_unwrapping(): 1379 | ll = sanest.list([ 1380 | [ 1381 | [1, 2], 1382 | [3, 4] 1383 | ], 1384 | sanest.list([ 1385 | sanest.list([5, 6]), 1386 | sanest.list([7, 8]), 1387 | ]), 1388 | ]) 1389 | assert ll == [ 1390 | [[1, 2], [3, 4]], 1391 | [[5, 6], [7, 8]], 1392 | ] 1393 | 1394 | 1395 | def test_list_concat(): 1396 | x = sanest.list(['a', 'b']) 1397 | y = sanest.list(['c']) 1398 | z = ['d'] 1399 | xy = x + y 1400 | assert xy == ['a', 'b', 'c'] 1401 | assert isinstance(xy, sanest.list) 1402 | xz = x + z 1403 | assert xz == ['a', 'b', 'd'] 1404 | assert isinstance(xz, sanest.list) 1405 | assert x == ['a', 'b'] 1406 | zx = z + x 1407 | assert zx == ['d', 'a', 'b'] 1408 | assert isinstance(zx, builtins.list) 1409 | x += z 1410 | assert isinstance(x, sanest.list) 1411 | assert x == xz 1412 | xy += z 1413 | assert isinstance(xy, sanest.list) 1414 | assert xy == ['a', 'b', 'c', 'd'] 1415 | 1416 | 1417 | def test_list_concat_only_accepts_lists(): 1418 | ll = sanest.list() 1419 | with pytest.raises(TypeError) as excinfo: 1420 | ll + 'abc' 1421 | assert str(excinfo.value) == "expected list, got str" 1422 | 1423 | 1424 | def test_list_repeat(): 1425 | ll = sanest.list([1, 2]) 1426 | assert ll * 2 == [1, 2, 1, 2] 1427 | assert 2 * ll == [1, 2, 1, 2] 1428 | assert ll == [1, 2] 1429 | assert isinstance(ll, sanest.list) 1430 | ll *= 2 1431 | assert ll == [1, 2, 1, 2] 1432 | assert isinstance(ll, sanest.list) 1433 | 1434 | 1435 | def test_list_reversing(): 1436 | ll = sanest.list(['a', {}]) 1437 | rev = reversed(ll) 1438 | first, second = rev 1439 | assert first == {} 1440 | assert isinstance(first, sanest.dict) 1441 | assert second == 'a' 1442 | assert ll == ['a', {}] 1443 | ll.reverse() 1444 | assert ll == [{}, 'a'] 1445 | 1446 | 1447 | def test_list_clear(): 1448 | ll = sanest.list([1, 2, 3]) 1449 | ll.clear() 1450 | assert ll == [] 1451 | 1452 | 1453 | def test_list_delitem(): 1454 | ll = sanest.list(['a', 'b', 'c']) 1455 | del ll[1] 1456 | assert ll == ['a', 'c'] 1457 | del ll[-1] 1458 | assert ll == ['a'] 1459 | del ll[0] 1460 | assert ll == [] 1461 | 1462 | 1463 | def test_list_delitem_with_type(): 1464 | ll = sanest.list(['a', 'b', 'c']) 1465 | del ll[0:str] 1466 | assert ll == ['b', 'c'] 1467 | with pytest.raises(sanest.InvalidValueError) as excinfo: 1468 | del ll[-1:int] 1469 | assert str(excinfo.value) == "expected int, got str at path [-1]: 'c'" 1470 | assert ll == ['b', 'c'] 1471 | 1472 | 1473 | def test_list_delitem_with_path(): 1474 | ll = sanest.list([['a', 'aa'], ['b', 'bb']]) 1475 | del ll[0, 1] 1476 | assert ll == [['a'], ['b', 'bb']] 1477 | path = [1, 0] 1478 | del ll[path] 1479 | assert ll == [['a'], ['bb']] 1480 | 1481 | 1482 | def test_list_delitem_with_path_and_type(): 1483 | ll = sanest.list([['a', 'aa'], ['b', 'bb']]) 1484 | del ll[0, 0:str] 1485 | with pytest.raises(sanest.InvalidValueError) as excinfo: 1486 | del ll[0, 0:int] 1487 | assert str(excinfo.value) == "expected int, got str at path [0, 0]: 'aa'" 1488 | assert ll == [['aa'], ['b', 'bb']] 1489 | path = [1, 1] 1490 | del ll[path:str] 1491 | assert ll == [['aa'], ['b']] 1492 | 1493 | 1494 | def test_list_pop(): 1495 | ll = sanest.list(['a', [], 'b', 'c']) 1496 | assert ll.pop() == 'c' 1497 | assert ll.pop(-1) == 'b' 1498 | with pytest.raises(sanest.InvalidValueError) as excinfo: 1499 | ll.pop(0, type=int) 1500 | assert str(excinfo.value) == "expected int, got str at path [0]: 'a'" 1501 | assert ll.pop(0, type=str) == 'a' 1502 | with pytest.raises(IndexError) as excinfo: 1503 | ll.pop(123) 1504 | assert str(excinfo.value) == "[123]" 1505 | assert excinfo.value.__cause__ is None 1506 | assert excinfo.value.__suppress_context__ 1507 | value = ll.pop(type=list) 1508 | assert isinstance(value, sanest.list) 1509 | assert len(ll) == 0 1510 | with pytest.raises(IndexError) as excinfo: 1511 | ll.pop(0, type=int) 1512 | assert str(excinfo.value) == "pop from empty list" 1513 | 1514 | 1515 | def test_list_pop_with_path(): 1516 | ll = sanest.list([ 1517 | {'items': ['a', 'b', 'c']}, 1518 | ]) 1519 | assert ll.pop([0, 'items', 0]) == 'a' 1520 | assert ll[0, 'items'] == ['b', 'c'] 1521 | with pytest.raises(sanest.InvalidValueError) as excinfo: 1522 | ll.pop([0, 'items', 1], type=int) 1523 | assert str(excinfo.value) == ( 1524 | "expected int, got str at path [0, 'items', 1]: 'c'") 1525 | assert ll.pop([0, 'items', 1], type=str) == 'c' 1526 | assert ll.pop([0, 'items', 0]) == 'b' 1527 | assert ll[0, 'items'] == [] 1528 | with pytest.raises(IndexError) as excinfo: 1529 | ll.pop([0, 'items', 0]) 1530 | assert str(excinfo.value) == "pop from empty list" 1531 | with pytest.raises(sanest.InvalidPathError) as excinfo: 1532 | ll.pop([0, 'x']) 1533 | assert str(excinfo.value) == "path must lead to list index" 1534 | 1535 | 1536 | def test_list_remove(): 1537 | ll = sanest.list(['a', 'a', 'b', 'a', {}]) 1538 | ll.remove('a') 1539 | assert ll == ['a', 'b', 'a', {}] 1540 | with pytest.raises(ValueError) as excinfo: 1541 | ll.remove('c') 1542 | assert str(excinfo.value) == "'c' is not in list" 1543 | ll.remove('a', type=str) 1544 | ll.remove({}, type=dict) 1545 | assert ll == ['b', 'a'] 1546 | with pytest.raises(sanest.InvalidValueError) as excinfo: 1547 | ll.remove('a', type=int) 1548 | assert str(excinfo.value) == "expected int, got str: 'a'" 1549 | assert ll == ['b', 'a'] 1550 | 1551 | 1552 | def test_list_sort(): 1553 | ll = sanest.list(['a', 'c', 'b']) 1554 | ll.sort() 1555 | assert ll == ['a', 'b', 'c'] 1556 | ll.sort(reverse=True) 1557 | assert ll == ['c', 'b', 'a'] 1558 | 1559 | 1560 | def test_list_get_slice(): 1561 | ll = sanest.list(['a', 'b', 'c']) 1562 | assert ll[0:] == ['a', 'b', 'c'] 1563 | assert ll[2:] == ['c'] 1564 | assert ll[:0] == [] 1565 | assert ll[:-2] == ['a'] 1566 | assert ll[0:20] == ['a', 'b', 'c'] 1567 | assert ll[:] == ['a', 'b', 'c'] 1568 | assert ll[::2] == ['a', 'c'] 1569 | assert isinstance(ll[1:2], sanest.list) 1570 | 1571 | 1572 | def test_list_set_slice(): 1573 | ll = sanest.list(['a', 'b', 'c', 'd', 'e']) 1574 | ll[::2] = ['x', 'y', 'z'] 1575 | assert ll == ['x', 'b', 'y', 'd', 'z'] 1576 | ll[:] = ['p', 'q', 'r'] 1577 | assert ll == ['p', 'q', 'r'] 1578 | with pytest.raises(sanest.InvalidValueError) as excinfo: 1579 | ll[:3] = [MyClass()] 1580 | assert str(excinfo.value) == "invalid value of type MyClass: " 1581 | assert ll == ['p', 'q', 'r'] 1582 | ll[:2] = sanest.list([{}, []]) 1583 | assert ll == [{}, [], 'r'] 1584 | with pytest.raises(ValueError) as excinfo: 1585 | ll[0::2] = ['this', 'one', 'is', 'too', 'long'] 1586 | assert str(excinfo.value) == ( 1587 | "attempt to assign sequence of size 5 to extended slice of size 2") 1588 | 1589 | 1590 | def test_list_del_slice(): 1591 | ll = sanest.list(['a', 'b', 'c', 'd', 'e']) 1592 | del ll[:2] 1593 | assert ll == ['c', 'd', 'e'] 1594 | del ll[-1:] 1595 | assert ll == ['c', 'd'] 1596 | del ll[:] 1597 | assert ll == [] 1598 | 1599 | 1600 | # 1601 | # dicts and lists 1602 | # 1603 | 1604 | 1605 | def test_prevent_subclassing(): 1606 | with pytest.raises(TypeError) as excinfo: 1607 | class X(sanest.dict): 1608 | pass 1609 | assert str(excinfo.value) == ( 1610 | "type 'sanest.dict' is not an acceptable base type") 1611 | 1612 | with pytest.raises(TypeError) as excinfo: 1613 | class Y(sanest.list): 1614 | pass 1615 | assert str(excinfo.value) == ( 1616 | "type 'sanest.list' is not an acceptable base type") 1617 | 1618 | 1619 | def test_wrap(): 1620 | ll = _sanest.list.wrap([1, 2]) 1621 | assert isinstance(ll, sanest.list) 1622 | d = _sanest.wrap({'a': 1}) 1623 | assert isinstance(d, sanest.dict) 1624 | with pytest.raises(TypeError) as excinfo: 1625 | _sanest.wrap(MyClass()) 1626 | assert str(excinfo.value) == "not a dict or list: " 1627 | 1628 | 1629 | def test_dict_list_mixed_nested_lookup(): 1630 | d = sanest.dict({ 1631 | 'a': [ 1632 | {'b': [1]}, 1633 | {'b': [2]}, 1634 | ], 1635 | }) 1636 | assert d['a', 0] == {'b': [1]} 1637 | assert d['a', 0, 'b'] == [1] 1638 | assert d['a', 1, 'b', 0] == 2 1639 | 1640 | 1641 | def test_dict_list_contains(): 1642 | d = sanest.dict({ 1643 | 'a': ['b', 'c'], 1644 | }) 1645 | assert ['a'] in d 1646 | assert ['a', 0] in d 1647 | assert ['a', 0, 'x'] not in d 1648 | assert ['a', 'x'] not in d 1649 | assert ['a', 3] not in d 1650 | 1651 | 1652 | def test_wrong_path_for_container_type(): 1653 | d = sanest.dict() 1654 | ll = sanest.list() 1655 | with pytest.raises(sanest.InvalidPathError) as excinfo: 1656 | d[2, 'a'] 1657 | assert str(excinfo.value) == "dict path must start with str: [2, 'a']" 1658 | with pytest.raises(sanest.InvalidPathError) as excinfo: 1659 | ll['a', 2] 1660 | assert str(excinfo.value) == "list path must start with int: ['a', 2]" 1661 | 1662 | 1663 | # 1664 | # misc 1665 | # 1666 | 1667 | def test_missing_arg_repr(): 1668 | assert repr(_sanest.MISSING) == '' 1669 | assert str(_sanest.MISSING) == '' 1670 | 1671 | 1672 | def test_slots(): 1673 | d = sanest.dict() 1674 | with pytest.raises(AttributeError): 1675 | d.foo = 123 1676 | ll = sanest.list() 1677 | with pytest.raises(AttributeError): 1678 | ll.foo = 123 1679 | 1680 | 1681 | def dedent(s): 1682 | s = s.lstrip('\n') 1683 | s = textwrap.dedent(s).rstrip() 1684 | return s 1685 | 1686 | 1687 | PRETTY_PRINT_SAMPLES = [ 1688 | ( 1689 | sanest.dict(a='a' * 30, b='b' * 30), 1690 | dedent("""\ 1691 | sanest.dict({'a': 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 1692 | 'b': 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'}) 1693 | """)), 1694 | ( 1695 | sanest.list(['a' * 30, 'b' * 30]), 1696 | dedent("""\ 1697 | sanest.list(['aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 1698 | 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbb']) 1699 | """)), 1700 | ] 1701 | 1702 | 1703 | @pytest.mark.skipif( 1704 | sys.version_info < (3, 5), 1705 | reason="requires python 3.5+ pprint module") 1706 | @pytest.mark.parametrize(('input', 'expected'), PRETTY_PRINT_SAMPLES) 1707 | def test_pretty_printing_pprint(input, expected): 1708 | actual = pprint.pformat(input) 1709 | assert actual == expected 1710 | 1711 | 1712 | @pytest.mark.parametrize(('input', 'expected'), PRETTY_PRINT_SAMPLES) 1713 | def test_pretty_printing_ipython(input, expected): 1714 | actual = IPython.lib.pretty.pretty(input) 1715 | assert actual == expected 1716 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py36, py35, py34, py33, pypy3 3 | 4 | [testenv] 5 | deps = -rrequirements-test.txt 6 | commands = 7 | pytest {posargs} 8 | flake8 sanest/ 9 | --------------------------------------------------------------------------------