├── pytest_assert_utils ├── util │ ├── __init__.py │ ├── assertions.py │ └── decl.py └── __init__.py ├── .travis.yml ├── tox.ini ├── pytest.ini ├── LICENSE ├── pyproject.toml ├── .gitignore ├── CHANGELOG.md ├── tests ├── test_assertions.py └── test_decl.py ├── README.md └── poetry.lock /pytest_assert_utils/util/__init__.py: -------------------------------------------------------------------------------- 1 | from .assertions import * 2 | from .decl import * 3 | -------------------------------------------------------------------------------- /pytest_assert_utils/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = '0.3.0' 2 | 3 | from . import util 4 | from .util import assert_dict_is_subset, assert_model_attrs 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - '3.7' 4 | - '3.8' 5 | - '3.9' 6 | - '3.10' 7 | - '3.11' 8 | 9 | before_install: pip install poetry 10 | install: pip install tox-travis 11 | script: tox 12 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | isolated_build = true 3 | envlist = py{37,38,39,310,311} 4 | 5 | 6 | [testenv] 7 | skip_install = true 8 | commands_pre = pip install poetry 9 | poetry install -v --no-root 10 | commands = poetry run pytest 11 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = -v --tb=short --doctest-modules 3 | 4 | python_classes = Test* Describe* Context* Case* 5 | python_functions = test_* it_* its_* test 6 | python_files = tests.py test_*.py 7 | 8 | doctest_optionflags = IGNORE_EXCEPTION_DETAIL 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Perch Security, Inc 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = 'pytest-assert-utils' 3 | version = "0.3.1" 4 | description = 'Useful assertion utilities for use with pytest' 5 | license = 'MIT' 6 | 7 | authors = [ 8 | 'Zach "theY4Kman" Kanzler ' 9 | ] 10 | 11 | readme = 'README.md' 12 | 13 | repository = 'https://github.com/theY4Kman/pytest-assert-utils' 14 | homepage = 'https://github.com/theY4Kman/pytest-assert-utils' 15 | 16 | keywords = ['pytest'] 17 | classifiers=[ 18 | 'Development Status :: 3 - Alpha', 19 | 'Programming Language :: Python', 20 | 'Framework :: Pytest', 21 | 'License :: OSI Approved :: MIT License', 22 | 'Topic :: Software Development :: Testing', 23 | ] 24 | packages = [ 25 | { include = "pytest_assert_utils" }, 26 | { include = "tests", format = "sdist" }, 27 | { include = "pytest.ini", format = "sdist" }, 28 | { include = "LICENSE", format = "sdist" }, 29 | { include = "CHANGELOG.md", format = "sdist" }, 30 | { include = "README.md", format = "sdist" }, 31 | ] 32 | 33 | 34 | [tool.poetry.plugins.pytest11] 35 | assert_utils = "pytest_assert_utils" 36 | 37 | 38 | [tool.poetry.dependencies] 39 | # Typing annotations are used 40 | # XXX: for whatever reason, poetry doesn't like `>=3.7` — the additional pin allows locking to work 41 | python = '^3.7, >= 3.7' 42 | 43 | 44 | [tool.poetry.dev-dependencies] 45 | pytest = '>=3.6' 46 | pytest-icdiff = "^0.5" 47 | pytest-lambda = "^1.2.5" 48 | tox = "^3.23.0" 49 | typing-extensions = { version = "^3.10.0", python = "<3.8" } 50 | 51 | 52 | [build-system] 53 | requires = ['poetry>=0.12'] 54 | build-backend = 'poetry.masonry.api' 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | 106 | # pycharm 107 | .idea/ 108 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) 5 | and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). 6 | 7 | 8 | ## [Unreleased] 9 | 10 | 11 | ## [0.3.1] — 2022-04-13 12 | ### Added 13 | - Add support for Python 3.11 14 | 15 | ### Removed 16 | - Removed support for Python 3.6 (mostly due to poor `typing.Generic` support) — see [GH#3](https://github.com/theY4Kman/pytest-assert-utils/issues/3) 17 | 18 | 19 | ## [0.3.0] — 2021-09-21 20 | ### Added 21 | - Added `containing_only` and `containing_exactly` methods to `util.Collection` classes — see [GH#1](https://github.com/theY4Kman/pytest-assert-utils/pull/1) (thanks, [sjhewitt](https://github.com/sjhewitt)) 22 | - Added `util.Model` class to check attrs of objects using an equality comparison — see [GH#2](https://github.com/theY4Kman/pytest-assert-utils/pull/2) (thanks, [sjhewitt](https://github.com/sjhewitt)) 23 | - Added tests for `util.Collection` classes 24 | 25 | 26 | ## [0.2.2] — 2021-03-27 27 | ### Fixed 28 | - Add support for Python 3.10 29 | 30 | 31 | ## [0.2.1] — 2020-08-25 32 | ### Added 33 | - Add documentation to README 34 | - Transfer ownership to @theY4Kman 35 | 36 | 37 | ## [0.2.0] — 2020-08-04 38 | ### Added 39 | - Allow `util.Collection` classes to be used directly in comparisons 40 | - Add `util.Dict` and `util.Str` comparison classes 41 | 42 | 43 | ## [0.1.1] — 2020-03-30 44 | ### Fixed 45 | - Added setuptools entrypoint, so pytest will perform assertion rewriting 46 | 47 | 48 | ## [0.1.0] — 2019-12-08 49 | ### Added 50 | - Introducing `util.Any(*types)` and `util.Optional(value)` meta-values to allow flexibility when performing equality comparisons. 51 | - Added `util.Collection`, `util.List`, and `util.Set` to flexibly perform assertions on collection types when performing equality comparisons. 52 | 53 | 54 | ## [0.0.1] — 2019-08-14 55 | ### Added 56 | - Introducing `assert_dict_is_subset` and `assert_model_attrs` assertion utilities 57 | -------------------------------------------------------------------------------- /pytest_assert_utils/util/assertions.py: -------------------------------------------------------------------------------- 1 | import operator 2 | 3 | try: 4 | from collections.abc import Mapping 5 | except ImportError: 6 | from collections import Mapping 7 | 8 | __all__ = ['assert_dict_is_subset', 'assert_model_attrs'] 9 | 10 | UNSET = object() 11 | 12 | 13 | def assert_dict_is_subset(subset, superset, recursive=True): 14 | """Assert `subset` is a non-strict subset of `superset` 15 | 16 | If this assertion fails, a pretty diff will be printed by pytest. 17 | 18 | >>> expected = {'a': 12} 19 | >>> actual = {'b': 20, 'a': 12} 20 | >>> assert_dict_is_subset(expected, actual) 21 | 22 | >>> expected = {'a': 12} 23 | >>> actual = {'b': 50000} 24 | >>> assert_dict_is_subset(expected, actual) 25 | Traceback (most recent call last): 26 | ... 27 | AssertionError 28 | """ 29 | superset_slice = _slice_superset(subset, superset, recursive=recursive) 30 | 31 | expected = subset 32 | actual = superset_slice 33 | assert expected == actual 34 | 35 | 36 | def assert_model_attrs(instance, _d=UNSET, **attrs): 37 | """Assert a model instance has the specified attr values 38 | """ 39 | if _d is not UNSET: 40 | if attrs: 41 | attrs['_d'] = _d 42 | else: 43 | attrs = _d 44 | 45 | model_attrs_slice = _slice_superset(attrs, instance, 46 | getitem=getattr, hasitem=hasattr) 47 | 48 | expected = attrs 49 | actual = model_attrs_slice 50 | assert_dict_is_subset(expected, actual) 51 | 52 | 53 | def _slice_superset(subset, superset, 54 | recursive=True, 55 | getitem=operator.getitem, 56 | hasitem=operator.contains): 57 | superset_slice = {} 58 | 59 | for key, value in subset.items(): 60 | if not hasitem(superset, key): 61 | continue 62 | 63 | superset_value = getitem(superset, key) 64 | if recursive and (isinstance(value, Mapping) and 65 | _is_bare_mapping(superset_value)): 66 | # NOTE: value *must* have .items(), superset_value only needs 67 | # `b in a` and `a[b]` 68 | superset_value = _slice_superset(value, superset_value) 69 | 70 | superset_slice[key] = superset_value 71 | 72 | return superset_slice 73 | 74 | 75 | def _is_bare_mapping(o): 76 | """Whether the object supports __getitem__ and __contains__""" 77 | return ( 78 | hasattr(o, '__contains__') and callable(o.__contains__) and 79 | hasattr(o, '__getitem__') and callable(o.__getitem__) 80 | ) 81 | -------------------------------------------------------------------------------- /tests/test_assertions.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import pytest 4 | 5 | from pytest_assert_utils import assert_dict_is_subset, assert_model_attrs 6 | 7 | if sys.version_info < (3, 7): 8 | import collections 9 | 10 | def namedtuple(typename, field_names, *, rename=False, defaults=(), module=None): 11 | """Named tuple with defaults support for Python 3.6 12 | 13 | Source: https://stackoverflow.com/a/18348004/148585 14 | """ 15 | typ = collections.namedtuple(typename, field_names, rename=rename, module=module) 16 | typ.__new__.__defaults__ = (None,) * len(field_names) 17 | 18 | if isinstance(defaults, collections.Mapping): 19 | prototype = typ(**defaults) 20 | else: 21 | prototype = typ(*defaults) 22 | 23 | typ.__new__.__defaults__ = tuple(prototype) 24 | return typ 25 | else: 26 | from collections import namedtuple 27 | 28 | 29 | class DescribeAssertDictIsSubset: 30 | 31 | @pytest.mark.parametrize('expected, actual', [ 32 | pytest.param( 33 | {}, 34 | {}, 35 | id='empty', 36 | ), 37 | pytest.param( 38 | {'key': 'value'}, 39 | {'key': 'value'}, 40 | id='non-strict-superset', 41 | ), 42 | pytest.param( 43 | {'key': 'value'}, 44 | {'key': 'value', 45 | 'other_key': 'other_value'}, 46 | id='strict-superset', 47 | ), 48 | pytest.param( 49 | {'parent': {'key': 'value'}}, 50 | {'parent': {'key': 'value', 51 | 'other_key': 'other_value'}}, 52 | id='nested-strict-superset', 53 | ), 54 | ]) 55 | def it_evaluates_equal_subsets_truthily(self, expected, actual): 56 | try: 57 | assert_dict_is_subset(expected, actual) 58 | except AssertionError as e: 59 | raise AssertionError('dict was unexpectedly not a subset') from e 60 | 61 | 62 | @pytest.mark.parametrize('expected, actual', [ 63 | pytest.param( 64 | {'key': 'value'}, 65 | {}, 66 | id='empty', 67 | ), 68 | pytest.param( 69 | {'key': 'value', 70 | 'other_key': 'other_value'}, 71 | {'key': 'value'}, 72 | id='expected-more', 73 | ), 74 | pytest.param( 75 | {'parent': {'key': 'value', 76 | 'other_key': 'other_value'}}, 77 | {'parent': {'key': 'value'}}, 78 | id='expected-more-nested', 79 | ), 80 | ]) 81 | def it_evaluates_unequal_subsets_falsily(self, expected, actual): 82 | with pytest.raises(AssertionError): 83 | assert_dict_is_subset(expected, actual) 84 | 85 | # Assertion above should fail. If not, manually fail it. 86 | pytest.fail('dict was unexpectedly a subset') 87 | 88 | 89 | class DescribeAssertModelAttrs: 90 | 91 | Model = namedtuple('Model', 92 | ('id', 'key', 'other_key', 'parent'), 93 | defaults=(None, None, None, None)) 94 | 95 | @pytest.mark.parametrize('expected,actual', [ 96 | pytest.param( 97 | {}, 98 | Model(), 99 | id='empty', 100 | ), 101 | pytest.param( 102 | {'key': 'value'}, 103 | Model(key='value'), 104 | id='non-strict-superset', 105 | ), 106 | pytest.param( 107 | {'key': 'value'}, 108 | Model(key='value', other_key='other_value'), 109 | id='strict-superset', 110 | ), 111 | ]) 112 | @pytest.mark.parametrize('as_kwargs', [ 113 | pytest.param(False, id='passing-dict'), 114 | pytest.param(True, id='passing-kwargs'), 115 | ]) 116 | def it_evaluates_equivalent_item_subsets_truthily(self, expected, actual, as_kwargs): 117 | if as_kwargs: 118 | assert_model_attrs(actual, **expected) 119 | else: 120 | assert_model_attrs(actual, expected) 121 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pytest-assert-utils 2 | 3 | Handy assertion utilities for use with pytest 4 | 5 | 6 | # Installation 7 | 8 | ```bash 9 | pip install pytest-assert-utils 10 | ``` 11 | 12 | 13 | # Usage 14 | 15 | ## assert_dict_is_subset 16 | ```python 17 | def assert_dict_is_subset(subset, superset, recursive=True) 18 | ``` 19 | 20 | Assert `subset` is a non-strict subset of `superset` 21 | 22 | If this assertion fails, a pretty diff will be printed by pytest. 23 | 24 | ```pycon 25 | >>> from pytest_assert_utils import assert_dict_is_subset 26 | 27 | >>> expected = {'a': 12} 28 | >>> actual = {'b': 20, 'a': 12} 29 | >>> assert_dict_is_subset(expected, actual) 30 | 31 | >>> expected = {'a': 12} 32 | >>> actual = {'b': 50000} 33 | >>> assert_dict_is_subset(expected, actual) 34 | Traceback (most recent call last): 35 | ... 36 | AssertionError 37 | ``` 38 | 39 | ## assert_model_attrs 40 | ```python 41 | def assert_model_attrs(instance, _d=UNSET, **attrs) 42 | ``` 43 | 44 | Assert a model instance has the specified attr values 45 | 46 | May be passed a dict of attrs, or kwargs as attrs 47 | 48 | ```pycon 49 | >>> from pytest_assert_utils import assert_model_attrs 50 | 51 | >>> from collections import namedtuple 52 | >>> Model = namedtuple('Model', 'id,key,other_key,parent', defaults=(None,)*4) 53 | 54 | >>> assert_model_attrs(Model(), {}) 55 | 56 | >>> assert_model_attrs(Model(key='value'), {'key': 'value'}) 57 | >>> assert_model_attrs(Model(key='value'), key='value') 58 | >>> assert_model_attrs(Model(key='value'), key='not the value') 59 | Traceback (most recent call last): 60 | ... 61 | AssertionError 62 | 63 | >>> assert_model_attrs(Model(key='value', other_key='other_value'), key='value') 64 | ``` 65 | 66 | ## Any 67 | Meta-value which compares True to any object (of the specified type(s)) 68 | 69 | ```pycon 70 | >>> from pytest_assert_utils import util 71 | 72 | >>> util.Any() == 'stuff' 73 | True 74 | >>> util.Any() == 1 75 | True 76 | >>> util.Any() == None 77 | True 78 | >>> util.Any() == object() 79 | True 80 | 81 | >>> util.Any(int) == 1 82 | True 83 | >>> util.Any(int) == '1' 84 | False 85 | ``` 86 | 87 | ## Optional 88 | Meta-value which compares True to None or the optionally specified value 89 | 90 | ```pycon 91 | >>> from pytest_assert_utils import util 92 | 93 | >>> util.Optional() == None 94 | True 95 | >>> util.Optional() is None # this will not work! 96 | False 97 | >>> util.Optional(24) == 24 98 | True 99 | >>> util.Optional(24) == None 100 | True 101 | 102 | >>> util.Optional(Any(int)) == 1 103 | True 104 | >>> util.Optional(Any(int)) == None 105 | True 106 | >>> util.Optional(Any(int)) == '1' 107 | False 108 | ``` 109 | 110 | ## Collection 111 | Special class enabling equality comparisons to check items in any collection (list, set, tuple, etc) 112 | 113 | ```pycon 114 | >>> from pytest_assert_utils import util 115 | 116 | >>> util.Collection.containing(1) == [1, 2, 3] 117 | True 118 | >>> util.Collection.containing(1) == {1, 2, 3} 119 | True 120 | >>> util.Collection.containing(1) == (1, 2, 3) 121 | True 122 | 123 | >>> util.Collection.containing(1) == [4, 5, 6] 124 | False 125 | >>> util.Collection.containing(1) == {4, 5, 6} 126 | False 127 | >>> util.Collection.containing(1) == (4, 5, 6) 128 | False 129 | ``` 130 | 131 | ## List 132 | Special class enabling equality comparisons to check items in a list 133 | 134 | ```pycon 135 | >>> from pytest_assert_utils import util 136 | 137 | >>> util.List.containing(1) == [1, 2, 3] 138 | True 139 | >>> util.List.containing(1) == [4, 5, 6] 140 | False 141 | 142 | >>> util.List.containing_only(1, 2) == [1, 2, 3] 143 | False 144 | >>> util.List.containing_only(1, 2) == [1, 2, 2] 145 | True 146 | >>> util.List.containing_only(4, 5, 6) == [4, 5, 6] 147 | True 148 | >>> util.List.containing_only(4, 5, 6, 7) == [4, 5, 6] 149 | True 150 | 151 | >>> util.List.containing_exactly(1, 2) == [1, 2, 3] 152 | False 153 | >>> util.List.containing_exactly(4, 5, 6, 7) == [4, 5, 6] 154 | False 155 | >>> util.List.containing_exactly(5, 6, 4) == [4, 5, 6] 156 | True 157 | >>> util.List.containing_exactly(4, 5) == [4, 5, 5] 158 | False 159 | >>> util.List.containing_exactly(5, 4, 5) == [4, 5, 5] 160 | True 161 | 162 | >>> util.List.not_containing(1) == [1, 2, 3] 163 | False 164 | >>> util.List.not_containing(1) == [4, 5, 6] 165 | True 166 | 167 | >>> util.List.empty() == [1, 2, 3] 168 | False 169 | >>> util.List.empty() == [] 170 | True 171 | 172 | >>> util.List.not_empty() == [1, 2, 3] 173 | True 174 | >>> util.List.not_empty() == [] 175 | False 176 | ``` 177 | 178 | ## Set 179 | Special class enabling equality comparisons to check items in a set 180 | 181 | ```pycon 182 | >>> from pytest_assert_utils import util 183 | 184 | >>> util.Set.containing(1) == {1, 2, 3} 185 | True 186 | >>> util.Set.containing(1) == {4, 5, 6} 187 | False 188 | 189 | >>> util.Set.not_containing(1) == {1, 2, 3} 190 | False 191 | >>> util.Set.not_containing(1) == {4, 5, 6} 192 | True 193 | 194 | >>> util.Set.empty() == {1, 2, 3} 195 | False 196 | >>> util.Set.empty() == set() 197 | True 198 | 199 | >>> util.Set.not_empty() == {1, 2, 3} 200 | True 201 | >>> util.Set.not_empty() == set() 202 | False 203 | ``` 204 | 205 | ## Dict 206 | Special class enabling equality comparisons to check items in a dict 207 | 208 | ```pycon 209 | >>> from pytest_assert_utils import util 210 | 211 | >>> util.Dict.containing('a') == {'a': 1, 'b': 2} 212 | True 213 | >>> util.Dict.containing(a=1) == {'a': 1, 'b': 2} 214 | True 215 | >>> util.Dict.containing({'a': 1}) == {'a': 1, 'b': 2} 216 | True 217 | >>> util.Dict.containing('a') == {'b': 2} 218 | False 219 | >>> util.Dict.containing(a=1) == {'b': 2} 220 | False 221 | >>> util.Dict.containing({'a': 1}) == {'b': 2} 222 | False 223 | 224 | >>> util.Dict.not_containing('a') == {'a': 1, 'b': 2} 225 | False 226 | >>> util.Dict.not_containing(a=1) == {'a': 1, 'b': 2} 227 | False 228 | >>> util.Dict.not_containing({'a': 1}) == {'a': 1, 'b': 2} 229 | False 230 | >>> util.Dict.not_containing('a') == {'b': 2} 231 | True 232 | >>> util.Dict.not_containing(a=1) == {'b': 2} 233 | True 234 | >>> util.Dict.not_containing({'a': 1}) == {'b': 2} 235 | True 236 | 237 | >>> util.Dict.empty() == {'a': 1, 'b': 2, 'c': 3} 238 | False 239 | >>> util.Dict.empty() == {} 240 | True 241 | 242 | >>> util.Dict.not_empty() == {'a': 1, 'b': 2, 'c': 3} 243 | True 244 | >>> util.Dict.not_empty() == {} 245 | False 246 | ``` 247 | 248 | ## Str 249 | Special class enabling equality comparisons to check items in a string 250 | 251 | ```pycon 252 | >>> from pytest_assert_utils import util 253 | 254 | >>> util.Str.containing('app') == 'apple' 255 | True 256 | >>> util.Str.containing('app') == 'happy' 257 | True 258 | >>> util.Str.containing('app') == 'banana' 259 | False 260 | 261 | >>> util.Str.not_containing('app') == 'apple' 262 | False 263 | >>> util.Str.not_containing('app') == 'happy' 264 | False 265 | >>> util.Str.not_containing('app') == 'banana' 266 | True 267 | 268 | >>> util.Str.empty() == 'hamster' 269 | False 270 | >>> util.Str.empty() == '' 271 | True 272 | 273 | >>> util.Str.not_empty() == 'hamster' 274 | True 275 | >>> util.Str.not_empty() == '' 276 | False 277 | ``` 278 | 279 | ## Model 280 | Special class enabling equality comparisons to check attrs of another object 281 | 282 | 283 | ```pycon 284 | >>> from collections import namedtuple 285 | >>> Foo = namedtuple('Foo', 'id,key,other_key,parent', defaults=(None,)*4) 286 | 287 | >>> Foo() == Model() 288 | True 289 | 290 | >>> Foo(key='value') == Model(key='value') 291 | True 292 | >>> Foo(key='value') == Model(key='not the value') 293 | False 294 | >>> Foo(key='value', other_key='other_value') == Model(key='value') 295 | True 296 | >>> [Foo(key='value', other_key='other_value')] == List.containing(Model(key='value')) 297 | True 298 | ``` 299 | -------------------------------------------------------------------------------- /tests/test_decl.py: -------------------------------------------------------------------------------- 1 | from dataclasses import make_dataclass 2 | from datetime import datetime 3 | from functools import partial 4 | from typing import Type, TypeVar 5 | 6 | import pytest 7 | from pytest_lambda import lambda_fixture 8 | 9 | from pytest_assert_utils import util 10 | from pytest_assert_utils.util import decl 11 | 12 | 13 | class DescribeAny: 14 | 15 | def it_compares_true_to_anything_with_no_args(self): 16 | example_values = [ 17 | 1, 18 | '2', 19 | 3.0, 20 | b'4', 21 | object(), 22 | None, 23 | True, 24 | False, 25 | ] 26 | 27 | any_ = util.Any() 28 | inequal_values = [ 29 | value 30 | for value in example_values 31 | if any_ != value 32 | ] 33 | 34 | assert not inequal_values, \ 35 | 'Unexpectedly found values which did not compare true to Any()' 36 | 37 | @pytest.mark.parametrize('expected,actual', [ 38 | pytest.param(util.Any(int), 1, id='int'), 39 | pytest.param(util.Any(str), '1', id='str'), 40 | pytest.param(util.Any(datetime), datetime.today(), id='datetime'), 41 | ]) 42 | def it_compares_true_to_values_of_same_type(self, expected, actual): 43 | assert expected == actual 44 | 45 | @pytest.mark.parametrize('expected,actual', [ 46 | pytest.param(util.Any(int), '1', id='int'), 47 | pytest.param(util.Any(str), 1, id='str'), 48 | pytest.param(util.Any(datetime), None, id='datetime'), 49 | ]) 50 | def it_compares_false_to_values_of_other_types(self, expected, actual): 51 | assert expected != actual 52 | 53 | def it_generates_string_repr_including_types(self): 54 | expected = { 55 | util.Any(): '', 56 | util.Any(int): '', 57 | util.Any(int, str): '', 58 | } 59 | actual = { 60 | instance: repr(instance) 61 | for instance in expected 62 | } 63 | assert expected == actual 64 | 65 | 66 | CHECKER_CLASSES = ( 67 | util.List, 68 | util.Set, 69 | util.Dict, 70 | util.Str, 71 | ) 72 | 73 | CHECKER_COLLECTION_TYPES = { 74 | cls: cls.__bases__[1] 75 | for cls in CHECKER_CLASSES if cls is not util.Collection 76 | } 77 | 78 | 79 | _CT = TypeVar('_CT', bound=decl.BaseCollection) 80 | 81 | 82 | def create_collection_of_type(collection_type: Type[_CT], *items) -> _CT: 83 | if issubclass(collection_type, dict): 84 | return {item: item for item in items} 85 | elif issubclass(collection_type, str): 86 | return ''.join(items) 87 | else: 88 | return collection_type(items) 89 | 90 | 91 | class DescribeCollectionValuesChecker: 92 | 93 | checker_cls = lambda_fixture(params=[ 94 | util.List, 95 | util.Set, 96 | util.Dict, 97 | util.Str, 98 | ]) 99 | collection_type = lambda_fixture(lambda checker_cls: checker_cls._collection_type) 100 | create_collection = lambda_fixture(lambda collection_type: partial(create_collection_of_type, collection_type)) 101 | 102 | 103 | class CaseType: 104 | checker_cls = lambda_fixture(params=CHECKER_CLASSES + (util.Collection,)) 105 | 106 | compatible_collection_types = lambda_fixture( 107 | lambda checker_cls: 108 | set(CHECKER_COLLECTION_TYPES.values()) 109 | if checker_cls is util.Collection 110 | else {CHECKER_COLLECTION_TYPES[checker_cls]} 111 | ) 112 | 113 | incompatible_collection_types = lambda_fixture( 114 | lambda compatible_collection_types: 115 | set(CHECKER_COLLECTION_TYPES.values()) - compatible_collection_types) 116 | 117 | def it_compares_true_to_compatible_instances(self, checker_cls, compatible_collection_types): 118 | checker = checker_cls 119 | 120 | expected = compatible_collection_types 121 | actual = {cls for cls in CHECKER_COLLECTION_TYPES.values() if checker == cls()} 122 | assert expected == actual 123 | 124 | def it_doesnt_compare_true_to_incompatible_instances(self, checker_cls, incompatible_collection_types): 125 | checker = checker_cls 126 | 127 | expected = incompatible_collection_types 128 | actual = {cls for cls in CHECKER_COLLECTION_TYPES.values() if checker != cls()} 129 | assert expected == actual 130 | 131 | 132 | class DescribeEmpty: 133 | 134 | def it_compares_true_to_empty_collection(self, checker_cls, create_collection): 135 | collection = create_collection() 136 | assert len(collection) == 0 # sanity check 137 | 138 | expected = checker_cls.empty() 139 | actual = collection 140 | assert expected == actual 141 | 142 | def it_compares_false_to_nonempty_collection(self, checker_cls, create_collection): 143 | collection = create_collection('a') 144 | assert len(collection) > 0 # sanity check 145 | 146 | expected = checker_cls.empty() 147 | actual = collection 148 | assert expected != actual 149 | 150 | 151 | class DescribeNotEmpty: 152 | 153 | def it_compares_true_to_nonempty_collection(self, checker_cls, create_collection): 154 | collection = create_collection('a') 155 | assert len(collection) > 0 # sanity check 156 | 157 | expected = checker_cls.not_empty() 158 | actual = collection 159 | assert expected == actual 160 | 161 | def it_compares_false_to_empty_collection(self, checker_cls, create_collection): 162 | collection = create_collection() 163 | assert len(collection) == 0 # sanity check 164 | 165 | expected = checker_cls.not_empty() 166 | actual = collection 167 | assert expected != actual 168 | 169 | 170 | class DescribeContaining: 171 | 172 | def it_compares_true_to_collection_containing_values(self, checker_cls, create_collection): 173 | expected = checker_cls.containing('b', 'c') 174 | actual = create_collection(*'abcz') 175 | assert expected == actual 176 | 177 | def it_accepts_values_from_generator(self, checker_cls, create_collection): 178 | expected = checker_cls.containing(c for c in 'bc') 179 | actual = create_collection(*'abcz') 180 | assert expected == actual 181 | 182 | def it_compares_false_to_collection_not_containing_values(self, checker_cls, create_collection): 183 | expected = checker_cls.containing('b', 'c') 184 | actual = create_collection(*'acz') 185 | assert expected != actual 186 | 187 | 188 | class DescribeNotContaining: 189 | 190 | def it_compares_true_to_collection_not_containing_values(self, checker_cls, create_collection): 191 | expected = checker_cls.not_containing('b', 'd') 192 | actual = create_collection(*'acz') 193 | assert expected == actual 194 | 195 | def it_accepts_values_from_generator(self, checker_cls, create_collection): 196 | expected = checker_cls.not_containing(c for c in 'bd') 197 | actual = create_collection(*'acz') 198 | assert expected == actual 199 | 200 | def it_compares_false_to_collection_containing_values(self, checker_cls, create_collection): 201 | expected = checker_cls.not_containing('b', 'c') 202 | actual = create_collection(*'abcz') 203 | assert expected != actual 204 | 205 | 206 | class DescribeContainingOnly: 207 | 208 | def it_compares_true_to_collection_containing_only_values(self, checker_cls, create_collection): 209 | expected = checker_cls.containing_only('b', 'c') 210 | actual = create_collection(*'bc') 211 | assert expected == actual 212 | 213 | def it_accepts_values_from_generator(self, checker_cls, create_collection): 214 | expected = checker_cls.containing_only(c for c in 'bc') 215 | actual = create_collection(*'bc') 216 | assert expected == actual 217 | 218 | def it_compares_false_to_collection_containing_extra_values(self, checker_cls, create_collection): 219 | expected = checker_cls.containing_only('b', 'c') 220 | actual = create_collection(*'abcz') 221 | assert expected != actual 222 | 223 | def it_compares_false_to_collection_not_containing_values(self, checker_cls, create_collection): 224 | expected = checker_cls.containing_only('b', 'c') 225 | actual = create_collection(*'acz') 226 | assert expected != actual 227 | 228 | 229 | class DescribeContainingExactly: 230 | 231 | def it_compares_true_to_collection_containing_exactly_values(self, checker_cls, create_collection): 232 | expected = checker_cls.containing_exactly('a', 'b') 233 | actual = create_collection(*'ab') 234 | assert expected == actual 235 | 236 | def it_accepts_values_from_generator(self, checker_cls, create_collection): 237 | expected = checker_cls.containing_exactly(c for c in 'ab') 238 | actual = create_collection(*'ab') 239 | assert expected == actual 240 | 241 | def it_compares_false_to_collection_not_containing_values(self, checker_cls, create_collection): 242 | expected = checker_cls.containing_exactly('a', 'b') 243 | actual = create_collection(*'ac') 244 | assert expected != actual 245 | 246 | def it_compares_false_to_collection_containing_additional_values(self, checker_cls, create_collection): 247 | expected = checker_cls.containing_exactly('a', 'b') 248 | actual = create_collection(*'abc') 249 | assert expected != actual 250 | 251 | def it_compares_false_to_collection_containing_dupe_values( 252 | self, checker_cls, create_collection, collection_type 253 | ): 254 | if issubclass(collection_type, (dict, set)): 255 | pytest.xfail('dict and set do not support dupe values') 256 | 257 | expected = checker_cls.containing_exactly('a', 'b') 258 | actual = create_collection(*'aabb') 259 | assert expected != actual 260 | 261 | def it_compares_false_to_collection_containing_fewer_values(self, checker_cls, create_collection): 262 | expected = checker_cls.containing_exactly('a', 'b') 263 | actual = create_collection('a') 264 | assert expected != actual 265 | 266 | 267 | class DescribeRepr: 268 | 269 | def it_generates_string_repr_for_all_methods(self, checker_cls): 270 | checker = ( 271 | checker_cls 272 | .empty() 273 | .not_empty() 274 | .containing('uniq1') 275 | .containing_only('uniq2') 276 | .not_containing('uniq5') 277 | ) 278 | if issubclass(checker_cls, dict): 279 | checker = checker.containing_exactly(uniq3='uniq4') 280 | else: 281 | checker = checker.containing_exactly('uniq3', 'uniq4') 282 | 283 | checker_repr = repr(checker) 284 | 285 | expected = { 286 | 'empty', 287 | 'not_empty', 288 | 'containing', 289 | 'containing_only', 290 | 'containing_exactly', 291 | 'not_containing', 292 | 'uniq1', 293 | 'uniq2', 294 | 'uniq3', 295 | 'uniq4', 296 | 'uniq5', 297 | } 298 | actual = {s for s in expected if s in checker_repr} 299 | assert actual == expected 300 | 301 | 302 | def create_object_with_attrs(**attrs): 303 | cls = make_dataclass('ExampleObject', attrs.keys()) 304 | return cls(**attrs) 305 | 306 | 307 | class DescribeModel: 308 | def it_compares_true_to_object_with_matching_attrs(self): 309 | expected = util.Model(a='alpha', b='beta') 310 | actual = create_object_with_attrs(a='alpha', b='beta') 311 | assert expected == actual 312 | 313 | def it_compares_false_to_object_with_differing_attr_values(self): 314 | expected = util.Model(a='alpha', b='beta') 315 | actual = create_object_with_attrs(a='agnes', b='bob') 316 | assert expected != actual 317 | 318 | def it_compares_false_to_object_with_missing_attr_values(self): 319 | expected = util.Model(a='alpha', b='beta') 320 | actual = create_object_with_attrs(a='alpha') 321 | assert expected != actual 322 | 323 | class DescribeRepr: 324 | def it_includes_attrs_and_values(self): 325 | checker = util.Model(uniq1='uniq2', uniq3='uniq4') 326 | checker_repr = repr(checker) 327 | 328 | expected = { 329 | 'uniq1', 330 | 'uniq2', 331 | 'uniq3', 332 | 'uniq4', 333 | } 334 | actual = {s for s in expected if s in checker_repr} 335 | assert actual == expected 336 | -------------------------------------------------------------------------------- /pytest_assert_utils/util/decl.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import inspect 3 | import typing 4 | 5 | from .assertions import assert_model_attrs 6 | 7 | try: 8 | from collections.abc import Collection as BaseCollection 9 | except ImportError: 10 | from collections import Collection as BaseCollection 11 | 12 | try: 13 | from typing import Protocol 14 | except ImportError: 15 | from typing_extensions import Protocol 16 | 17 | __all__ = [ 18 | 'Any', 19 | 'Optional', 20 | 'Collection', 21 | 'List', 22 | 'Set', 23 | 'Dict', 24 | 'Str', 25 | 'Model', 26 | ] 27 | 28 | 29 | T = typing.TypeVar('T') 30 | 31 | _CheckerType = typing.TypeVar('_CheckerType', bound=typing.Type['_BaseCollectionValuesChecker']) 32 | _ItemType = typing.TypeVar('_ItemType') 33 | 34 | 35 | class Any: 36 | """Meta-value which compares True to any object (of the specified type(s)) 37 | 38 | Examples of functionality: 39 | 40 | >>> Any() == 'stuff' 41 | True 42 | >>> Any() == 1 43 | True 44 | >>> Any() == None 45 | True 46 | >>> Any() == object() 47 | True 48 | 49 | >>> Any(int) == 1 50 | True 51 | >>> Any(int) == '1' 52 | False 53 | """ 54 | 55 | def __init__(self, *allowed_types): 56 | self.allowed_types = allowed_types 57 | 58 | def __eq__(self, other): 59 | if self.allowed_types: 60 | return isinstance(other, self.allowed_types) 61 | else: 62 | return True 63 | 64 | def __repr__(self): 65 | if not self.allowed_types: 66 | return '' 67 | else: 68 | return f'' 69 | 70 | def __hash__(self): 71 | return hash(self.allowed_types) 72 | 73 | 74 | class Optional: 75 | """Meta-value which compares True to None or the optionally specified value 76 | 77 | Examples of functionality: 78 | 79 | >>> Optional() == None 80 | True 81 | >>> Optional() is None # this will not work! 82 | False 83 | >>> Optional(24) == 24 84 | True 85 | >>> Optional(24) == None 86 | True 87 | 88 | >>> Optional(Any(int)) == 1 89 | True 90 | >>> Optional(Any(int)) == None 91 | True 92 | >>> Optional(Any(int)) == '1' 93 | False 94 | """ 95 | 96 | def __init__(self, value=None): 97 | self.value = value 98 | 99 | def __eq__(self, other): 100 | return other in (None, self.value) 101 | 102 | def __hash__(self): 103 | return hash(self.value) 104 | 105 | 106 | class _CollectionValuesCheckerMeta(type(BaseCollection)): 107 | """Overloads isinstance() to enable truthiness based on values inside the collection 108 | """ 109 | 110 | def __hash__(cls): 111 | return hash(id(cls)) 112 | 113 | def __eq__(cls: typing.Type['_BaseCollectionValuesChecker'], instance) -> bool: 114 | return cls.__instancecheck__(instance) 115 | 116 | def __instancecheck__(cls: typing.Type['_BaseCollectionValuesChecker'], instance) -> bool: 117 | if cls._collection_type and not isinstance(instance, cls._collection_type): 118 | return False 119 | 120 | if cls._must_be_empty and not cls._collection_is_empty_(instance): 121 | return False 122 | 123 | if cls._must_not_be_empty and cls._collection_is_empty_(instance): 124 | return False 125 | 126 | if cls._must_contain and not all(cls._collection_contains_(instance, v) for v in cls._must_contain): 127 | return False 128 | 129 | if cls._must_contain_only and not all(v in cls._must_contain_only for v in cls._collection_iter_(instance)): 130 | return False 131 | 132 | if cls._must_contain_exactly: 133 | values = list(cls._collection_iter_(instance)) 134 | if len(cls._must_contain_exactly) != len(values): 135 | return False 136 | for v in cls._must_contain_exactly: 137 | try: 138 | values.remove(v) 139 | except ValueError: 140 | return False 141 | 142 | if cls._must_not_contain and any(cls._collection_contains_(instance, v) for v in cls._must_not_contain): 143 | return False 144 | 145 | return True 146 | 147 | def __repr__(cls: typing.Type['_BaseCollectionValuesChecker']) -> str: 148 | parts = [cls.__name__] 149 | 150 | if cls._must_be_empty: 151 | parts.append('empty()') 152 | 153 | if cls._must_not_be_empty: 154 | parts.append('not_empty()') 155 | 156 | if cls._must_contain: 157 | parts.append(f'containing({cls._repr_containing_(cls._must_contain)})') 158 | 159 | if cls._must_contain_only: 160 | parts.append(f'containing_only({cls._repr_containing_only_(cls._must_contain_only)})') 161 | 162 | if cls._must_contain_exactly: 163 | parts.append(f'containing_exactly({cls._repr_containing_exactly_(cls._must_contain_exactly)})') 164 | 165 | if cls._must_not_contain: 166 | parts.append(f'not_containing({cls._repr_not_containing_(cls._must_not_contain)})') 167 | 168 | return '.'.join(parts) 169 | 170 | 171 | class _GenerativeMethod(Protocol): 172 | def __call__(cls: _CheckerType, *args, **kwargs) -> _CheckerType: ... 173 | 174 | 175 | def _generative(fn: _GenerativeMethod) -> _GenerativeMethod: 176 | 177 | @functools.wraps(fn) 178 | def wrapped(cls: _CheckerType, *args, **kwargs) -> _CheckerType: 179 | clone = typing.cast(_CheckerType, type(cls.__name__, (cls,), {})) 180 | fn(clone, *args, **kwargs) 181 | return clone 182 | 183 | return wrapped 184 | 185 | 186 | class _BaseCollectionValuesChecker(typing.Generic[_ItemType]): 187 | _collection_type: typing.Type[BaseCollection] = None 188 | _must_contain: typing.Tuple[_ItemType, ...] = () 189 | _must_contain_only: typing.Tuple[_ItemType, ...] = () 190 | _must_contain_exactly: typing.Tuple[_ItemType, ...] = () 191 | _must_not_contain: typing.Tuple[_ItemType, ...] = () 192 | _must_be_empty: bool = False 193 | _must_not_be_empty: bool = False 194 | 195 | def __init_subclass__(cls, **kwargs): 196 | cls._collection_type = next((base for base in reversed(cls.__mro__) if issubclass(base, BaseCollection)), None) 197 | 198 | @classmethod 199 | def _process_items(cls, items) -> typing.Iterable[_ItemType]: 200 | return cls._unpack_generators(items) 201 | 202 | @classmethod 203 | def _unpack_generators(cls, items) -> typing.Iterable[_ItemType]: 204 | for item in items: 205 | if inspect.isgenerator(item): 206 | yield from item 207 | else: 208 | yield item 209 | 210 | @classmethod 211 | @_generative 212 | def containing(cls, *items): 213 | cls._must_contain += tuple(cls._process_items(items)) 214 | 215 | @classmethod 216 | @_generative 217 | def containing_only(cls, *items): 218 | cls._must_contain_only += tuple(cls._process_items(items)) 219 | 220 | @classmethod 221 | @_generative 222 | def containing_exactly(cls, *items): 223 | cls._must_contain_exactly += tuple(cls._process_items(items)) 224 | 225 | @classmethod 226 | @_generative 227 | def not_containing(cls, *items): 228 | cls._must_not_contain += tuple(cls._process_items(items)) 229 | 230 | @classmethod 231 | @_generative 232 | def empty(cls): 233 | cls._must_be_empty = True 234 | 235 | @classmethod 236 | @_generative 237 | def not_empty(cls): 238 | cls._must_not_be_empty = True 239 | 240 | @classmethod 241 | def _repr_containing_(cls, must_contain): 242 | return ', '.join(repr(item) for item in must_contain) 243 | 244 | @classmethod 245 | def _repr_containing_only_(cls, must_contain_only): 246 | return cls._repr_containing_(must_contain_only) 247 | 248 | @classmethod 249 | def _repr_containing_exactly_(cls, must_contain_exactly): 250 | return cls._repr_containing_(must_contain_exactly) 251 | 252 | @classmethod 253 | def _repr_not_containing_(cls, must_not_contain): 254 | return cls._repr_containing_(must_not_contain) 255 | 256 | @classmethod 257 | def _collection_is_empty_(cls, collection) -> bool: 258 | return len(collection) == 0 259 | 260 | @classmethod 261 | def _collection_contains_(cls, collection, v) -> bool: 262 | return v in collection 263 | 264 | @classmethod 265 | def _collection_iter_(cls, collection) -> typing.Iterable[_ItemType]: 266 | return collection 267 | 268 | 269 | class _CollectionValuesChecker(_BaseCollectionValuesChecker[typing.Any]): 270 | pass 271 | 272 | 273 | class _DictValuesChecker(_BaseCollectionValuesChecker[typing.Tuple[typing.Hashable, typing.Any]]): 274 | @classmethod 275 | def _process_items( 276 | cls, 277 | items: typing.Union[typing.Hashable, typing.Dict], 278 | ) -> typing.List[typing.Tuple[typing.Hashable, typing.Any]]: 279 | processed = [] 280 | for item in cls._unpack_generators(items): 281 | if isinstance(item, dict): 282 | processed.extend(item.items()) 283 | else: 284 | processed.append((item, Any())) 285 | return processed 286 | 287 | @classmethod 288 | def containing(cls: T, *items, **kwargs) -> T: 289 | return super().containing(*items, kwargs) 290 | 291 | @classmethod 292 | def containing_only(cls: T, *items, **kwargs) -> T: 293 | return super().containing_only(*items, kwargs) 294 | 295 | @classmethod 296 | def containing_exactly(cls: T, *items, **kwargs) -> T: 297 | return super().containing_exactly(*items, kwargs) 298 | 299 | @classmethod 300 | def not_containing(cls: T, *items, **kwargs) -> T: 301 | return super().not_containing(*items, kwargs) 302 | 303 | @classmethod 304 | def _repr_containing_(cls, must_contain): 305 | return dict(must_contain) 306 | 307 | @classmethod 308 | def _collection_contains_(cls, collection, v) -> bool: 309 | k, v = v 310 | return k in collection and collection[k] == v 311 | 312 | @classmethod 313 | def _collection_iter_(cls, collection) -> typing.Iterable[typing.Tuple[typing.Hashable, typing.Any]]: 314 | return collection.items() 315 | 316 | 317 | class Collection(_CollectionValuesChecker, BaseCollection, metaclass=_CollectionValuesCheckerMeta): 318 | """Special class enabling equality comparisons to check items in any collection (list, set, tuple, etc) 319 | 320 | Examples of functionality: 321 | 322 | >>> Collection.containing(1) == [1, 2, 3] 323 | True 324 | >>> Collection.containing(1) == {1, 2, 3} 325 | True 326 | >>> Collection.containing(1) == (1, 2, 3) 327 | True 328 | 329 | >>> Collection.containing(1) == [4, 5, 6] 330 | False 331 | >>> Collection.containing(1) == {4, 5, 6} 332 | False 333 | >>> Collection.containing(1) == (4, 5, 6) 334 | False 335 | """ 336 | 337 | 338 | class List(_CollectionValuesChecker, list, metaclass=_CollectionValuesCheckerMeta): 339 | """Special class enabling equality comparisons to check items in a list 340 | 341 | Examples of functionality: 342 | 343 | >>> List.containing(1) == [1, 2, 3] 344 | True 345 | >>> List.containing(1) == [4, 5, 6] 346 | False 347 | 348 | >>> List.containing_only(1, 2) == [1, 2, 3] 349 | False 350 | >>> List.containing_only(1, 2) == [1, 2, 2] 351 | True 352 | >>> List.containing_only(4, 5, 6) == [4, 5, 6] 353 | True 354 | >>> List.containing_only(4, 5, 6, 7) == [4, 5, 6] 355 | True 356 | 357 | >>> List.containing_exactly(1, 2) == [1, 2, 3] 358 | False 359 | >>> List.containing_exactly(4, 5, 6, 7) == [4, 5, 6] 360 | False 361 | >>> List.containing_exactly(5, 6, 4) == [4, 5, 6] 362 | True 363 | >>> List.containing_exactly(4, 5) == [4, 5, 5] 364 | False 365 | >>> List.containing_exactly(5, 4, 5) == [4, 5, 5] 366 | True 367 | 368 | >>> List.not_containing(1) == [1, 2, 3] 369 | False 370 | >>> List.not_containing(1) == [4, 5, 6] 371 | True 372 | 373 | >>> List.empty() == [1, 2, 3] 374 | False 375 | >>> List.empty() == [] 376 | True 377 | 378 | >>> List.not_empty() == [1, 2, 3] 379 | True 380 | >>> List.not_empty() == [] 381 | False 382 | """ 383 | 384 | 385 | class Set(_CollectionValuesChecker, set, metaclass=_CollectionValuesCheckerMeta): 386 | """Special class enabling equality comparisons to check items in a set 387 | 388 | Examples of functionality: 389 | 390 | >>> Set.containing(1) == {1, 2, 3} 391 | True 392 | >>> Set.containing(1) == {4, 5, 6} 393 | False 394 | 395 | >>> Set.not_containing(1) == {1, 2, 3} 396 | False 397 | >>> Set.not_containing(1) == {4, 5, 6} 398 | True 399 | 400 | >>> Set.empty() == {1, 2, 3} 401 | False 402 | >>> Set.empty() == set() 403 | True 404 | 405 | >>> Set.not_empty() == {1, 2, 3} 406 | True 407 | >>> Set.not_empty() == set() 408 | False 409 | """ 410 | 411 | 412 | class Dict(_DictValuesChecker, dict, metaclass=_CollectionValuesCheckerMeta): 413 | """Special class enabling equality comparisons to check items in a dict 414 | 415 | Examples of functionality: 416 | 417 | >>> Dict.containing('a') == {'a': 1, 'b': 2} 418 | True 419 | >>> Dict.containing(a=1) == {'a': 1, 'b': 2} 420 | True 421 | >>> Dict.containing({'a': 1}) == {'a': 1, 'b': 2} 422 | True 423 | >>> Dict.containing('a') == {'b': 2} 424 | False 425 | >>> Dict.containing(a=1) == {'b': 2} 426 | False 427 | >>> Dict.containing({'a': 1}) == {'b': 2} 428 | False 429 | 430 | >>> Dict.not_containing('a') == {'a': 1, 'b': 2} 431 | False 432 | >>> Dict.not_containing(a=1) == {'a': 1, 'b': 2} 433 | False 434 | >>> Dict.not_containing({'a': 1}) == {'a': 1, 'b': 2} 435 | False 436 | >>> Dict.not_containing('a') == {'b': 2} 437 | True 438 | >>> Dict.not_containing(a=1) == {'b': 2} 439 | True 440 | >>> Dict.not_containing({'a': 1}) == {'b': 2} 441 | True 442 | 443 | >>> Dict.empty() == {'a': 1, 'b': 2, 'c': 3} 444 | False 445 | >>> Dict.empty() == {} 446 | True 447 | 448 | >>> Dict.not_empty() == {'a': 1, 'b': 2, 'c': 3} 449 | True 450 | >>> Dict.not_empty() == {} 451 | False 452 | 453 | """ 454 | 455 | 456 | class Str(_CollectionValuesChecker, str, metaclass=_CollectionValuesCheckerMeta): 457 | """Special class enabling equality comparisons to check items in a string 458 | 459 | Examples of functionality: 460 | 461 | >>> Str.containing('app') == 'apple' 462 | True 463 | >>> Str.containing('app') == 'happy' 464 | True 465 | >>> Str.containing('app') == 'banana' 466 | False 467 | 468 | >>> Str.not_containing('app') == 'apple' 469 | False 470 | >>> Str.not_containing('app') == 'happy' 471 | False 472 | >>> Str.not_containing('app') == 'banana' 473 | True 474 | 475 | >>> Str.empty() == 'hamster' 476 | False 477 | >>> Str.empty() == '' 478 | True 479 | 480 | >>> Str.not_empty() == 'hamster' 481 | True 482 | >>> Str.not_empty() == '' 483 | False 484 | 485 | """ 486 | 487 | 488 | class Model: 489 | """Special class for comparing the equality of attrs of another object 490 | 491 | Examples of functionality: 492 | 493 | >>> from collections import namedtuple 494 | >>> Foo = namedtuple('Foo', 'id,key,other_key,parent', defaults=(None,)*4) 495 | 496 | >>> Foo() == Model() 497 | True 498 | 499 | >>> Foo(key='value') == Model(key='value') 500 | True 501 | >>> Foo(key='value') == Model(key='not the value') 502 | False 503 | >>> Foo(key='value', other_key='other_value') == Model(key='value') 504 | True 505 | >>> [Foo(key='value', other_key='other_value')] == List.containing(Model(key='value')) 506 | True 507 | 508 | """ 509 | 510 | __slots__ = ('attrs',) 511 | 512 | def __init__(self, **attrs): 513 | self.attrs = attrs 514 | 515 | def __repr__(self): 516 | attrs_repr = ', '.join(f'{k}={v!r}' for k, v in self.attrs.items()) 517 | return f'{self.__class__.__name__}({attrs_repr})' 518 | 519 | def __eq__(self, other): 520 | try: 521 | assert_model_attrs(other, self.attrs) 522 | except AssertionError: 523 | return False 524 | else: 525 | return True 526 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | [[package]] 2 | name = "atomicwrites" 3 | version = "1.4.0" 4 | description = "Atomic file writes." 5 | category = "dev" 6 | optional = false 7 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 8 | 9 | [[package]] 10 | name = "attrs" 11 | version = "21.4.0" 12 | description = "Classes Without Boilerplate" 13 | category = "dev" 14 | optional = false 15 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 16 | 17 | [package.extras] 18 | dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit", "cloudpickle"] 19 | docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"] 20 | tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "cloudpickle"] 21 | tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "cloudpickle"] 22 | 23 | [[package]] 24 | name = "colorama" 25 | version = "0.4.4" 26 | description = "Cross-platform colored terminal text." 27 | category = "dev" 28 | optional = false 29 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 30 | 31 | [[package]] 32 | name = "distlib" 33 | version = "0.3.4" 34 | description = "Distribution utilities" 35 | category = "dev" 36 | optional = false 37 | python-versions = "*" 38 | 39 | [[package]] 40 | name = "filelock" 41 | version = "3.6.0" 42 | description = "A platform independent file lock." 43 | category = "dev" 44 | optional = false 45 | python-versions = ">=3.7" 46 | 47 | [package.extras] 48 | docs = ["furo (>=2021.8.17b43)", "sphinx (>=4.1)", "sphinx-autodoc-typehints (>=1.12)"] 49 | testing = ["covdefaults (>=1.2.0)", "coverage (>=4)", "pytest (>=4)", "pytest-cov", "pytest-timeout (>=1.4.2)"] 50 | 51 | [[package]] 52 | name = "icdiff" 53 | version = "2.0.5" 54 | description = "improved colored diff" 55 | category = "dev" 56 | optional = false 57 | python-versions = "*" 58 | 59 | [[package]] 60 | name = "importlib-metadata" 61 | version = "4.11.3" 62 | description = "Read metadata from Python packages" 63 | category = "dev" 64 | optional = false 65 | python-versions = ">=3.7" 66 | 67 | [package.dependencies] 68 | typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""} 69 | zipp = ">=0.5" 70 | 71 | [package.extras] 72 | docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)"] 73 | perf = ["ipython"] 74 | testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "packaging", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)", "importlib-resources (>=1.3)"] 75 | 76 | [[package]] 77 | name = "iniconfig" 78 | version = "1.1.1" 79 | description = "iniconfig: brain-dead simple config-ini parsing" 80 | category = "dev" 81 | optional = false 82 | python-versions = "*" 83 | 84 | [[package]] 85 | name = "packaging" 86 | version = "21.3" 87 | description = "Core utilities for Python packages" 88 | category = "dev" 89 | optional = false 90 | python-versions = ">=3.6" 91 | 92 | [package.dependencies] 93 | pyparsing = ">=2.0.2,<3.0.5 || >3.0.5" 94 | 95 | [[package]] 96 | name = "platformdirs" 97 | version = "2.5.1" 98 | description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." 99 | category = "dev" 100 | optional = false 101 | python-versions = ">=3.7" 102 | 103 | [package.extras] 104 | docs = ["Sphinx (>=4)", "furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx-autodoc-typehints (>=1.12)"] 105 | test = ["appdirs (==1.4.4)", "pytest (>=6)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)"] 106 | 107 | [[package]] 108 | name = "pluggy" 109 | version = "1.0.0" 110 | description = "plugin and hook calling mechanisms for python" 111 | category = "dev" 112 | optional = false 113 | python-versions = ">=3.6" 114 | 115 | [package.dependencies] 116 | importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} 117 | 118 | [package.extras] 119 | dev = ["pre-commit", "tox"] 120 | testing = ["pytest", "pytest-benchmark"] 121 | 122 | [[package]] 123 | name = "pprintpp" 124 | version = "0.4.0" 125 | description = "A drop-in replacement for pprint that's actually pretty" 126 | category = "dev" 127 | optional = false 128 | python-versions = "*" 129 | 130 | [[package]] 131 | name = "py" 132 | version = "1.11.0" 133 | description = "library with cross-python path, ini-parsing, io, code, log facilities" 134 | category = "dev" 135 | optional = false 136 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 137 | 138 | [[package]] 139 | name = "pyparsing" 140 | version = "3.0.8" 141 | description = "pyparsing module - Classes and methods to define and execute parsing grammars" 142 | category = "dev" 143 | optional = false 144 | python-versions = ">=3.6.8" 145 | 146 | [package.extras] 147 | diagrams = ["railroad-diagrams", "jinja2"] 148 | 149 | [[package]] 150 | name = "pytest" 151 | version = "6.2.5" 152 | description = "pytest: simple powerful testing with Python" 153 | category = "dev" 154 | optional = false 155 | python-versions = ">=3.6" 156 | 157 | [package.dependencies] 158 | atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} 159 | attrs = ">=19.2.0" 160 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 161 | importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} 162 | iniconfig = "*" 163 | packaging = "*" 164 | pluggy = ">=0.12,<2.0" 165 | py = ">=1.8.2" 166 | toml = "*" 167 | 168 | [package.extras] 169 | testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] 170 | 171 | [[package]] 172 | name = "pytest-icdiff" 173 | version = "0.5" 174 | description = "use icdiff for better error messages in pytest assertions" 175 | category = "dev" 176 | optional = false 177 | python-versions = ">=3.6" 178 | 179 | [package.dependencies] 180 | icdiff = "*" 181 | pprintpp = "*" 182 | pytest = "*" 183 | 184 | [[package]] 185 | name = "pytest-lambda" 186 | version = "1.2.5" 187 | description = "Define pytest fixtures with lambda functions." 188 | category = "dev" 189 | optional = false 190 | python-versions = ">=3.6,<4.0" 191 | 192 | [package.dependencies] 193 | pytest = ">=3.6,<7" 194 | wrapt = ">=1.11.0,<2.0.0" 195 | 196 | [[package]] 197 | name = "six" 198 | version = "1.16.0" 199 | description = "Python 2 and 3 compatibility utilities" 200 | category = "dev" 201 | optional = false 202 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" 203 | 204 | [[package]] 205 | name = "toml" 206 | version = "0.10.2" 207 | description = "Python Library for Tom's Obvious, Minimal Language" 208 | category = "dev" 209 | optional = false 210 | python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" 211 | 212 | [[package]] 213 | name = "tox" 214 | version = "3.25.0" 215 | description = "tox is a generic virtualenv management and test command line tool" 216 | category = "dev" 217 | optional = false 218 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" 219 | 220 | [package.dependencies] 221 | colorama = {version = ">=0.4.1", markers = "platform_system == \"Windows\""} 222 | filelock = ">=3.0.0" 223 | importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} 224 | packaging = ">=14" 225 | pluggy = ">=0.12.0" 226 | py = ">=1.4.17" 227 | six = ">=1.14.0" 228 | toml = ">=0.9.4" 229 | virtualenv = ">=16.0.0,<20.0.0 || >20.0.0,<20.0.1 || >20.0.1,<20.0.2 || >20.0.2,<20.0.3 || >20.0.3,<20.0.4 || >20.0.4,<20.0.5 || >20.0.5,<20.0.6 || >20.0.6,<20.0.7 || >20.0.7" 230 | 231 | [package.extras] 232 | docs = ["pygments-github-lexers (>=0.0.5)", "sphinx (>=2.0.0)", "sphinxcontrib-autoprogram (>=0.1.5)", "towncrier (>=18.5.0)"] 233 | testing = ["flaky (>=3.4.0)", "freezegun (>=0.3.11)", "pytest (>=4.0.0)", "pytest-cov (>=2.5.1)", "pytest-mock (>=1.10.0)", "pytest-randomly (>=1.0.0)", "psutil (>=5.6.1)", "pathlib2 (>=2.3.3)"] 234 | 235 | [[package]] 236 | name = "typing-extensions" 237 | version = "3.10.0.2" 238 | description = "Backported and Experimental Type Hints for Python 3.5+" 239 | category = "dev" 240 | optional = false 241 | python-versions = "*" 242 | 243 | [[package]] 244 | name = "virtualenv" 245 | version = "20.14.1" 246 | description = "Virtual Python Environment builder" 247 | category = "dev" 248 | optional = false 249 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" 250 | 251 | [package.dependencies] 252 | distlib = ">=0.3.1,<1" 253 | filelock = ">=3.2,<4" 254 | importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} 255 | platformdirs = ">=2,<3" 256 | six = ">=1.9.0,<2" 257 | 258 | [package.extras] 259 | docs = ["proselint (>=0.10.2)", "sphinx (>=3)", "sphinx-argparse (>=0.2.5)", "sphinx-rtd-theme (>=0.4.3)", "towncrier (>=21.3)"] 260 | testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)", "packaging (>=20.0)"] 261 | 262 | [[package]] 263 | name = "wrapt" 264 | version = "1.14.0" 265 | description = "Module for decorators, wrappers and monkey patching." 266 | category = "dev" 267 | optional = false 268 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" 269 | 270 | [[package]] 271 | name = "zipp" 272 | version = "3.8.0" 273 | description = "Backport of pathlib-compatible object wrapper for zip files" 274 | category = "dev" 275 | optional = false 276 | python-versions = ">=3.7" 277 | 278 | [package.extras] 279 | docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)"] 280 | testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)"] 281 | 282 | [metadata] 283 | lock-version = "1.1" 284 | python-versions = '^3.7, >= 3.7' 285 | content-hash = "c3ad7d27e5f9698adb04aa824338d6706d197999df786a6545b01dc4fc65f3b5" 286 | 287 | [metadata.files] 288 | atomicwrites = [ 289 | {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, 290 | {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, 291 | ] 292 | attrs = [ 293 | {file = "attrs-21.4.0-py2.py3-none-any.whl", hash = "sha256:2d27e3784d7a565d36ab851fe94887c5eccd6a463168875832a1be79c82828b4"}, 294 | {file = "attrs-21.4.0.tar.gz", hash = "sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd"}, 295 | ] 296 | colorama = [ 297 | {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, 298 | {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, 299 | ] 300 | distlib = [ 301 | {file = "distlib-0.3.4-py2.py3-none-any.whl", hash = "sha256:6564fe0a8f51e734df6333d08b8b94d4ea8ee6b99b5ed50613f731fd4089f34b"}, 302 | {file = "distlib-0.3.4.zip", hash = "sha256:e4b58818180336dc9c529bfb9a0b58728ffc09ad92027a3f30b7cd91e3458579"}, 303 | ] 304 | filelock = [ 305 | {file = "filelock-3.6.0-py3-none-any.whl", hash = "sha256:f8314284bfffbdcfa0ff3d7992b023d4c628ced6feb957351d4c48d059f56bc0"}, 306 | {file = "filelock-3.6.0.tar.gz", hash = "sha256:9cd540a9352e432c7246a48fe4e8712b10acb1df2ad1f30e8c070b82ae1fed85"}, 307 | ] 308 | icdiff = [ 309 | {file = "icdiff-2.0.5.tar.gz", hash = "sha256:35d24b728e48b7e0a12bdb69386d3bfc7eef4fe922d0ac1cd70d6e5c11630bae"}, 310 | ] 311 | importlib-metadata = [ 312 | {file = "importlib_metadata-4.11.3-py3-none-any.whl", hash = "sha256:1208431ca90a8cca1a6b8af391bb53c1a2db74e5d1cef6ddced95d4b2062edc6"}, 313 | {file = "importlib_metadata-4.11.3.tar.gz", hash = "sha256:ea4c597ebf37142f827b8f39299579e31685c31d3a438b59f469406afd0f2539"}, 314 | ] 315 | iniconfig = [ 316 | {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, 317 | {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, 318 | ] 319 | packaging = [ 320 | {file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"}, 321 | {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"}, 322 | ] 323 | platformdirs = [ 324 | {file = "platformdirs-2.5.1-py3-none-any.whl", hash = "sha256:bcae7cab893c2d310a711b70b24efb93334febe65f8de776ee320b517471e227"}, 325 | {file = "platformdirs-2.5.1.tar.gz", hash = "sha256:7535e70dfa32e84d4b34996ea99c5e432fa29a708d0f4e394bbcb2a8faa4f16d"}, 326 | ] 327 | pluggy = [ 328 | {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, 329 | {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, 330 | ] 331 | pprintpp = [ 332 | {file = "pprintpp-0.4.0-py2.py3-none-any.whl", hash = "sha256:b6b4dcdd0c0c0d75e4d7b2f21a9e933e5b2ce62b26e1a54537f9651ae5a5c01d"}, 333 | {file = "pprintpp-0.4.0.tar.gz", hash = "sha256:ea826108e2c7f49dc6d66c752973c3fc9749142a798d6b254e1e301cfdbc6403"}, 334 | ] 335 | py = [ 336 | {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, 337 | {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, 338 | ] 339 | pyparsing = [ 340 | {file = "pyparsing-3.0.8-py3-none-any.whl", hash = "sha256:ef7b523f6356f763771559412c0d7134753f037822dad1b16945b7b846f7ad06"}, 341 | {file = "pyparsing-3.0.8.tar.gz", hash = "sha256:7bf433498c016c4314268d95df76c81b842a4cb2b276fa3312cfb1e1d85f6954"}, 342 | ] 343 | pytest = [ 344 | {file = "pytest-6.2.5-py3-none-any.whl", hash = "sha256:7310f8d27bc79ced999e760ca304d69f6ba6c6649c0b60fb0e04a4a77cacc134"}, 345 | {file = "pytest-6.2.5.tar.gz", hash = "sha256:131b36680866a76e6781d13f101efb86cf674ebb9762eb70d3082b6f29889e89"}, 346 | ] 347 | pytest-icdiff = [ 348 | {file = "pytest-icdiff-0.5.tar.gz", hash = "sha256:3a14097f4385665cb04330e6ae09a3dd430375f717e94482af6944470ad5f100"}, 349 | ] 350 | pytest-lambda = [ 351 | {file = "pytest-lambda-1.2.5.tar.gz", hash = "sha256:1c7dd2a29bd303121fd03a79fe20854691b8a0dadc77931e31d60380ccb13104"}, 352 | {file = "pytest_lambda-1.2.5-py3-none-any.whl", hash = "sha256:37d485609fe0112a91d7b2d086ad02643941b72870f6d7efcea50244ec5575cd"}, 353 | ] 354 | six = [ 355 | {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, 356 | {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, 357 | ] 358 | toml = [ 359 | {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, 360 | {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, 361 | ] 362 | tox = [ 363 | {file = "tox-3.25.0-py2.py3-none-any.whl", hash = "sha256:0805727eb4d6b049de304977dfc9ce315a1938e6619c3ab9f38682bb04662a5a"}, 364 | {file = "tox-3.25.0.tar.gz", hash = "sha256:37888f3092aa4e9f835fc8cc6dadbaaa0782651c41ef359e3a5743fcb0308160"}, 365 | ] 366 | typing-extensions = [ 367 | {file = "typing_extensions-3.10.0.2-py2-none-any.whl", hash = "sha256:d8226d10bc02a29bcc81df19a26e56a9647f8b0a6d4a83924139f4a8b01f17b7"}, 368 | {file = "typing_extensions-3.10.0.2-py3-none-any.whl", hash = "sha256:f1d25edafde516b146ecd0613dabcc61409817af4766fbbcfb8d1ad4ec441a34"}, 369 | {file = "typing_extensions-3.10.0.2.tar.gz", hash = "sha256:49f75d16ff11f1cd258e1b988ccff82a3ca5570217d7ad8c5f48205dd99a677e"}, 370 | ] 371 | virtualenv = [ 372 | {file = "virtualenv-20.14.1-py2.py3-none-any.whl", hash = "sha256:e617f16e25b42eb4f6e74096b9c9e37713cf10bf30168fb4a739f3fa8f898a3a"}, 373 | {file = "virtualenv-20.14.1.tar.gz", hash = "sha256:ef589a79795589aada0c1c5b319486797c03b67ac3984c48c669c0e4f50df3a5"}, 374 | ] 375 | wrapt = [ 376 | {file = "wrapt-1.14.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:5a9a1889cc01ed2ed5f34574c90745fab1dd06ec2eee663e8ebeefe363e8efd7"}, 377 | {file = "wrapt-1.14.0-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:9a3ff5fb015f6feb78340143584d9f8a0b91b6293d6b5cf4295b3e95d179b88c"}, 378 | {file = "wrapt-1.14.0-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:4b847029e2d5e11fd536c9ac3136ddc3f54bc9488a75ef7d040a3900406a91eb"}, 379 | {file = "wrapt-1.14.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:9a5a544861b21e0e7575b6023adebe7a8c6321127bb1d238eb40d99803a0e8bd"}, 380 | {file = "wrapt-1.14.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:88236b90dda77f0394f878324cfbae05ae6fde8a84d548cfe73a75278d760291"}, 381 | {file = "wrapt-1.14.0-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:f0408e2dbad9e82b4c960274214af533f856a199c9274bd4aff55d4634dedc33"}, 382 | {file = "wrapt-1.14.0-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:9d8c68c4145041b4eeae96239802cfdfd9ef927754a5be3f50505f09f309d8c6"}, 383 | {file = "wrapt-1.14.0-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:22626dca56fd7f55a0733e604f1027277eb0f4f3d95ff28f15d27ac25a45f71b"}, 384 | {file = "wrapt-1.14.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:65bf3eb34721bf18b5a021a1ad7aa05947a1767d1aa272b725728014475ea7d5"}, 385 | {file = "wrapt-1.14.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:09d16ae7a13cff43660155383a2372b4aa09109c7127aa3f24c3cf99b891c330"}, 386 | {file = "wrapt-1.14.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:debaf04f813ada978d7d16c7dfa16f3c9c2ec9adf4656efdc4defdf841fc2f0c"}, 387 | {file = "wrapt-1.14.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:748df39ed634851350efa87690c2237a678ed794fe9ede3f0d79f071ee042561"}, 388 | {file = "wrapt-1.14.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1807054aa7b61ad8d8103b3b30c9764de2e9d0c0978e9d3fc337e4e74bf25faa"}, 389 | {file = "wrapt-1.14.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:763a73ab377390e2af26042f685a26787c402390f682443727b847e9496e4a2a"}, 390 | {file = "wrapt-1.14.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:8529b07b49b2d89d6917cfa157d3ea1dfb4d319d51e23030664a827fe5fd2131"}, 391 | {file = "wrapt-1.14.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:68aeefac31c1f73949662ba8affaf9950b9938b712fb9d428fa2a07e40ee57f8"}, 392 | {file = "wrapt-1.14.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:59d7d92cee84a547d91267f0fea381c363121d70fe90b12cd88241bd9b0e1763"}, 393 | {file = "wrapt-1.14.0-cp310-cp310-win32.whl", hash = "sha256:3a88254881e8a8c4784ecc9cb2249ff757fd94b911d5df9a5984961b96113fff"}, 394 | {file = "wrapt-1.14.0-cp310-cp310-win_amd64.whl", hash = "sha256:9a242871b3d8eecc56d350e5e03ea1854de47b17f040446da0e47dc3e0b9ad4d"}, 395 | {file = "wrapt-1.14.0-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:a65bffd24409454b889af33b6c49d0d9bcd1a219b972fba975ac935f17bdf627"}, 396 | {file = "wrapt-1.14.0-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:9d9fcd06c952efa4b6b95f3d788a819b7f33d11bea377be6b8980c95e7d10775"}, 397 | {file = "wrapt-1.14.0-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:db6a0ddc1282ceb9032e41853e659c9b638789be38e5b8ad7498caac00231c23"}, 398 | {file = "wrapt-1.14.0-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:14e7e2c5f5fca67e9a6d5f753d21f138398cad2b1159913ec9e9a67745f09ba3"}, 399 | {file = "wrapt-1.14.0-cp35-cp35m-win32.whl", hash = "sha256:6d9810d4f697d58fd66039ab959e6d37e63ab377008ef1d63904df25956c7db0"}, 400 | {file = "wrapt-1.14.0-cp35-cp35m-win_amd64.whl", hash = "sha256:d808a5a5411982a09fef6b49aac62986274ab050e9d3e9817ad65b2791ed1425"}, 401 | {file = "wrapt-1.14.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:b77159d9862374da213f741af0c361720200ab7ad21b9f12556e0eb95912cd48"}, 402 | {file = "wrapt-1.14.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:36a76a7527df8583112b24adc01748cd51a2d14e905b337a6fefa8b96fc708fb"}, 403 | {file = "wrapt-1.14.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a0057b5435a65b933cbf5d859cd4956624df37b8bf0917c71756e4b3d9958b9e"}, 404 | {file = "wrapt-1.14.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a0a4ca02752ced5f37498827e49c414d694ad7cf451ee850e3ff160f2bee9d3"}, 405 | {file = "wrapt-1.14.0-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:8c6be72eac3c14baa473620e04f74186c5d8f45d80f8f2b4eda6e1d18af808e8"}, 406 | {file = "wrapt-1.14.0-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:21b1106bff6ece8cb203ef45b4f5778d7226c941c83aaaa1e1f0f4f32cc148cd"}, 407 | {file = "wrapt-1.14.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:493da1f8b1bb8a623c16552fb4a1e164c0200447eb83d3f68b44315ead3f9036"}, 408 | {file = "wrapt-1.14.0-cp36-cp36m-win32.whl", hash = "sha256:89ba3d548ee1e6291a20f3c7380c92f71e358ce8b9e48161401e087e0bc740f8"}, 409 | {file = "wrapt-1.14.0-cp36-cp36m-win_amd64.whl", hash = "sha256:729d5e96566f44fccac6c4447ec2332636b4fe273f03da128fff8d5559782b06"}, 410 | {file = "wrapt-1.14.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:891c353e95bb11abb548ca95c8b98050f3620a7378332eb90d6acdef35b401d4"}, 411 | {file = "wrapt-1.14.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23f96134a3aa24cc50614920cc087e22f87439053d886e474638c68c8d15dc80"}, 412 | {file = "wrapt-1.14.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6807bcee549a8cb2f38f73f469703a1d8d5d990815c3004f21ddb68a567385ce"}, 413 | {file = "wrapt-1.14.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6915682f9a9bc4cf2908e83caf5895a685da1fbd20b6d485dafb8e218a338279"}, 414 | {file = "wrapt-1.14.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:f2f3bc7cd9c9fcd39143f11342eb5963317bd54ecc98e3650ca22704b69d9653"}, 415 | {file = "wrapt-1.14.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:3a71dbd792cc7a3d772ef8cd08d3048593f13d6f40a11f3427c000cf0a5b36a0"}, 416 | {file = "wrapt-1.14.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:5a0898a640559dec00f3614ffb11d97a2666ee9a2a6bad1259c9facd01a1d4d9"}, 417 | {file = "wrapt-1.14.0-cp37-cp37m-win32.whl", hash = "sha256:167e4793dc987f77fd476862d32fa404d42b71f6a85d3b38cbce711dba5e6b68"}, 418 | {file = "wrapt-1.14.0-cp37-cp37m-win_amd64.whl", hash = "sha256:d066ffc5ed0be00cd0352c95800a519cf9e4b5dd34a028d301bdc7177c72daf3"}, 419 | {file = "wrapt-1.14.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d9bdfa74d369256e4218000a629978590fd7cb6cf6893251dad13d051090436d"}, 420 | {file = "wrapt-1.14.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:2498762814dd7dd2a1d0248eda2afbc3dd9c11537bc8200a4b21789b6df6cd38"}, 421 | {file = "wrapt-1.14.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f24ca7953f2643d59a9c87d6e272d8adddd4a53bb62b9208f36db408d7aafc7"}, 422 | {file = "wrapt-1.14.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5b835b86bd5a1bdbe257d610eecab07bf685b1af2a7563093e0e69180c1d4af1"}, 423 | {file = "wrapt-1.14.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b21650fa6907e523869e0396c5bd591cc326e5c1dd594dcdccac089561cacfb8"}, 424 | {file = "wrapt-1.14.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:354d9fc6b1e44750e2a67b4b108841f5f5ea08853453ecbf44c81fdc2e0d50bd"}, 425 | {file = "wrapt-1.14.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:1f83e9c21cd5275991076b2ba1cd35418af3504667affb4745b48937e214bafe"}, 426 | {file = "wrapt-1.14.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:61e1a064906ccba038aa3c4a5a82f6199749efbbb3cef0804ae5c37f550eded0"}, 427 | {file = "wrapt-1.14.0-cp38-cp38-win32.whl", hash = "sha256:28c659878f684365d53cf59dc9a1929ea2eecd7ac65da762be8b1ba193f7e84f"}, 428 | {file = "wrapt-1.14.0-cp38-cp38-win_amd64.whl", hash = "sha256:b0ed6ad6c9640671689c2dbe6244680fe8b897c08fd1fab2228429b66c518e5e"}, 429 | {file = "wrapt-1.14.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b3f7e671fb19734c872566e57ce7fc235fa953d7c181bb4ef138e17d607dc8a1"}, 430 | {file = "wrapt-1.14.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:87fa943e8bbe40c8c1ba4086971a6fefbf75e9991217c55ed1bcb2f1985bd3d4"}, 431 | {file = "wrapt-1.14.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4775a574e9d84e0212f5b18886cace049a42e13e12009bb0491562a48bb2b758"}, 432 | {file = "wrapt-1.14.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9d57677238a0c5411c76097b8b93bdebb02eb845814c90f0b01727527a179e4d"}, 433 | {file = "wrapt-1.14.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00108411e0f34c52ce16f81f1d308a571df7784932cc7491d1e94be2ee93374b"}, 434 | {file = "wrapt-1.14.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d332eecf307fca852d02b63f35a7872de32d5ba8b4ec32da82f45df986b39ff6"}, 435 | {file = "wrapt-1.14.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:01f799def9b96a8ec1ef6b9c1bbaf2bbc859b87545efbecc4a78faea13d0e3a0"}, 436 | {file = "wrapt-1.14.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:47045ed35481e857918ae78b54891fac0c1d197f22c95778e66302668309336c"}, 437 | {file = "wrapt-1.14.0-cp39-cp39-win32.whl", hash = "sha256:2eca15d6b947cfff51ed76b2d60fd172c6ecd418ddab1c5126032d27f74bc350"}, 438 | {file = "wrapt-1.14.0-cp39-cp39-win_amd64.whl", hash = "sha256:bb36fbb48b22985d13a6b496ea5fb9bb2a076fea943831643836c9f6febbcfdc"}, 439 | {file = "wrapt-1.14.0.tar.gz", hash = "sha256:8323a43bd9c91f62bb7d4be74cc9ff10090e7ef820e27bfe8815c57e68261311"}, 440 | ] 441 | zipp = [ 442 | {file = "zipp-3.8.0-py3-none-any.whl", hash = "sha256:c4f6e5bbf48e74f7a38e7cc5b0480ff42b0ae5178957d564d18932525d5cf099"}, 443 | {file = "zipp-3.8.0.tar.gz", hash = "sha256:56bf8aadb83c24db6c4b577e13de374ccfb67da2078beba1d037c17980bf43ad"}, 444 | ] 445 | --------------------------------------------------------------------------------