├── .codecov.yml ├── .github ├── FUNDING.yml └── workflows │ ├── ci.yml │ └── upload-previews.yml ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── Makefile ├── README.md ├── dirty_equals ├── __init__.py ├── _base.py ├── _boolean.py ├── _datetime.py ├── _dict.py ├── _inspection.py ├── _numeric.py ├── _other.py ├── _sequence.py ├── _strings.py ├── _utils.py ├── py.typed └── version.py ├── docs ├── CNAME ├── img │ ├── dirty-equals-logo-base.svg │ ├── dirty-equals-logo-favicon.svg │ ├── favicon.png │ ├── logo-text.svg │ └── logo-white.svg ├── index.md ├── internals.md ├── plugins.py ├── types │ ├── boolean.md │ ├── custom.md │ ├── datetime.md │ ├── dict.md │ ├── inspection.md │ ├── numeric.md │ ├── other.md │ ├── sequence.md │ └── string.md └── usage.md ├── mkdocs.yml ├── pyproject.toml ├── requirements ├── all.txt ├── docs.in ├── docs.txt ├── linting.in ├── linting.txt ├── pyproject.txt ├── tests.in └── tests.txt └── tests ├── __init__.py ├── mypy_checks.py ├── test_base.py ├── test_boolean.py ├── test_datetime.py ├── test_dict.py ├── test_docs.py ├── test_inspection.py ├── test_list_tuple.py ├── test_numeric.py ├── test_other.py └── test_strings.py /.codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | precision: 2 3 | range: [90, 100] 4 | status: 5 | patch: false 6 | project: false 7 | 8 | comment: 9 | layout: 'header, diff, flags, files, footer' 10 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: samuelcolvin 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | tags: 8 | - '**' 9 | pull_request: 10 | types: [opened, synchronize] 11 | 12 | jobs: 13 | test: 14 | name: test ${{ matrix.python-version }} on ${{ matrix.os }} 15 | strategy: 16 | fail-fast: false 17 | matrix: 18 | os: [ubuntu, macos] 19 | python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13'] 20 | # test pypy on ubuntu only to speed up CI, no reason why macos X pypy should fail separately 21 | include: 22 | - os: 'ubuntu' 23 | python-version: 'pypy-3.9' 24 | - os: 'ubuntu' 25 | python-version: 'pypy-3.10' 26 | 27 | runs-on: ${{ matrix.os }}-latest 28 | 29 | env: 30 | PYTHON: ${{ matrix.python-version }} 31 | OS: ${{ matrix.os }} 32 | 33 | steps: 34 | - uses: actions/checkout@v4 35 | 36 | - name: set up python 37 | uses: actions/setup-python@v5 38 | with: 39 | python-version: ${{ matrix.python-version }} 40 | allow-prereleases: true 41 | 42 | - run: pip install -r requirements/tests.txt -r requirements/pyproject.txt 43 | 44 | - run: make test 45 | 46 | - run: coverage xml 47 | 48 | - uses: codecov/codecov-action@v3 49 | with: 50 | file: ./coverage.xml 51 | env_vars: PYTHON,OS 52 | 53 | lint: 54 | runs-on: ubuntu-latest 55 | 56 | steps: 57 | - uses: actions/checkout@v4 58 | 59 | - uses: actions/setup-python@v5 60 | with: 61 | python-version: '3.12' 62 | 63 | - run: pip install -r requirements/linting.txt 64 | 65 | - uses: pre-commit/action@v3.0.0 66 | with: 67 | extra_args: --all-files 68 | env: 69 | SKIP: no-commit-to-branch 70 | 71 | docs: 72 | runs-on: ubuntu-latest 73 | steps: 74 | - uses: actions/checkout@v4 75 | 76 | - name: set up python 77 | uses: actions/setup-python@v5 78 | with: 79 | python-version: '3.12' 80 | 81 | - name: install 82 | run: pip install -r requirements/docs.txt 83 | 84 | - name: install mkdocs-material-insiders 85 | if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/') 86 | run: pip install https://files.scolvin.com/${MKDOCS_TOKEN}/mkdocs_material-9.4.2+insiders.4.42.0-py3-none-any.whl 87 | env: 88 | MKDOCS_TOKEN: ${{ secrets.mkdocs_token }} 89 | 90 | - name: build site 91 | run: mkdocs build --strict 92 | 93 | - name: store docs site 94 | uses: actions/upload-artifact@v3 95 | with: 96 | name: docs 97 | path: site 98 | 99 | check: # This job does nothing and is only used for the branch protection 100 | if: always() 101 | needs: [lint, test, docs] 102 | runs-on: ubuntu-latest 103 | 104 | steps: 105 | - name: Decide whether the needed jobs succeeded or failed 106 | uses: re-actors/alls-green@release/v1 107 | id: all-green 108 | with: 109 | jobs: ${{ toJSON(needs) }} 110 | 111 | publish_docs: 112 | needs: [check] 113 | if: "success() && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/'))" 114 | runs-on: ubuntu-latest 115 | 116 | steps: 117 | - name: checkout docs-site 118 | uses: actions/checkout@v4 119 | with: 120 | ref: docs-site 121 | 122 | - uses: actions/checkout@v4 123 | 124 | - name: set up python 125 | uses: actions/setup-python@v5 126 | with: 127 | python-version: '3.12' 128 | 129 | - name: install 130 | run: pip install -r requirements/docs.txt 131 | 132 | - name: install mkdocs-material-insiders 133 | run: pip install https://files.scolvin.com/${MKDOCS_TOKEN}/mkdocs_material-9.4.2+insiders.4.42.0-py3-none-any.whl 134 | env: 135 | MKDOCS_TOKEN: ${{ secrets.mkdocs_token }} 136 | 137 | - name: Set git credentials 138 | run: | 139 | git config --global user.name "${{ github.actor }}" 140 | git config --global user.email "${{ github.actor }}@users.noreply.github.com" 141 | 142 | - run: mike deploy -b docs-site dev --push 143 | if: github.ref == 'refs/heads/main' 144 | 145 | - name: check version 146 | if: "startsWith(github.ref, 'refs/tags/')" 147 | id: check-version 148 | uses: samuelcolvin/check-python-version@v3.2 149 | with: 150 | version_file_path: 'dirty_equals/version.py' 151 | 152 | - run: mike deploy -b docs-site ${{ steps.check-version.outputs.VERSION_MAJOR_MINOR }} latest --update-aliases --push 153 | if: "startsWith(github.ref, 'refs/tags/') && !fromJSON(steps.check-version.outputs.IS_PRERELEASE)" 154 | 155 | deploy: 156 | needs: [check] 157 | if: "success() && startsWith(github.ref, 'refs/tags/')" 158 | runs-on: ubuntu-latest 159 | environment: release 160 | 161 | permissions: 162 | id-token: write 163 | 164 | steps: 165 | - uses: actions/checkout@v4 166 | 167 | - name: set up python 168 | uses: actions/setup-python@v5 169 | with: 170 | python-version: '3.12' 171 | 172 | - name: install 173 | run: pip install -U build 174 | 175 | - name: check version 176 | id: check-version 177 | uses: samuelcolvin/check-python-version@v3.2 178 | with: 179 | version_file_path: 'dirty_equals/version.py' 180 | 181 | - name: build 182 | run: python -m build 183 | 184 | - name: Upload package to PyPI 185 | uses: pypa/gh-action-pypi-publish@release/v1 186 | -------------------------------------------------------------------------------- /.github/workflows/upload-previews.yml: -------------------------------------------------------------------------------- 1 | name: Upload Previews 2 | 3 | on: 4 | workflow_run: 5 | workflows: [CI] 6 | types: [completed] 7 | 8 | permissions: 9 | statuses: write 10 | 11 | jobs: 12 | upload-previews: 13 | if: ${{ github.event.workflow_run.conclusion == 'success' }} 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/setup-python@v1 18 | with: 19 | python-version: '3.10' 20 | 21 | - run: pip install click==8.0.4 22 | - run: pip install smokeshow 23 | 24 | - uses: dawidd6/action-download-artifact@v2 25 | with: 26 | workflow: ci.yml 27 | commit: ${{ github.event.workflow_run.head_sha }} 28 | 29 | - run: smokeshow upload docs 30 | env: 31 | SMOKESHOW_GITHUB_STATUS_DESCRIPTION: Docs Preview 32 | SMOKESHOW_GITHUB_CONTEXT: docs 33 | SMOKESHOW_GITHUB_TOKEN: ${{ secrets.github_token }} 34 | SMOKESHOW_GITHUB_PR_HEAD_SHA: ${{ github.event.workflow_run.head_sha }} 35 | SMOKESHOW_AUTH_KEY: ${{ secrets.smokeshow_auth_key }} 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | .idea/ 3 | env/ 4 | env*/ 5 | .coverage 6 | .cache/ 7 | htmlcov/ 8 | media/ 9 | sandbox/ 10 | .pytest_cache/ 11 | *.egg-info/ 12 | /build/ 13 | /dist/ 14 | npm-debug.log* 15 | yarn-debug.log* 16 | yarn-error.log* 17 | /TODO.md 18 | /.mypy_cache/ 19 | /.ruff_cache/ 20 | /scratch/ 21 | /site/ 22 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.0.1 4 | hooks: 5 | - id: no-commit-to-branch 6 | - id: check-yaml 7 | args: ['--unsafe'] 8 | - id: check-toml 9 | - id: end-of-file-fixer 10 | - id: trailing-whitespace 11 | - id: check-added-large-files 12 | 13 | - repo: local 14 | hooks: 15 | - id: format 16 | name: Format 17 | entry: make format 18 | types: [python] 19 | language: system 20 | pass_filenames: false 21 | - id: lint 22 | name: Lint 23 | entry: make lint 24 | types: [python] 25 | language: system 26 | pass_filenames: false 27 | - id: mypy 28 | name: Mypy 29 | entry: make mypy 30 | types: [python] 31 | language: system 32 | pass_filenames: false 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022 Samuel Colvin 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 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .DEFAULT_GOAL := all 2 | sources = dirty_equals tests 3 | 4 | .PHONY: install 5 | install: 6 | pip install -U pip pre-commit pip-tools 7 | pip install -r requirements/all.txt 8 | pre-commit install 9 | 10 | .PHONY: refresh-lockfiles 11 | refresh-lockfiles: 12 | @echo "Replacing requirements/*.txt files using pip-compile" 13 | find requirements/ -name '*.txt' ! -name 'all.txt' -type f -delete 14 | make update-lockfiles 15 | 16 | .PHONY: update-lockfiles 17 | update-lockfiles: 18 | @echo "Updating requirements/*.txt files using pip-compile" 19 | pip-compile -q -o requirements/linting.txt requirements/linting.in 20 | pip-compile -q -o requirements/tests.txt -c requirements/linting.txt requirements/tests.in 21 | pip-compile -q -o requirements/docs.txt -c requirements/linting.txt -c requirements/tests.txt requirements/docs.in 22 | pip-compile -q -o requirements/pyproject.txt \ 23 | --extra pydantic \ 24 | -c requirements/linting.txt -c requirements/tests.txt -c requirements/docs.txt \ 25 | pyproject.toml 26 | pip install --dry-run -r requirements/all.txt 27 | 28 | .PHONY: format 29 | format: 30 | ruff check --fix-only $(sources) 31 | ruff format $(sources) 32 | 33 | .PHONY: lint 34 | lint: 35 | ruff check $(sources) 36 | ruff format --check $(sources) 37 | 38 | .PHONY: test 39 | test: 40 | TZ=utc coverage run -m pytest 41 | python tests/mypy_checks.py 42 | 43 | .PHONY: testcov 44 | testcov: test 45 | @coverage report --show-missing 46 | @coverage html 47 | 48 | .PHONY: mypy 49 | mypy: 50 | mypy dirty_equals tests/mypy_checks.py 51 | 52 | .PHONY: docs 53 | docs: 54 | mkdocs build --strict 55 | 56 | .PHONY: all 57 | all: lint mypy testcov docs 58 | 59 | .PHONY: clean 60 | clean: 61 | rm -rf `find . -name __pycache__` 62 | rm -f `find . -type f -name '*.py[co]' ` 63 | rm -f `find . -type f -name '*~' ` 64 | rm -f `find . -type f -name '.*~' ` 65 | rm -rf .cache 66 | rm -rf .pytest_cache 67 | rm -rf .mypy_cache 68 | rm -rf htmlcov 69 | rm -rf *.egg-info 70 | rm -f .coverage 71 | rm -f .coverage.* 72 | rm -rf build 73 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | dirty-equals 4 | 5 |

6 |

7 | Doing dirty (but extremely useful) things with equals. 8 |

9 |

10 | 11 | CI 12 | 13 | 14 | Coverage 15 | 16 | 17 | pypi 18 | 19 | 20 | versions 21 | 22 | 23 | license 24 | 25 |

26 | 27 | --- 28 | 29 | **Documentation**: [dirty-equals.helpmanual.io](https://dirty-equals.helpmanual.io) 30 | 31 | **Source Code**: [github.com/samuelcolvin/dirty-equals](https://github.com/samuelcolvin/dirty-equals) 32 | 33 | --- 34 | 35 | **dirty-equals** is a python library that (mis)uses the `__eq__` method to make python code (generally unit tests) 36 | more declarative and therefore easier to read and write. 37 | 38 | *dirty-equals* can be used in whatever context you like, but it comes into its own when writing unit tests for 39 | applications where you're commonly checking the response to API calls and the contents of a database. 40 | 41 | ## Usage 42 | 43 | Here's a trivial example of what *dirty-equals* can do: 44 | 45 | ```py 46 | from dirty_equals import IsPositive 47 | 48 | assert 1 == IsPositive 49 | assert -2 == IsPositive # this will fail! 50 | ``` 51 | 52 | **That doesn't look very useful yet!**, but consider the following unit test code using *dirty-equals*: 53 | 54 | ```py title="More Powerful Usage" 55 | from dirty_equals import IsJson, IsNow, IsPositiveInt, IsStr 56 | 57 | ... 58 | 59 | # user_data is a dict returned from a database or API which we want to test 60 | assert user_data == { 61 | # we want to check that id is a positive int 62 | 'id': IsPositiveInt, 63 | # we know avatar_file should be a string, but we need a regex as we don't know whole value 64 | 'avatar_file': IsStr(regex=r'/[a-z0-9\-]{10}/example\.png'), 65 | # settings_json is JSON, but it's more robust to compare the value it encodes, not strings 66 | 'settings_json': IsJson({'theme': 'dark', 'language': 'en'}), 67 | # created_ts is datetime, we don't know the exact value, but we know it should be close to now 68 | 'created_ts': IsNow(delta=3), 69 | } 70 | ``` 71 | 72 | Without *dirty-equals*, you'd have to compare individual fields and/or modify some fields before comparison - 73 | the test would not be declarative or as clear. 74 | 75 | *dirty-equals* can do so much more than that, for example: 76 | 77 | * [`IsPartialDict`](https://dirty-equals.helpmanual.io/types/dict/#dirty_equals.IsPartialDict) 78 | lets you compare a subset of a dictionary 79 | * [`IsStrictDict`](https://dirty-equals.helpmanual.io/types/dict/#dirty_equals.IsStrictDict) 80 | lets you confirm order in a dictionary 81 | * [`IsList`](https://dirty-equals.helpmanual.io/types/sequence/#dirty_equals.IsList) and 82 | [`IsTuple`](https://dirty-equals.helpmanual.io/types/sequence/#dirty_equals.IsTuple) 83 | lets you compare partial lists and tuples, with or without order constraints 84 | * nesting any of these types inside any others 85 | * [`IsInstance`](https://dirty-equals.helpmanual.io/types/other/#dirty_equals.IsInstance) 86 | lets you simply confirm the type of an object 87 | * You can even use [boolean operators](https://dirty-equals.helpmanual.io/usage/#boolean-logic) 88 | `|` and `&` to combine multiple conditions 89 | * and much more... 90 | 91 | ## Installation 92 | 93 | Simply: 94 | 95 | ```bash 96 | pip install dirty-equals 97 | ``` 98 | 99 | **dirty-equals** requires **Python 3.8+**. 100 | -------------------------------------------------------------------------------- /dirty_equals/__init__.py: -------------------------------------------------------------------------------- 1 | from ._base import AnyThing, DirtyEquals, IsOneOf 2 | from ._boolean import IsFalseLike, IsTrueLike 3 | from ._datetime import IsDate, IsDatetime, IsNow, IsToday 4 | from ._dict import IsDict, IsIgnoreDict, IsPartialDict, IsStrictDict 5 | from ._inspection import HasAttributes, HasName, HasRepr, IsInstance 6 | from ._numeric import ( 7 | IsApprox, 8 | IsFloat, 9 | IsFloatInf, 10 | IsFloatInfNeg, 11 | IsFloatInfPos, 12 | IsFloatNan, 13 | IsInt, 14 | IsNegative, 15 | IsNegativeFloat, 16 | IsNegativeInt, 17 | IsNonNegative, 18 | IsNonPositive, 19 | IsNumber, 20 | IsNumeric, 21 | IsPositive, 22 | IsPositiveFloat, 23 | IsPositiveInt, 24 | ) 25 | from ._other import ( 26 | FunctionCheck, 27 | IsDataclass, 28 | IsDataclassType, 29 | IsEnum, 30 | IsHash, 31 | IsIP, 32 | IsJson, 33 | IsPartialDataclass, 34 | IsStrictDataclass, 35 | IsUrl, 36 | IsUUID, 37 | ) 38 | from ._sequence import Contains, HasLen, IsList, IsListOrTuple, IsTuple 39 | from ._strings import IsAnyStr, IsBytes, IsStr 40 | from .version import VERSION 41 | 42 | __all__ = ( 43 | # base 44 | 'DirtyEquals', 45 | 'AnyThing', 46 | 'IsOneOf', 47 | # boolean 48 | 'IsTrueLike', 49 | 'IsFalseLike', 50 | # dataclass 51 | 'IsDataclass', 52 | 'IsDataclassType', 53 | 'IsPartialDataclass', 54 | 'IsStrictDataclass', 55 | # datetime 56 | 'IsDatetime', 57 | 'IsNow', 58 | 'IsDate', 59 | 'IsToday', 60 | # dict 61 | 'IsDict', 62 | 'IsPartialDict', 63 | 'IsIgnoreDict', 64 | 'IsStrictDict', 65 | # enum 66 | 'IsEnum', 67 | # sequence 68 | 'Contains', 69 | 'HasLen', 70 | 'IsList', 71 | 'IsTuple', 72 | 'IsListOrTuple', 73 | # numeric 74 | 'IsNumeric', 75 | 'IsApprox', 76 | 'IsNumber', 77 | 'IsPositive', 78 | 'IsNegative', 79 | 'IsNonPositive', 80 | 'IsNonNegative', 81 | 'IsInt', 82 | 'IsPositiveInt', 83 | 'IsNegativeInt', 84 | 'IsFloat', 85 | 'IsPositiveFloat', 86 | 'IsNegativeFloat', 87 | 'IsFloatInf', 88 | 'IsFloatInfNeg', 89 | 'IsFloatInfPos', 90 | 'IsFloatNan', 91 | # inspection 92 | 'HasAttributes', 93 | 'HasName', 94 | 'HasRepr', 95 | 'IsInstance', 96 | # other 97 | 'FunctionCheck', 98 | 'IsJson', 99 | 'IsUUID', 100 | 'IsUrl', 101 | 'IsHash', 102 | 'IsIP', 103 | # strings 104 | 'IsStr', 105 | 'IsBytes', 106 | 'IsAnyStr', 107 | # version 108 | '__version__', 109 | ) 110 | 111 | __version__ = VERSION 112 | -------------------------------------------------------------------------------- /dirty_equals/_base.py: -------------------------------------------------------------------------------- 1 | import io 2 | from abc import ABCMeta 3 | from pprint import PrettyPrinter 4 | from typing import TYPE_CHECKING, Any, Dict, Generic, Iterable, Optional, Protocol, Tuple, TypeVar 5 | 6 | from ._utils import Omit 7 | 8 | if TYPE_CHECKING: 9 | from typing import TypeAlias, Union # noqa: F401 10 | 11 | __all__ = 'DirtyEqualsMeta', 'DirtyEquals', 'AnyThing', 'IsOneOf' 12 | 13 | 14 | class DirtyEqualsMeta(ABCMeta): 15 | def __eq__(self, other: Any) -> bool: 16 | if self is other: 17 | return True 18 | 19 | # this is required as fancy things happen when creating generics which include equals checks, without it, 20 | # we get some recursive errors 21 | if self is DirtyEquals or other is Generic or other is Protocol: 22 | return False 23 | else: 24 | try: 25 | return self() == other 26 | except TypeError: 27 | # we don't want to raise a type error here since somewhere deep in pytest it does something like 28 | # type(a) == type(b), if we raised TypeError we would upset the pytest error message 29 | return False 30 | 31 | def __or__(self, other: Any) -> 'DirtyOr': # type: ignore[override] 32 | return DirtyOr(self, other) 33 | 34 | def __and__(self, other: Any) -> 'DirtyAnd': 35 | return DirtyAnd(self, other) 36 | 37 | def __invert__(self) -> 'DirtyNot': 38 | return DirtyNot(self) 39 | 40 | def __hash__(self) -> int: 41 | return hash(self.__name__) 42 | 43 | def __repr__(self) -> str: 44 | return self.__name__ 45 | 46 | 47 | T = TypeVar('T') 48 | 49 | 50 | class DirtyEquals(Generic[T], metaclass=DirtyEqualsMeta): 51 | """ 52 | Base type for all *dirty-equals* types. 53 | """ 54 | 55 | __slots__ = '_other', '_was_equal', '_repr_args', '_repr_kwargs' 56 | 57 | def __init__(self, *repr_args: Any, **repr_kwargs: Any): 58 | """ 59 | Args: 60 | *repr_args: unnamed args to be used in `__repr__` 61 | **repr_kwargs: named args to be used in `__repr__` 62 | """ 63 | self._other: Any = None 64 | self._was_equal: Optional[bool] = None 65 | self._repr_args: Iterable[Any] = repr_args 66 | self._repr_kwargs: Dict[str, Any] = repr_kwargs 67 | 68 | def equals(self, other: Any) -> bool: 69 | """ 70 | Abstract method, must be implemented by subclasses. 71 | 72 | `TypeError` and `ValueError` are caught in `__eq__` and indicate `other` is not equals to this type. 73 | """ 74 | raise NotImplementedError() 75 | 76 | @property 77 | def value(self) -> T: 78 | """ 79 | Property to get the value last successfully compared to this object. 80 | 81 | This is seldom very useful, put it's provided for completeness. 82 | 83 | Example of usage: 84 | 85 | ```py title=".values" 86 | from dirty_equals import IsStr 87 | 88 | token_is_str = IsStr(regex=r't-.+') 89 | assert 't-123' == token_is_str 90 | 91 | print(token_is_str.value) 92 | #> t-123 93 | ``` 94 | """ 95 | if self._was_equal: 96 | return self._other 97 | else: 98 | raise AttributeError('value is not available until __eq__ has been called') 99 | 100 | def __eq__(self, other: Any) -> bool: 101 | self._other = other 102 | try: 103 | self._was_equal = self.equals(other) 104 | except (TypeError, ValueError): 105 | self._was_equal = False 106 | 107 | return self._was_equal 108 | 109 | def __ne__(self, other: Any) -> bool: 110 | # We don't set _was_equal to avoid strange errors in pytest 111 | self._other = other 112 | try: 113 | return not self.equals(other) 114 | except (TypeError, ValueError): 115 | return True 116 | 117 | def __or__(self, other: Any) -> 'DirtyOr': 118 | return DirtyOr(self, other) 119 | 120 | def __and__(self, other: Any) -> 'DirtyAnd': 121 | return DirtyAnd(self, other) 122 | 123 | def __invert__(self) -> 'DirtyNot': 124 | return DirtyNot(self) 125 | 126 | def _repr_ne(self) -> str: 127 | args = [repr(arg) for arg in self._repr_args if arg is not Omit] 128 | args += [f'{k}={v!r}' for k, v in self._repr_kwargs.items() if v is not Omit] 129 | return f'{self.__class__.__name__}({", ".join(args)})' 130 | 131 | def __repr__(self) -> str: 132 | if self._was_equal: 133 | # if we've got the correct value return it to aid in diffs 134 | return repr(self._other) 135 | else: 136 | # else return something which explains what's going on. 137 | return self._repr_ne() 138 | 139 | def _pprint_format(self, pprinter: PrettyPrinter, stream: io.StringIO, *args: Any, **kwargs: Any) -> None: 140 | # pytest diffs use pprint to format objects, so we patch pprint to call this method 141 | # for DirtyEquals objects. So this method needs to follow the same pattern as __repr__. 142 | # We check that the protected _format method actually exists 143 | # to be safe and to make linters happy. 144 | if self._was_equal and hasattr(pprinter, '_format'): 145 | pprinter._format(self._other, stream, *args, **kwargs) 146 | else: 147 | stream.write(repr(self)) # i.e. self._repr_ne() (for now) 148 | 149 | 150 | # Patch pprint to call _pprint_format for DirtyEquals objects 151 | # Check that the protected attribute _dispatch exists to be safe and to make linters happy. 152 | # The reason we modify _dispatch rather than _format 153 | # is that pytest sometimes uses a subclass of PrettyPrinter which overrides _format. 154 | if hasattr(PrettyPrinter, '_dispatch'): # pragma: no branch 155 | PrettyPrinter._dispatch[DirtyEquals.__repr__] = lambda pprinter, obj, *args, **kwargs: obj._pprint_format( 156 | pprinter, *args, **kwargs 157 | ) 158 | 159 | 160 | InstanceOrType: 'TypeAlias' = 'Union[DirtyEquals[Any], DirtyEqualsMeta]' 161 | 162 | 163 | class DirtyOr(DirtyEquals[Any]): 164 | def __init__(self, a: 'InstanceOrType', b: 'InstanceOrType', *extra: 'InstanceOrType'): 165 | self.dirties = (a, b) + extra 166 | super().__init__() 167 | 168 | def equals(self, other: Any) -> bool: 169 | return any(d == other for d in self.dirties) 170 | 171 | def _repr_ne(self) -> str: 172 | return ' | '.join(_repr_ne(d) for d in self.dirties) 173 | 174 | 175 | class DirtyAnd(DirtyEquals[Any]): 176 | def __init__(self, a: InstanceOrType, b: InstanceOrType, *extra: InstanceOrType): 177 | self.dirties = (a, b) + extra 178 | super().__init__() 179 | 180 | def equals(self, other: Any) -> bool: 181 | return all(d == other for d in self.dirties) 182 | 183 | def _repr_ne(self) -> str: 184 | return ' & '.join(_repr_ne(d) for d in self.dirties) 185 | 186 | 187 | class DirtyNot(DirtyEquals[Any]): 188 | def __init__(self, subject: InstanceOrType): 189 | self.subject = subject 190 | super().__init__() 191 | 192 | def equals(self, other: Any) -> bool: 193 | return self.subject != other 194 | 195 | def _repr_ne(self) -> str: 196 | return f'~{_repr_ne(self.subject)}' 197 | 198 | 199 | def _repr_ne(v: InstanceOrType) -> str: 200 | if isinstance(v, DirtyEqualsMeta): 201 | return repr(v) 202 | else: 203 | return v._repr_ne() 204 | 205 | 206 | class AnyThing(DirtyEquals[Any]): 207 | """ 208 | A type which matches any value. `AnyThing` isn't generally very useful on its own, but can be used within 209 | other comparisons. 210 | 211 | ```py title="AnyThing" 212 | from dirty_equals import AnyThing, IsList, IsStrictDict 213 | 214 | assert 1 == AnyThing 215 | assert 'foobar' == AnyThing 216 | assert [1, 2, 3] == AnyThing 217 | 218 | assert [1, 2, 3] == IsList(AnyThing, 2, 3) 219 | 220 | assert {'a': 1, 'b': 2, 'c': 3} == IsStrictDict(a=1, b=AnyThing, c=3) 221 | ``` 222 | """ 223 | 224 | def equals(self, other: Any) -> bool: 225 | return True 226 | 227 | 228 | class IsOneOf(DirtyEquals[Any]): 229 | """ 230 | A type which checks that the value is equal to one of the given values. 231 | 232 | Can be useful with boolean operators. 233 | """ 234 | 235 | def __init__(self, expected_value: Any, *more_expected_values: Any): 236 | """ 237 | Args: 238 | expected_value: Expected value for equals to return true. 239 | *more_expected_values: More expected values for equals to return true. 240 | 241 | ```py title="IsOneOf" 242 | from dirty_equals import Contains, IsOneOf 243 | 244 | assert 1 == IsOneOf(1, 2, 3) 245 | assert 4 != IsOneOf(1, 2, 3) 246 | # check that a list either contain 1 or is empty 247 | assert [1, 2, 3] == Contains(1) | IsOneOf([]) 248 | assert [] == Contains(1) | IsOneOf([]) 249 | ``` 250 | """ 251 | self.expected_values: Tuple[Any, ...] = (expected_value,) + more_expected_values 252 | super().__init__(*self.expected_values) 253 | 254 | def equals(self, other: Any) -> bool: 255 | return any(other == e for e in self.expected_values) 256 | -------------------------------------------------------------------------------- /dirty_equals/_boolean.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from ._base import DirtyEquals 4 | from ._utils import Omit 5 | 6 | 7 | class IsTrueLike(DirtyEquals[bool]): 8 | """ 9 | Check if the value is True like. `IsTrueLike` allows comparison to anything and effectively uses just 10 | `return bool(other)`. 11 | 12 | Example of basic usage: 13 | 14 | ```py title="IsTrueLike" 15 | from dirty_equals import IsTrueLike 16 | 17 | assert True == IsTrueLike 18 | assert 1 == IsTrueLike 19 | assert 'true' == IsTrueLike 20 | assert 'foobar' == IsTrueLike # any non-empty string is "True" 21 | assert '' != IsTrueLike 22 | assert [1] == IsTrueLike 23 | assert {} != IsTrueLike 24 | assert None != IsTrueLike 25 | ``` 26 | """ 27 | 28 | def equals(self, other: Any) -> bool: 29 | return bool(other) 30 | 31 | 32 | class IsFalseLike(DirtyEquals[bool]): 33 | """ 34 | Check if the value is False like. `IsFalseLike` allows comparison to anything and effectively uses 35 | `return not bool(other)` (with string checks if `allow_strings=True` is set). 36 | """ 37 | 38 | def __init__(self, *, allow_strings: bool = False): 39 | """ 40 | Args: 41 | allow_strings: if `True`, allow comparisons to `False` like strings, case-insensitive, allows 42 | `''`, `'false'` and any string where `float(other) == 0` (e.g. `'0'`). 43 | 44 | Example of basic usage: 45 | 46 | ```py title="IsFalseLike" 47 | from dirty_equals import IsFalseLike 48 | 49 | assert False == IsFalseLike 50 | assert 0 == IsFalseLike 51 | assert 'false' == IsFalseLike(allow_strings=True) 52 | assert '0' == IsFalseLike(allow_strings=True) 53 | assert 'foobar' != IsFalseLike(allow_strings=True) 54 | assert 'false' != IsFalseLike 55 | assert 'True' != IsFalseLike(allow_strings=True) 56 | assert [1] != IsFalseLike 57 | assert {} == IsFalseLike 58 | assert None == IsFalseLike 59 | assert '' == IsFalseLike(allow_strings=True) 60 | assert '' == IsFalseLike 61 | ``` 62 | """ 63 | self.allow_strings = allow_strings 64 | super().__init__(allow_strings=allow_strings or Omit) 65 | 66 | def equals(self, other: Any) -> bool: 67 | if isinstance(other, str) and self.allow_strings: 68 | return self.make_string_check(other) 69 | return not bool(other) 70 | 71 | @staticmethod 72 | def make_string_check(other: str) -> bool: 73 | if other.lower() in {'false', ''}: 74 | return True 75 | 76 | try: 77 | return float(other) == 0 78 | except ValueError: 79 | return False 80 | -------------------------------------------------------------------------------- /dirty_equals/_datetime.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations as _annotations 2 | 3 | from datetime import date, datetime, timedelta, timezone, tzinfo 4 | from typing import TYPE_CHECKING, Any 5 | 6 | from ._numeric import IsNumeric 7 | from ._utils import Omit 8 | 9 | if TYPE_CHECKING: 10 | from zoneinfo import ZoneInfo 11 | 12 | 13 | class IsDatetime(IsNumeric[datetime]): 14 | """ 15 | Check if the value is a datetime, and matches the given conditions. 16 | """ 17 | 18 | allowed_types = datetime 19 | 20 | def __init__( 21 | self, 22 | *, 23 | approx: datetime | None = None, 24 | delta: timedelta | int | float | None = None, 25 | gt: datetime | None = None, 26 | lt: datetime | None = None, 27 | ge: datetime | None = None, 28 | le: datetime | None = None, 29 | unix_number: bool = False, 30 | iso_string: bool = False, 31 | format_string: str | None = None, 32 | enforce_tz: bool = True, 33 | ): 34 | """ 35 | Args: 36 | approx: A value to approximately compare to. 37 | delta: The allowable different when comparing to the value to `approx`, if omitted 2 seconds is used, 38 | ints and floats are assumed to represent seconds and converted to `timedelta`s. 39 | gt: Value which the compared value should be greater than (after). 40 | lt: Value which the compared value should be less than (before). 41 | ge: Value which the compared value should be greater than (after) or equal to. 42 | le: Value which the compared value should be less than (before) or equal to. 43 | unix_number: whether to allow unix timestamp numbers in comparison 44 | iso_string: whether to allow iso formatted strings in comparison 45 | format_string: if provided, `format_string` is used with `datetime.strptime` to parse strings 46 | enforce_tz: whether timezone should be enforced in comparison, see below for more details 47 | 48 | Examples of basic usage: 49 | 50 | ```py title="IsDatetime" 51 | from datetime import datetime 52 | 53 | from dirty_equals import IsDatetime 54 | 55 | y2k = datetime(2000, 1, 1) 56 | assert datetime(2000, 1, 1) == IsDatetime(approx=y2k) 57 | # Note: this requires the system timezone to be UTC 58 | assert 946684800.123 == IsDatetime(approx=y2k, unix_number=True) 59 | assert datetime(2000, 1, 1, 0, 0, 9) == IsDatetime(approx=y2k, delta=10) 60 | assert '2000-01-01T00:00' == IsDatetime(approx=y2k, iso_string=True) 61 | 62 | assert datetime(2000, 1, 2) == IsDatetime(gt=y2k) 63 | assert datetime(1999, 1, 2) != IsDatetime(gt=y2k) 64 | ``` 65 | """ 66 | if isinstance(delta, (int, float)): 67 | delta = timedelta(seconds=delta) 68 | 69 | super().__init__( 70 | approx=approx, 71 | delta=delta, # type: ignore[arg-type] 72 | gt=gt, 73 | lt=lt, 74 | ge=ge, 75 | le=le, 76 | ) 77 | self.unix_number = unix_number 78 | self.iso_string = iso_string 79 | self.format_string = format_string 80 | self.enforce_tz = enforce_tz 81 | self._repr_kwargs.update( 82 | unix_number=Omit if unix_number is False else unix_number, 83 | iso_string=Omit if iso_string is False else iso_string, 84 | format_string=Omit if format_string is None else format_string, 85 | enforce_tz=Omit if enforce_tz is True else format_string, 86 | ) 87 | 88 | def prepare(self, other: Any) -> datetime: 89 | if isinstance(other, datetime): 90 | dt = other 91 | elif isinstance(other, (float, int)): 92 | if self.unix_number: 93 | dt = datetime.fromtimestamp(other) 94 | else: 95 | raise TypeError('numbers not allowed') 96 | elif isinstance(other, str): 97 | if self.iso_string: 98 | dt = datetime.fromisoformat(other) 99 | elif self.format_string: 100 | dt = datetime.strptime(other, self.format_string) 101 | else: 102 | raise ValueError('not a valid datetime string') 103 | else: 104 | raise ValueError(f'{type(other)} not valid as datetime') 105 | 106 | if self.approx is not None and not self.enforce_tz and self.approx.tzinfo is None and dt.tzinfo is not None: 107 | dt = dt.replace(tzinfo=None) 108 | return dt 109 | 110 | def approx_equals(self, other: datetime, delta: timedelta) -> bool: 111 | if not super().approx_equals(other, delta): 112 | return False 113 | 114 | if self.enforce_tz: 115 | if self.approx.tzinfo is None: # type: ignore[union-attr] 116 | return other.tzinfo is None 117 | else: 118 | approx_offset = self.approx.tzinfo.utcoffset(self.approx) # type: ignore[union-attr] 119 | other_offset = other.tzinfo.utcoffset(other) # type: ignore[union-attr] 120 | return approx_offset == other_offset 121 | else: 122 | return True 123 | 124 | 125 | def _zoneinfo(tz: str) -> ZoneInfo: 126 | """ 127 | Instantiate a `ZoneInfo` object from a string, falling back to `pytz.timezone` when `ZoneInfo` is not available 128 | (most likely on Python 3.8 and webassembly). 129 | """ 130 | try: 131 | from zoneinfo import ZoneInfo 132 | except ImportError: 133 | try: 134 | import pytz 135 | except ImportError as e: 136 | raise ImportError('`pytz` or `zoneinfo` required for tz handling') from e 137 | else: 138 | return pytz.timezone(tz) # type: ignore[return-value] 139 | else: 140 | return ZoneInfo(tz) 141 | 142 | 143 | class IsNow(IsDatetime): 144 | """ 145 | Check if a datetime is close to now, this is similar to `IsDatetime(approx=datetime.now())`, 146 | but slightly more powerful. 147 | """ 148 | 149 | def __init__( 150 | self, 151 | *, 152 | delta: timedelta | int | float = 2, 153 | unix_number: bool = False, 154 | iso_string: bool = False, 155 | format_string: str | None = None, 156 | enforce_tz: bool = True, 157 | tz: str | tzinfo | None = None, 158 | ): 159 | """ 160 | Args: 161 | delta: The allowable different when comparing to the value to now, if omitted 2 seconds is used, 162 | ints and floats are assumed to represent seconds and converted to `timedelta`s. 163 | unix_number: whether to allow unix timestamp numbers in comparison 164 | iso_string: whether to allow iso formatted strings in comparison 165 | format_string: if provided, `format_string` is used with `datetime.strptime` to parse strings 166 | enforce_tz: whether timezone should be enforced in comparison, see below for more details 167 | tz: either a `ZoneInfo`, a `datetime.timezone` or a string which will be passed to `ZoneInfo`, 168 | (or `pytz.timezone` on 3.8) to get a timezone, 169 | if provided now will be converted to this timezone. 170 | 171 | ```py title="IsNow" 172 | from datetime import datetime, timezone 173 | 174 | from dirty_equals import IsNow 175 | 176 | now = datetime.now() 177 | assert now == IsNow 178 | assert now.timestamp() == IsNow(unix_number=True) 179 | assert now.timestamp() != IsNow 180 | assert now.isoformat() == IsNow(iso_string=True) 181 | assert now.isoformat() != IsNow 182 | 183 | utc_now = datetime.utcnow().replace(tzinfo=timezone.utc) 184 | assert utc_now == IsNow(tz=timezone.utc) 185 | ``` 186 | """ 187 | if isinstance(tz, str): 188 | tz = _zoneinfo(tz) 189 | 190 | self.tz = tz 191 | 192 | approx = self._get_now() 193 | 194 | super().__init__( 195 | approx=approx, 196 | delta=delta, 197 | unix_number=unix_number, 198 | iso_string=iso_string, 199 | format_string=format_string, 200 | enforce_tz=enforce_tz, 201 | ) 202 | if tz is not None: 203 | self._repr_kwargs['tz'] = tz 204 | 205 | def _get_now(self) -> datetime: 206 | if self.tz is None: 207 | return datetime.now() 208 | else: 209 | utc_now = datetime.now(tz=timezone.utc).replace(tzinfo=timezone.utc) 210 | return utc_now.astimezone(self.tz) 211 | 212 | def prepare(self, other: Any) -> datetime: 213 | # update approx for every comparing, to check if other value is dirty equal 214 | # to current moment of time 215 | self.approx = self._get_now() 216 | 217 | return super().prepare(other) 218 | 219 | 220 | class IsDate(IsNumeric[date]): 221 | """ 222 | Check if the value is a date, and matches the given conditions. 223 | """ 224 | 225 | allowed_types = date 226 | 227 | def __init__( 228 | self, 229 | *, 230 | approx: date | None = None, 231 | delta: timedelta | int | float | None = None, 232 | gt: date | None = None, 233 | lt: date | None = None, 234 | ge: date | None = None, 235 | le: date | None = None, 236 | iso_string: bool = False, 237 | format_string: str | None = None, 238 | ): 239 | """ 240 | Args: 241 | approx: A value to approximately compare to. 242 | delta: The allowable different when comparing to the value to now, if omitted 2 seconds is used, 243 | ints and floats are assumed to represent seconds and converted to `timedelta`s. 244 | gt: Value which the compared value should be greater than (after). 245 | lt: Value which the compared value should be less than (before). 246 | ge: Value which the compared value should be greater than (after) or equal to. 247 | le: Value which the compared value should be less than (before) or equal to. 248 | iso_string: whether to allow iso formatted strings in comparison 249 | format_string: if provided, `format_string` is used with `datetime.strptime` to parse strings 250 | 251 | Examples of basic usage: 252 | 253 | ```py title="IsDate" 254 | from datetime import date 255 | 256 | from dirty_equals import IsDate 257 | 258 | y2k = date(2000, 1, 1) 259 | assert date(2000, 1, 1) == IsDate(approx=y2k) 260 | assert '2000-01-01' == IsDate(approx=y2k, iso_string=True) 261 | 262 | assert date(2000, 1, 2) == IsDate(gt=y2k) 263 | assert date(1999, 1, 2) != IsDate(gt=y2k) 264 | ``` 265 | """ 266 | 267 | if delta is None: 268 | delta = timedelta() 269 | elif isinstance(delta, (int, float)): 270 | delta = timedelta(seconds=delta) 271 | 272 | super().__init__(approx=approx, gt=gt, lt=lt, ge=ge, le=le, delta=delta) # type: ignore[arg-type] 273 | 274 | self.iso_string = iso_string 275 | self.format_string = format_string 276 | self._repr_kwargs.update( 277 | iso_string=Omit if iso_string is False else iso_string, 278 | format_string=Omit if format_string is None else format_string, 279 | ) 280 | 281 | def prepare(self, other: Any) -> date: 282 | if type(other) is date: 283 | dt = other 284 | elif isinstance(other, str): 285 | if self.iso_string: 286 | dt = date.fromisoformat(other) 287 | elif self.format_string: 288 | dt = datetime.strptime(other, self.format_string).date() 289 | else: 290 | raise ValueError('not a valid date string') 291 | else: 292 | raise ValueError(f'{type(other)} not valid as date') 293 | 294 | return dt 295 | 296 | 297 | class IsToday(IsDate): 298 | """ 299 | Check if a date is today, this is similar to `IsDate(approx=date.today())`, but slightly more powerful. 300 | """ 301 | 302 | def __init__( 303 | self, 304 | *, 305 | iso_string: bool = False, 306 | format_string: str | None = None, 307 | ): 308 | """ 309 | Args: 310 | iso_string: whether to allow iso formatted strings in comparison 311 | format_string: if provided, `format_string` is used with `datetime.strptime` to parse strings 312 | ```py title="IsToday" 313 | from datetime import date, timedelta 314 | 315 | from dirty_equals import IsToday 316 | 317 | today = date.today() 318 | assert today == IsToday 319 | assert today.isoformat() == IsToday(iso_string=True) 320 | assert today.isoformat() != IsToday 321 | assert today + timedelta(days=1) != IsToday 322 | assert today.strftime('%Y/%m/%d') == IsToday(format_string='%Y/%m/%d') 323 | assert today.strftime('%Y/%m/%d') != IsToday() 324 | ``` 325 | """ 326 | 327 | super().__init__(approx=date.today(), iso_string=iso_string, format_string=format_string) 328 | -------------------------------------------------------------------------------- /dirty_equals/_dict.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any, Callable, Container, Dict, overload 4 | 5 | from ._base import DirtyEquals, DirtyEqualsMeta 6 | from ._utils import get_dict_arg 7 | 8 | NotGiven = object() 9 | 10 | 11 | class IsDict(DirtyEquals[Dict[Any, Any]]): 12 | """ 13 | Base class for comparing dictionaries. By default, `IsDict` isn't particularly useful on its own 14 | (it behaves pretty much like a normal `dict`), but it can be subclassed 15 | (see [`IsPartialDict`][dirty_equals.IsPartialDict] and [`IsStrictDict`][dirty_equals.IsStrictDict]) or modified 16 | with `.settings(...)` to powerful things. 17 | """ 18 | 19 | @overload 20 | def __init__(self, expected: dict[Any, Any]): ... 21 | 22 | @overload 23 | def __init__(self, **expected: Any): ... 24 | 25 | def __init__(self, *expected_args: dict[Any, Any], **expected_kwargs: Any): 26 | """ 27 | Can be created from either keyword arguments or an existing dictionary (same as `dict()`). 28 | 29 | `IsDict` is not particularly useful on its own, but it can be subclassed or modified with 30 | [`.settings(...)`][dirty_equals.IsDict.settings] to facilitate powerful comparison of dictionaries. 31 | 32 | ```py title="IsDict" 33 | from dirty_equals import IsDict 34 | 35 | assert {'a': 1, 'b': 2} == IsDict(a=1, b=2) 36 | assert {1: 2, 3: 4} == IsDict({1: 2, 3: 4}) 37 | ``` 38 | """ 39 | self.expected_values = get_dict_arg('IsDict', expected_args, expected_kwargs) 40 | 41 | self.strict = False 42 | self.partial = False 43 | self.ignore: None | Container[Any] | Callable[[Any], bool] = None 44 | self._post_init() 45 | super().__init__() 46 | 47 | def _post_init(self) -> None: 48 | pass 49 | 50 | def settings( 51 | self, 52 | *, 53 | strict: bool | None = None, 54 | partial: bool | None = None, 55 | ignore: None | Container[Any] | Callable[[Any], bool] = NotGiven, # type: ignore[assignment] 56 | ) -> IsDict: 57 | """ 58 | Allows you to customise the behaviour of `IsDict`, technically a new `IsDict` is required to allow chaining. 59 | 60 | Args: 61 | strict (bool): If `True`, the order of key/value pairs must match. 62 | partial (bool): If `True`, only keys include in the wrapped dict are checked. 63 | ignore (Union[None, Container[Any], Callable[[Any], bool]]): Values to omit from comparison. 64 | Can be either a `Container` (e.g. `set` or `list`) of values to ignore, or a function that takes a 65 | value and should return `True` if the value should be ignored. 66 | 67 | ```py title="IsDict.settings(...)" 68 | from dirty_equals import IsDict 69 | 70 | assert {'a': 1, 'b': 2, 'c': None} != IsDict(a=1, b=2) 71 | assert {'a': 1, 'b': 2, 'c': None} == IsDict(a=1, b=2).settings(partial=True) # (1)! 72 | 73 | assert {'b': 2, 'a': 1} == IsDict(a=1, b=2) 74 | assert {'b': 2, 'a': 1} != IsDict(a=1, b=2).settings(strict=True) # (2)! 75 | 76 | # combining partial and strict 77 | assert {'a': 1, 'b': None, 'c': 3} == IsDict(a=1, c=3).settings( 78 | strict=True, partial=True 79 | ) 80 | assert {'b': None, 'c': 3, 'a': 1} != IsDict(a=1, c=3).settings( 81 | strict=True, partial=True 82 | ) 83 | ``` 84 | 85 | 1. This is the same as [`IsPartialDict(a=1, b=2)`][dirty_equals.IsPartialDict] 86 | 2. This is the same as [`IsStrictDict(a=1, b=2)`][dirty_equals.IsStrictDict] 87 | """ 88 | new_cls = self.__class__(self.expected_values) 89 | new_cls.__dict__ = self.__dict__.copy() 90 | if strict is not None: 91 | new_cls.strict = strict 92 | if partial is not None: 93 | new_cls.partial = partial 94 | if ignore is not NotGiven: 95 | new_cls.ignore = ignore 96 | 97 | if new_cls.partial and new_cls.ignore: 98 | raise TypeError('partial and ignore cannot be used together') 99 | 100 | return new_cls 101 | 102 | def equals(self, other: dict[Any, Any]) -> bool: 103 | if not isinstance(other, dict): 104 | return False 105 | 106 | expected = self.expected_values 107 | if self.partial: 108 | other = {k: v for k, v in other.items() if k in expected} 109 | 110 | if self.ignore: 111 | expected = self._filter_dict(self.expected_values) 112 | other = self._filter_dict(other) 113 | 114 | if other != expected: 115 | return False 116 | 117 | if self.strict and list(other.keys()) != list(expected.keys()): 118 | return False 119 | 120 | return True 121 | 122 | def _filter_dict(self, d: dict[Any, Any]) -> dict[Any, Any]: 123 | return {k: v for k, v in d.items() if not self._ignore_value(v)} 124 | 125 | def _ignore_value(self, v: Any) -> bool: 126 | # `isinstance(v, (DirtyEquals, DirtyEqualsMeta))` seems to always return `True` on pypy, no idea why 127 | if type(v) in (DirtyEquals, DirtyEqualsMeta): 128 | return False 129 | elif callable(self.ignore): 130 | return self.ignore(v) 131 | else: 132 | try: 133 | return v in self.ignore # type: ignore[operator] 134 | except TypeError: 135 | # happens for unhashable types 136 | return False 137 | 138 | def _repr_ne(self) -> str: 139 | name = self.__class__.__name__ 140 | modifiers = [] 141 | if self.partial != (name == 'IsPartialDict'): 142 | modifiers += [f'partial={self.partial}'] 143 | if (self.ignore == {None}) != (name == 'IsIgnoreDict') or self.ignore not in (None, {None}): 144 | r = self.ignore.__name__ if callable(self.ignore) else repr(self.ignore) 145 | modifiers += [f'ignore={r}'] 146 | if self.strict != (name == 'IsStrictDict'): 147 | modifiers += [f'strict={self.strict}'] 148 | 149 | if modifiers: 150 | mod = f'[{", ".join(modifiers)}]' 151 | else: 152 | mod = '' 153 | 154 | args = [f'{k}={v!r}' for k, v in self.expected_values.items()] 155 | return f'{name}{mod}({", ".join(args)})' 156 | 157 | 158 | class IsPartialDict(IsDict): 159 | """ 160 | Partial dictionary comparison, this is the same as 161 | [`IsDict(...).settings(partial=True)`][dirty_equals.IsDict.settings]. 162 | 163 | ```py title="IsPartialDict" 164 | from dirty_equals import IsPartialDict 165 | 166 | assert {'a': 1, 'b': 2, 'c': 3} == IsPartialDict(a=1, b=2) 167 | 168 | assert {'a': 1, 'b': 2, 'c': 3} != IsPartialDict(a=1, b=3) 169 | assert {'a': 1, 'b': 2, 'd': 3} != IsPartialDict(a=1, b=2, c=3) 170 | 171 | # combining partial and strict 172 | assert {'a': 1, 'b': None, 'c': 3} == IsPartialDict(a=1, c=3).settings(strict=True) 173 | assert {'b': None, 'c': 3, 'a': 1} != IsPartialDict(a=1, c=3).settings(strict=True) 174 | ``` 175 | """ 176 | 177 | def _post_init(self) -> None: 178 | self.partial = True 179 | 180 | 181 | class IsIgnoreDict(IsDict): 182 | """ 183 | Dictionary comparison with `None` values ignored, this is the same as 184 | [`IsDict(...).settings(ignore={None})`][dirty_equals.IsDict.settings]. 185 | 186 | `.settings(...)` can be used to customise the behaviour of `IsIgnoreDict`, in particular changing which 187 | values are ignored. 188 | 189 | ```py title="IsIgnoreDict" 190 | from dirty_equals import IsIgnoreDict 191 | 192 | assert {'a': 1, 'b': 2, 'c': None} == IsIgnoreDict(a=1, b=2) 193 | assert {'a': 1, 'b': 2, 'c': 'ignore'} == ( 194 | IsIgnoreDict(a=1, b=2).settings(ignore={None, 'ignore'}) 195 | ) 196 | 197 | def is_even(v: int) -> bool: 198 | return v % 2 == 0 199 | 200 | assert {'a': 1, 'b': 2, 'c': 3, 'd': 4} == ( 201 | IsIgnoreDict(a=1, c=3).settings(ignore=is_even) 202 | ) 203 | 204 | # combining partial and strict 205 | assert {'a': 1, 'b': None, 'c': 3} == IsIgnoreDict(a=1, c=3).settings(strict=True) 206 | assert {'b': None, 'c': 3, 'a': 1} != IsIgnoreDict(a=1, c=3).settings(strict=True) 207 | ``` 208 | """ 209 | 210 | def _post_init(self) -> None: 211 | self.ignore = {None} 212 | 213 | 214 | class IsStrictDict(IsDict): 215 | """ 216 | Dictionary comparison with order enforced, this is the same as 217 | [`IsDict(...).settings(strict=True)`][dirty_equals.IsDict.settings]. 218 | 219 | ```py title="IsDict.settings(...)" 220 | from dirty_equals import IsStrictDict 221 | 222 | assert {'a': 1, 'b': 2} == IsStrictDict(a=1, b=2) 223 | assert {'a': 1, 'b': 2, 'c': 3} != IsStrictDict(a=1, b=2) 224 | assert {'b': 2, 'a': 1} != IsStrictDict(a=1, b=2) 225 | 226 | # combining partial and strict 227 | assert {'a': 1, 'b': None, 'c': 3} == IsStrictDict(a=1, c=3).settings(partial=True) 228 | assert {'b': None, 'c': 3, 'a': 1} != IsStrictDict(a=1, c=3).settings(partial=True) 229 | ``` 230 | """ 231 | 232 | def _post_init(self) -> None: 233 | self.strict = True 234 | -------------------------------------------------------------------------------- /dirty_equals/_inspection.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, Tuple, TypeVar, Union, overload 2 | 3 | from ._base import DirtyEquals 4 | from ._strings import IsStr 5 | from ._utils import get_dict_arg 6 | 7 | ExpectedType = TypeVar('ExpectedType', bound=Union[type, Tuple[Union[type, Tuple[Any, ...]], ...]]) 8 | 9 | 10 | class IsInstance(DirtyEquals[ExpectedType]): 11 | """ 12 | A type which checks that the value is an instance of the expected type. 13 | """ 14 | 15 | def __init__(self, expected_type: ExpectedType, *, only_direct_instance: bool = False): 16 | """ 17 | Args: 18 | expected_type: The type to check against. 19 | only_direct_instance: whether instances of subclasses of `expected_type` should be considered equal. 20 | 21 | !!! note 22 | `IsInstance` can be parameterized or initialised with a type - 23 | `IsInstance[Foo]` is exactly equivalent to `IsInstance(Foo)`. 24 | 25 | This allows usage to be analogous to type hints. 26 | 27 | Example: 28 | ```py title="IsInstance" 29 | from dirty_equals import IsInstance 30 | 31 | class Foo: 32 | pass 33 | 34 | class Bar(Foo): 35 | pass 36 | 37 | assert Foo() == IsInstance[Foo] 38 | assert Foo() == IsInstance(Foo) 39 | assert Foo != IsInstance[Bar] 40 | 41 | assert Bar() == IsInstance[Foo] 42 | assert Foo() == IsInstance(Foo, only_direct_instance=True) 43 | assert Bar() != IsInstance(Foo, only_direct_instance=True) 44 | ``` 45 | """ 46 | self.expected_type = expected_type 47 | self.only_direct_instance = only_direct_instance 48 | super().__init__(expected_type) 49 | 50 | def __class_getitem__(cls, expected_type: ExpectedType) -> 'IsInstance[ExpectedType]': 51 | return cls(expected_type) 52 | 53 | def equals(self, other: Any) -> bool: 54 | if self.only_direct_instance: 55 | return type(other) == self.expected_type 56 | else: 57 | return isinstance(other, self.expected_type) 58 | 59 | 60 | T = TypeVar('T') 61 | 62 | 63 | class HasName(DirtyEquals[T]): 64 | """ 65 | A type which checks that the value has the given `__name__` attribute. 66 | """ 67 | 68 | def __init__(self, expected_name: Union[IsStr, str], *, allow_instances: bool = True): 69 | """ 70 | Args: 71 | expected_name: The name to check against. 72 | allow_instances: whether instances of classes with the given name should be considered equal, 73 | (e.g. whether `other.__class__.__name__ == expected_name` should be checked). 74 | 75 | Example: 76 | ```py title="HasName" 77 | from dirty_equals import HasName, IsStr 78 | 79 | class Foo: 80 | pass 81 | 82 | assert Foo == HasName('Foo') 83 | assert Foo == HasName['Foo'] 84 | assert Foo() == HasName('Foo') 85 | assert Foo() != HasName('Foo', allow_instances=False) 86 | assert Foo == HasName(IsStr(regex='F..')) 87 | assert Foo != HasName('Bar') 88 | assert int == HasName('int') 89 | assert int == HasName('int') 90 | ``` 91 | """ 92 | self.expected_name = expected_name 93 | self.allow_instances = allow_instances 94 | kwargs = {} 95 | if allow_instances: 96 | kwargs['allow_instances'] = allow_instances 97 | super().__init__(expected_name, allow_instances=allow_instances) 98 | 99 | def __class_getitem__(cls, expected_name: str) -> 'HasName[T]': 100 | return cls(expected_name) 101 | 102 | def equals(self, other: Any) -> bool: 103 | direct_name = getattr(other, '__name__', None) 104 | if direct_name is not None and direct_name == self.expected_name: 105 | return True 106 | 107 | if self.allow_instances: 108 | cls = getattr(other, '__class__', None) 109 | if cls is not None: # pragma: no branch 110 | cls_name = getattr(cls, '__name__', None) 111 | if cls_name is not None and cls_name == self.expected_name: 112 | return True 113 | 114 | return False 115 | 116 | 117 | class HasRepr(DirtyEquals[T]): 118 | """ 119 | A type which checks that the value has the given `repr()` value. 120 | """ 121 | 122 | def __init__(self, expected_repr: Union[IsStr, str]): 123 | """ 124 | Args: 125 | expected_repr: The expected repr value. 126 | 127 | Example: 128 | ```py title="HasRepr" 129 | from dirty_equals import HasRepr, IsStr 130 | 131 | class Foo: 132 | def __repr__(self): 133 | return 'This is a Foo' 134 | 135 | assert Foo() == HasRepr('This is a Foo') 136 | assert Foo() == HasRepr['This is a Foo'] 137 | assert Foo == HasRepr(IsStr(regex=' 'HasRepr[T]': 146 | return cls(expected_repr) 147 | 148 | def equals(self, other: Any) -> bool: 149 | return repr(other) == self.expected_repr 150 | 151 | 152 | class HasAttributes(DirtyEquals[Any]): 153 | """ 154 | A type which checks that the value has the given attributes. 155 | 156 | This is a partial check - e.g. the attributes provided to check do not need to be exhaustive. 157 | """ 158 | 159 | @overload 160 | def __init__(self, expected: Dict[Any, Any]): ... 161 | 162 | @overload 163 | def __init__(self, **expected: Any): ... 164 | 165 | def __init__(self, *expected_args: Dict[Any, Any], **expected_kwargs: Any): 166 | """ 167 | Can be created from either keyword arguments or an existing dictionary (same as `dict()`). 168 | 169 | Example: 170 | ```py title="HasAttributes" 171 | from dirty_equals import AnyThing, HasAttributes, IsInt, IsStr 172 | 173 | class Foo: 174 | def __init__(self, a, b): 175 | self.a = a 176 | self.b = b 177 | 178 | def spam(self): 179 | pass 180 | 181 | assert Foo(1, 2) == HasAttributes(a=1, b=2) 182 | assert Foo(1, 2) == HasAttributes(a=1) 183 | assert Foo(1, 's') == HasAttributes(a=IsInt, b=IsStr) 184 | assert Foo(1, 2) != HasAttributes(a=IsInt, b=IsStr) 185 | assert Foo(1, 2) != HasAttributes(a=1, b=2, c=3) 186 | assert Foo(1, 2) == HasAttributes(a=1, b=2, spam=AnyThing) 187 | ``` 188 | """ 189 | self.expected_attrs = get_dict_arg('HasAttributes', expected_args, expected_kwargs) 190 | super().__init__(**self.expected_attrs) 191 | 192 | def equals(self, other: Any) -> bool: 193 | for attr, expected_value in self.expected_attrs.items(): 194 | # done like this to avoid problems with `AnyThing` equaling `None` or `DefaultAttr` 195 | try: 196 | value = getattr(other, attr) 197 | except AttributeError: 198 | return False 199 | else: 200 | if value != expected_value: 201 | return False 202 | return True 203 | -------------------------------------------------------------------------------- /dirty_equals/_numeric.py: -------------------------------------------------------------------------------- 1 | import math 2 | from datetime import date, datetime, timedelta 3 | from decimal import Decimal 4 | from typing import Any, Optional, Tuple, Type, TypeVar, Union 5 | 6 | from ._base import DirtyEquals 7 | 8 | __all__ = ( 9 | 'IsApprox', 10 | 'IsNumeric', 11 | 'IsNumber', 12 | 'IsPositive', 13 | 'IsNegative', 14 | 'IsNonPositive', 15 | 'IsNonNegative', 16 | 'IsInt', 17 | 'IsPositiveInt', 18 | 'IsNegativeInt', 19 | 'IsFloat', 20 | 'IsPositiveFloat', 21 | 'IsNegativeFloat', 22 | ) 23 | 24 | from ._utils import Omit 25 | 26 | AnyNumber = Union[int, float, Decimal] 27 | N = TypeVar('N', int, float, Decimal, date, datetime, AnyNumber) 28 | 29 | 30 | class IsNumeric(DirtyEquals[N]): 31 | """ 32 | Base class for all numeric types, `IsNumeric` implements approximate and inequality comparisons, 33 | as well as the type checks. 34 | 35 | This class can be used directly or via any of its subclasses. 36 | """ 37 | 38 | allowed_types: Union[Type[N], Tuple[type, ...]] = (int, float, Decimal, date, datetime) 39 | """It allows any of the types supported in its subclasses.""" 40 | 41 | def __init__( 42 | self, 43 | *, 44 | exactly: Optional[N] = None, 45 | approx: Optional[N] = None, 46 | delta: Optional[N] = None, 47 | gt: Optional[N] = None, 48 | lt: Optional[N] = None, 49 | ge: Optional[N] = None, 50 | le: Optional[N] = None, 51 | ): 52 | """ 53 | Args: 54 | exactly: A value to exactly compare to - useful when you want to make sure a value is an `int` or `float`, 55 | while also checking its value. 56 | approx: A value to approximately compare to. 57 | delta: The allowable different when comparing to the value to `approx`, 58 | if omitted `value / 100` is used except for datetimes where 2 seconds is used. 59 | gt: Value which the compared value should be greater than. 60 | lt: Value which the compared value should be less than. 61 | ge: Value which the compared value should be greater than or equal to. 62 | le: Value which the compared value should be less than or equal to. 63 | 64 | If not values are provided, only the type is checked. 65 | 66 | If `approx` is provided as well a `gt`, `lt`, `ge`, or `le`, a `TypeError` is raised. 67 | 68 | Example of direct usage: 69 | 70 | ```py title="IsNumeric" 71 | from datetime import datetime 72 | 73 | from dirty_equals import IsNumeric 74 | 75 | assert 1.0 == IsNumeric 76 | assert 4 == IsNumeric(gt=3) 77 | d = datetime(2020, 1, 1, 12, 0, 0) 78 | assert d == IsNumeric(approx=datetime(2020, 1, 1, 12, 0, 1)) 79 | ``` 80 | """ 81 | self.exactly: Optional[N] = exactly 82 | if self.exactly is not None and (gt, lt, ge, le) != (None, None, None, None): 83 | raise TypeError('"exactly" cannot be combined with "gt", "lt", "ge", or "le"') 84 | if self.exactly is not None and approx is not None: 85 | raise TypeError('"exactly" cannot be combined with "approx"') 86 | self.approx: Optional[N] = approx 87 | if self.approx is not None and (gt, lt, ge, le) != (None, None, None, None): 88 | raise TypeError('"approx" cannot be combined with "gt", "lt", "ge", or "le"') 89 | self.delta: Optional[N] = delta 90 | self.gt: Optional[N] = gt 91 | self.lt: Optional[N] = lt 92 | self.ge: Optional[N] = ge 93 | self.le: Optional[N] = le 94 | self.has_bounds_checks = not all(f is None for f in (exactly, approx, delta, gt, lt, ge, le)) 95 | kwargs = { 96 | 'exactly': Omit if exactly is None else exactly, 97 | 'approx': Omit if approx is None else approx, 98 | 'delta': Omit if delta is None else delta, 99 | 'gt': Omit if gt is None else gt, 100 | 'lt': Omit if lt is None else lt, 101 | 'ge': Omit if ge is None else ge, 102 | 'le': Omit if le is None else le, 103 | } 104 | super().__init__(**kwargs) 105 | 106 | def prepare(self, other: Any) -> N: 107 | if other is True or other is False: 108 | raise TypeError('booleans are not numbers') 109 | elif not isinstance(other, self.allowed_types): 110 | raise TypeError(f'not a {self.allowed_types}') 111 | else: 112 | return other 113 | 114 | def equals(self, other: Any) -> bool: 115 | other = self.prepare(other) 116 | 117 | if self.has_bounds_checks: 118 | return self.bounds_checks(other) 119 | else: 120 | return True 121 | 122 | def bounds_checks(self, other: N) -> bool: 123 | if self.exactly is not None: 124 | return self.exactly == other 125 | elif self.approx is not None: 126 | if self.delta is None: 127 | if isinstance(other, date): 128 | delta: Any = timedelta(seconds=2) 129 | else: 130 | delta = abs(other / 100) 131 | else: 132 | delta = self.delta 133 | return self.approx_equals(other, delta) 134 | elif self.gt is not None and not other > self.gt: 135 | return False 136 | elif self.lt is not None and not other < self.lt: 137 | return False 138 | elif self.ge is not None and not other >= self.ge: 139 | return False 140 | elif self.le is not None and not other <= self.le: 141 | return False 142 | else: 143 | return True 144 | 145 | def approx_equals(self, other: Any, delta: Any) -> bool: 146 | return abs(self.approx - other) <= delta 147 | 148 | 149 | class IsNumber(IsNumeric[AnyNumber]): 150 | """ 151 | Base class for all types that can be used with all number types, e.g. numeric but not `date` or `datetime`. 152 | 153 | Inherits from [`IsNumeric`][dirty_equals.IsNumeric] and can therefore be initialised with any of its arguments. 154 | """ 155 | 156 | allowed_types = int, float, Decimal 157 | """ 158 | It allows any of the number types. 159 | """ 160 | 161 | 162 | Num = TypeVar('Num', int, float, Decimal) 163 | 164 | 165 | class IsApprox(IsNumber): 166 | """ 167 | Simplified subclass of [`IsNumber`][dirty_equals.IsNumber] that only allows approximate comparisons. 168 | """ 169 | 170 | def __init__(self, approx: Num, *, delta: Optional[Num] = None): 171 | """ 172 | Args: 173 | approx: A value to approximately compare to. 174 | delta: The allowable different when comparing to the value to `approx`, if omitted `value / 100` is used. 175 | 176 | ```py title="IsApprox" 177 | from dirty_equals import IsApprox 178 | 179 | assert 1.0 == IsApprox(1) 180 | assert 123 == IsApprox(120, delta=4) 181 | assert 201 == IsApprox(200) 182 | assert 201 != IsApprox(200, delta=0.1) 183 | ``` 184 | """ 185 | super().__init__(approx=approx, delta=delta) 186 | 187 | 188 | class IsPositive(IsNumber): 189 | """ 190 | Check that a value is positive (`> 0`), can be an `int`, a `float` or a `Decimal` 191 | (or indeed any value which implements `__gt__` for `0`). 192 | 193 | ```py title="IsPositive" 194 | from decimal import Decimal 195 | 196 | from dirty_equals import IsPositive 197 | 198 | assert 1.0 == IsPositive 199 | assert 1 == IsPositive 200 | assert Decimal('3.14') == IsPositive 201 | assert 0 != IsPositive 202 | assert -1 != IsPositive 203 | ``` 204 | """ 205 | 206 | def __init__(self) -> None: 207 | super().__init__(gt=0) 208 | self._repr_kwargs = {} 209 | 210 | 211 | class IsNegative(IsNumber): 212 | """ 213 | Check that a value is negative (`< 0`), can be an `int`, a `float` or a `Decimal` 214 | (or indeed any value which implements `__lt__` for `0`). 215 | 216 | ```py title="IsNegative" 217 | from decimal import Decimal 218 | 219 | from dirty_equals import IsNegative 220 | 221 | assert -1.0 == IsNegative 222 | assert -1 == IsNegative 223 | assert Decimal('-3.14') == IsNegative 224 | assert 0 != IsNegative 225 | assert 1 != IsNegative 226 | ``` 227 | """ 228 | 229 | def __init__(self) -> None: 230 | super().__init__(lt=0) 231 | self._repr_kwargs = {} 232 | 233 | 234 | class IsNonNegative(IsNumber): 235 | """ 236 | Check that a value is positive or zero (`>= 0`), can be an `int`, a `float` or a `Decimal` 237 | (or indeed any value which implements `__ge__` for `0`). 238 | 239 | ```py title="IsNonNegative" 240 | from decimal import Decimal 241 | 242 | from dirty_equals import IsNonNegative 243 | 244 | assert 1.0 == IsNonNegative 245 | assert 1 == IsNonNegative 246 | assert Decimal('3.14') == IsNonNegative 247 | assert 0 == IsNonNegative 248 | assert -1 != IsNonNegative 249 | assert Decimal('0') == IsNonNegative 250 | ``` 251 | """ 252 | 253 | def __init__(self) -> None: 254 | super().__init__(ge=0) 255 | self._repr_kwargs = {} 256 | 257 | 258 | class IsNonPositive(IsNumber): 259 | """ 260 | Check that a value is negative or zero (`<=0`), can be an `int`, a `float` or a `Decimal` 261 | (or indeed any value which implements `__le__` for `0`). 262 | 263 | ```py title="IsNonPositive" 264 | from decimal import Decimal 265 | 266 | from dirty_equals import IsNonPositive 267 | 268 | assert -1.0 == IsNonPositive 269 | assert -1 == IsNonPositive 270 | assert Decimal('-3.14') == IsNonPositive 271 | assert 0 == IsNonPositive 272 | assert 1 != IsNonPositive 273 | assert Decimal('-0') == IsNonPositive 274 | assert Decimal('0') == IsNonPositive 275 | ``` 276 | """ 277 | 278 | def __init__(self) -> None: 279 | super().__init__(le=0) 280 | self._repr_kwargs = {} 281 | 282 | 283 | class IsInt(IsNumeric[int]): 284 | """ 285 | Checks that a value is an integer. 286 | 287 | Inherits from [`IsNumeric`][dirty_equals.IsNumeric] and can therefore be initialised with any of its arguments. 288 | 289 | ```py title="IsInt" 290 | from dirty_equals import IsInt 291 | 292 | assert 1 == IsInt 293 | assert -2 == IsInt 294 | assert 1.0 != IsInt 295 | assert 'foobar' != IsInt 296 | assert True != IsInt 297 | assert 1 == IsInt(exactly=1) 298 | assert -2 != IsInt(exactly=1) 299 | ``` 300 | """ 301 | 302 | allowed_types = int 303 | """ 304 | As the name suggests, only integers are allowed, booleans (`True` are `False`) are explicitly excluded although 305 | technically they are sub-types of `int`. 306 | """ 307 | 308 | 309 | class IsPositiveInt(IsInt): 310 | """ 311 | Like [`IsPositive`][dirty_equals.IsPositive] but only for `int`s. 312 | 313 | ```py title="IsPositiveInt" 314 | from decimal import Decimal 315 | 316 | from dirty_equals import IsPositiveInt 317 | 318 | assert 1 == IsPositiveInt 319 | assert 1.0 != IsPositiveInt 320 | assert Decimal('3.14') != IsPositiveInt 321 | assert 0 != IsPositiveInt 322 | assert -1 != IsPositiveInt 323 | ``` 324 | """ 325 | 326 | def __init__(self) -> None: 327 | super().__init__(gt=0) 328 | self._repr_kwargs = {} 329 | 330 | 331 | class IsNegativeInt(IsInt): 332 | """ 333 | Like [`IsNegative`][dirty_equals.IsNegative] but only for `int`s. 334 | 335 | ```py title="IsNegativeInt" 336 | from decimal import Decimal 337 | 338 | from dirty_equals import IsNegativeInt 339 | 340 | assert -1 == IsNegativeInt 341 | assert -1.0 != IsNegativeInt 342 | assert Decimal('-3.14') != IsNegativeInt 343 | assert 0 != IsNegativeInt 344 | assert 1 != IsNegativeInt 345 | ``` 346 | """ 347 | 348 | def __init__(self) -> None: 349 | super().__init__(lt=0) 350 | self._repr_kwargs = {} 351 | 352 | 353 | class IsFloat(IsNumeric[float]): 354 | """ 355 | Checks that a value is a float. 356 | 357 | Inherits from [`IsNumeric`][dirty_equals.IsNumeric] and can therefore be initialised with any of its arguments. 358 | 359 | ```py title="IsFloat" 360 | from dirty_equals import IsFloat 361 | 362 | assert 1.0 == IsFloat 363 | assert 1 != IsFloat 364 | assert 1.0 == IsFloat(exactly=1.0) 365 | assert 1.001 != IsFloat(exactly=1.0) 366 | ``` 367 | """ 368 | 369 | allowed_types = float 370 | """ 371 | As the name suggests, only floats are allowed. 372 | """ 373 | 374 | 375 | class IsPositiveFloat(IsFloat): 376 | """ 377 | Like [`IsPositive`][dirty_equals.IsPositive] but only for `float`s. 378 | 379 | ```py title="IsPositiveFloat" 380 | from decimal import Decimal 381 | 382 | from dirty_equals import IsPositiveFloat 383 | 384 | assert 1.0 == IsPositiveFloat 385 | assert 1 != IsPositiveFloat 386 | assert Decimal('3.14') != IsPositiveFloat 387 | assert 0.0 != IsPositiveFloat 388 | assert -1.0 != IsPositiveFloat 389 | ``` 390 | """ 391 | 392 | def __init__(self) -> None: 393 | super().__init__(gt=0) 394 | self._repr_kwargs = {} 395 | 396 | 397 | class IsNegativeFloat(IsFloat): 398 | """ 399 | Like [`IsNegative`][dirty_equals.IsNegative] but only for `float`s. 400 | 401 | ```py title="IsNegativeFloat" 402 | from decimal import Decimal 403 | 404 | from dirty_equals import IsNegativeFloat 405 | 406 | assert -1.0 == IsNegativeFloat 407 | assert -1 != IsNegativeFloat 408 | assert Decimal('-3.14') != IsNegativeFloat 409 | assert 0.0 != IsNegativeFloat 410 | assert 1.0 != IsNegativeFloat 411 | ``` 412 | """ 413 | 414 | def __init__(self) -> None: 415 | super().__init__(lt=0) 416 | self._repr_kwargs = {} 417 | 418 | 419 | class IsFloatInf(IsFloat): 420 | """ 421 | Checks that a value is float and infinite (positive or negative). 422 | 423 | Inherits from [`IsFloat`][dirty_equals.IsFloat]. 424 | 425 | ```py title="IsFloatInf" 426 | from dirty_equals import IsFloatInf 427 | 428 | assert float('inf') == IsFloatInf 429 | assert float('-inf') == IsFloatInf 430 | assert 1.0 != IsFloatInf 431 | ``` 432 | """ 433 | 434 | def equals(self, other: Any) -> bool: 435 | other = self.prepare(other) 436 | return math.isinf(other) 437 | 438 | 439 | class IsFloatInfPos(IsFloatInf): 440 | """ 441 | Checks that a value is float and positive infinite. 442 | 443 | Inherits from [`IsFloatInf`][dirty_equals.IsFloatInf]. 444 | 445 | ```py title="IsFloatInfPos" 446 | from dirty_equals import IsFloatInfPos 447 | 448 | assert float('inf') == IsFloatInfPos 449 | assert -float('-inf') == IsFloatInfPos 450 | assert -float('inf') != IsFloatInfPos 451 | assert float('-inf') != IsFloatInfPos 452 | ``` 453 | """ 454 | 455 | def __init__(self) -> None: 456 | super().__init__(gt=0) 457 | self._repr_kwargs = {} 458 | 459 | def equals(self, other: Any) -> bool: 460 | return self.bounds_checks(other) and super().equals(other) 461 | 462 | 463 | class IsFloatInfNeg(IsFloatInf): 464 | """ 465 | Checks that a value is float and negative infinite. 466 | 467 | Inherits from [`IsFloatInf`][dirty_equals.IsFloatInf]. 468 | 469 | ```py title="IsFloatInfNeg" 470 | from dirty_equals import IsFloatInfNeg 471 | 472 | assert -float('inf') == IsFloatInfNeg 473 | assert float('-inf') == IsFloatInfNeg 474 | assert float('inf') != IsFloatInfNeg 475 | assert -float('-inf') != IsFloatInfNeg 476 | ``` 477 | """ 478 | 479 | def __init__(self) -> None: 480 | super().__init__(lt=0) 481 | self._repr_kwargs = {} 482 | 483 | def equals(self, other: Any) -> bool: 484 | return self.bounds_checks(other) and super().equals(other) 485 | 486 | 487 | class IsFloatNan(IsFloat): 488 | """ 489 | Checks that a value is float and nan (not a number). 490 | 491 | Inherits from [`IsFloat`][dirty_equals.IsFloat]. 492 | 493 | ```py title="IsFloatNan" 494 | from dirty_equals import IsFloatNan 495 | 496 | assert float('nan') == IsFloatNan 497 | assert 1.0 != IsFloatNan 498 | ``` 499 | """ 500 | 501 | def equals(self, other: Any) -> bool: 502 | other = self.prepare(other) 503 | return math.isnan(other) 504 | -------------------------------------------------------------------------------- /dirty_equals/_other.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | import re 5 | from dataclasses import asdict, is_dataclass 6 | from enum import Enum 7 | from functools import lru_cache 8 | from ipaddress import IPv4Address, IPv4Network, IPv6Address, IPv6Network, ip_network 9 | from typing import TYPE_CHECKING, Any, Callable, Literal, TypeVar, Union, overload 10 | from uuid import UUID 11 | 12 | from ._base import DirtyEquals 13 | from ._dict import IsDict 14 | from ._utils import Omit, plain_repr 15 | 16 | if TYPE_CHECKING: 17 | from pydantic import TypeAdapter 18 | 19 | 20 | class IsUUID(DirtyEquals[UUID]): 21 | """ 22 | A class that checks if a value is a valid UUID, optionally checking UUID version. 23 | """ 24 | 25 | def __init__(self, version: Literal[None, 1, 2, 3, 4, 5] = None): 26 | """ 27 | Args: 28 | version: The version of the UUID to check, if omitted, all versions are accepted. 29 | 30 | ```py title="IsUUID" 31 | import uuid 32 | 33 | from dirty_equals import IsUUID 34 | 35 | assert 'edf9f29e-45c7-431c-99db-28ea44df9785' == IsUUID 36 | assert 'edf9f29e-45c7-431c-99db-28ea44df9785' == IsUUID(4) 37 | assert 'edf9f29e45c7431c99db28ea44df9785' == IsUUID(4) 38 | assert 'edf9f29e-45c7-431c-99db-28ea44df9785' != IsUUID(5) 39 | assert uuid.uuid4() == IsUUID(4) 40 | ``` 41 | """ 42 | self.version = version 43 | super().__init__(version or plain_repr('*')) 44 | 45 | def equals(self, other: Any) -> bool: 46 | if isinstance(other, UUID): 47 | uuid = other 48 | elif isinstance(other, str): 49 | uuid = UUID(other) 50 | if self.version is not None and uuid.version != self.version: 51 | return False 52 | else: 53 | return False 54 | 55 | if self.version: 56 | return uuid.version == self.version 57 | else: 58 | return True 59 | 60 | 61 | AnyJson = object 62 | JsonType = TypeVar('JsonType', AnyJson, Any) 63 | 64 | 65 | class IsJson(DirtyEquals[JsonType]): 66 | """ 67 | A class that checks if a value is a JSON object, and check the contents of the JSON. 68 | """ 69 | 70 | @overload 71 | def __init__(self, expected_value: JsonType = AnyJson): ... 72 | 73 | @overload 74 | def __init__(self, **expected_kwargs: Any): ... 75 | 76 | def __init__(self, expected_value: JsonType = AnyJson, **expected_kwargs: Any): 77 | """ 78 | Args: 79 | expected_value: Value to compare the JSON to, if omitted, any JSON is accepted. 80 | **expected_kwargs: Keyword arguments forming a dict to compare the JSON to, 81 | `expected_value` and `expected_kwargs` may not be combined. 82 | 83 | As with any `dirty_equals` type, types can be nested to provide more complex checks. 84 | 85 | !!! note 86 | Like [`IsInstance`][dirty_equals.IsInstance], `IsJson` can be parameterized or initialised with a value - 87 | `IsJson[xyz]` is exactly equivalent to `IsJson(xyz)`. 88 | 89 | This allows usage to be analogous to type hints. 90 | 91 | 92 | ```py title="IsJson" 93 | from dirty_equals import IsJson, IsPositiveInt, IsStrictDict 94 | 95 | assert '{"a": 1, "b": 2}' == IsJson 96 | assert '{"a": 1, "b": 2}' == IsJson(a=1, b=2) 97 | assert '{"a": 1}' != IsJson(a=2) 98 | assert 'invalid json' != IsJson 99 | assert '{"a": 1}' == IsJson(a=IsPositiveInt) 100 | assert '"just a quoted string"' == IsJson('just a quoted string') 101 | 102 | assert '{"a": 1, "b": 2}' == IsJson[IsStrictDict(a=1, b=2)] 103 | assert '{"b": 2, "a": 1}' != IsJson[IsStrictDict(a=1, b=2)] 104 | ``` 105 | """ 106 | if expected_kwargs: 107 | if expected_value is not AnyJson: 108 | raise TypeError('IsJson requires either an argument or kwargs, not both') 109 | self.expected_value: Any = expected_kwargs 110 | else: 111 | self.expected_value = expected_value 112 | super().__init__(plain_repr('*') if expected_value is AnyJson else expected_value) 113 | 114 | def __class_getitem__(cls, expected_type: JsonType) -> IsJson[JsonType]: 115 | return cls(expected_type) 116 | 117 | def equals(self, other: Any) -> bool: 118 | if isinstance(other, (str, bytes)): 119 | v = json.loads(other) 120 | if self.expected_value is AnyJson: 121 | return True 122 | else: 123 | return v == self.expected_value 124 | else: 125 | return False 126 | 127 | 128 | class FunctionCheck(DirtyEquals[Any]): 129 | """ 130 | Use a function to check if a value "equals" whatever you want to check 131 | """ 132 | 133 | def __init__(self, func: Callable[[Any], bool]): 134 | """ 135 | Args: 136 | func: callable that takes a value and returns a bool. 137 | 138 | ```py title="FunctionCheck" 139 | from dirty_equals import FunctionCheck 140 | 141 | def is_even(x): 142 | return x % 2 == 0 143 | 144 | assert 2 == FunctionCheck(is_even) 145 | assert 3 != FunctionCheck(is_even) 146 | ``` 147 | """ 148 | self.func = func 149 | super().__init__(plain_repr(func.__name__)) 150 | 151 | def equals(self, other: Any) -> bool: 152 | return self.func(other) 153 | 154 | 155 | T = TypeVar('T') 156 | 157 | 158 | @lru_cache 159 | def _build_type_adapter(ta: type[TypeAdapter[T]], schema: T) -> TypeAdapter[T]: 160 | return ta(schema) 161 | 162 | 163 | _allowed_url_attribute_checks: set[str] = { 164 | 'scheme', 165 | 'host', 166 | 'host_type', 167 | 'user', 168 | 'password', 169 | 'port', 170 | 'path', 171 | 'query', 172 | 'fragment', 173 | } 174 | 175 | 176 | class IsUrl(DirtyEquals[Any]): 177 | """ 178 | A class that checks if a value is a valid URL, optionally checking different URL types and attributes with 179 | [Pydantic](https://pydantic-docs.helpmanual.io/usage/types/#urls). 180 | """ 181 | 182 | def __init__( 183 | self, 184 | any_url: bool = False, 185 | any_http_url: bool = False, 186 | http_url: bool = False, 187 | file_url: bool = False, 188 | postgres_dsn: bool = False, 189 | ampqp_dsn: bool = False, 190 | redis_dsn: bool = False, 191 | **expected_attributes: Any, 192 | ): 193 | """ 194 | Args: 195 | any_url: any scheme allowed, host required 196 | any_http_url: scheme http or https, host required 197 | http_url: scheme http or https, host required, max length 2083 198 | file_url: scheme file, host not required 199 | postgres_dsn: user info required 200 | ampqp_dsn: schema amqp or amqps, user info not required, host not required 201 | redis_dsn: scheme redis or rediss, user info not required, host not required 202 | **expected_attributes: Expected values for url attributes 203 | ```py title="IsUrl" 204 | from dirty_equals import IsUrl 205 | 206 | assert 'https://example.com' == IsUrl 207 | assert 'https://example.com' == IsUrl(host='example.com') 208 | assert 'https://example.com' == IsUrl(scheme='https') 209 | assert 'https://example.com' != IsUrl(scheme='http') 210 | assert 'postgres://user:pass@localhost:5432/app' == IsUrl(postgres_dsn=True) 211 | assert 'postgres://user:pass@localhost:5432/app' != IsUrl(http_url=True) 212 | ``` 213 | """ 214 | try: 215 | from pydantic import ( 216 | AmqpDsn, 217 | AnyHttpUrl, 218 | AnyUrl, 219 | FileUrl, 220 | HttpUrl, 221 | PostgresDsn, 222 | RedisDsn, 223 | TypeAdapter, 224 | ValidationError, 225 | ) 226 | 227 | self.ValidationError = ValidationError 228 | except ImportError as e: # pragma: no cover 229 | raise ImportError('Pydantic V2 is not installed, run `pip install dirty-equals[pydantic]`') from e 230 | url_type_mappings = { 231 | AnyUrl: any_url, 232 | AnyHttpUrl: any_http_url, 233 | HttpUrl: http_url, 234 | FileUrl: file_url, 235 | PostgresDsn: postgres_dsn, 236 | AmqpDsn: ampqp_dsn, 237 | RedisDsn: redis_dsn, 238 | } 239 | url_types_sum = sum(url_type_mappings.values()) 240 | if url_types_sum == 0: 241 | url_type: Any = AnyUrl 242 | elif url_types_sum == 1: 243 | url_type = max(url_type_mappings, key=url_type_mappings.get) # type: ignore[arg-type] 244 | else: 245 | raise ValueError('You can only check against one Pydantic url type at a time') 246 | 247 | self.type_adapter = _build_type_adapter(TypeAdapter, url_type) 248 | 249 | for item in expected_attributes: 250 | if item not in _allowed_url_attribute_checks: 251 | raise TypeError( 252 | 'IsURL only checks these attributes: scheme, host, host_type, user, password, ' 253 | 'port, path, query, fragment' 254 | ) 255 | self.attribute_checks = expected_attributes 256 | super().__init__() 257 | 258 | def equals(self, other: Any) -> bool: 259 | try: 260 | other_url = self.type_adapter.validate_python(other) 261 | except self.ValidationError: 262 | raise ValueError('Invalid URL') 263 | 264 | # we now check that str() of the parsed URL equals its original value 265 | # so that invalid encodings fail 266 | # we remove trailing slashes since they're added by pydantic's URL parsing, but don't mean `other` is invalid 267 | other_url_str = str(other_url) 268 | if not other.endswith('/') and other_url_str.endswith('/'): 269 | other_url_str = other_url_str[:-1] 270 | equal = other_url_str == other 271 | 272 | if not self.attribute_checks: 273 | return equal 274 | 275 | for attribute, expected in self.attribute_checks.items(): 276 | if getattr(other_url, attribute) != expected: 277 | return False 278 | return equal 279 | 280 | 281 | HashTypes = Literal['md5', 'sha-1', 'sha-256'] 282 | 283 | 284 | class IsHash(DirtyEquals[str]): 285 | """ 286 | A class that checks if a value is a valid common hash type, using a simple length and allowed characters regex. 287 | """ 288 | 289 | def __init__(self, hash_type: HashTypes): 290 | """ 291 | Args: 292 | hash_type: The hash type to check. Must be specified. 293 | 294 | ```py title="IsHash" 295 | from dirty_equals import IsHash 296 | 297 | assert 'f1e069787ece74531d112559945c6871' == IsHash('md5') 298 | assert b'f1e069787ece74531d112559945c6871' == IsHash('md5') 299 | assert 'f1e069787ece74531d112559945c6871' != IsHash('sha-256') 300 | assert 'F1E069787ECE74531D112559945C6871' == IsHash('md5') 301 | assert '40bd001563085fc35165329ea1ff5c5ecbdbbeef' == IsHash('sha-1') 302 | assert 'a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3' == IsHash( 303 | 'sha-256' 304 | ) 305 | ``` 306 | """ 307 | 308 | allowed_hashes = HashTypes.__args__ # type: ignore[attr-defined] 309 | if hash_type not in allowed_hashes: 310 | raise ValueError(f"Hash type must be one of the following values: {', '.join(allowed_hashes)}") 311 | 312 | self.hash_type = hash_type 313 | super().__init__(hash_type) 314 | 315 | def equals(self, other: Any) -> bool: 316 | if isinstance(other, str): 317 | s = other 318 | elif isinstance(other, (bytes, bytearray)): 319 | s = other.decode() 320 | else: 321 | return False 322 | hash_type_regex_patterns = { 323 | 'md5': r'[a-fA-F\d]{32}', 324 | 'sha-1': r'[a-fA-F\d]{40}', 325 | 'sha-256': r'[a-fA-F\d]{64}', 326 | } 327 | return bool(re.fullmatch(hash_type_regex_patterns[self.hash_type], s)) 328 | 329 | 330 | IP = TypeVar('IP', IPv4Address, IPv4Network, IPv6Address, IPv6Network, Union[str, int, bytes]) 331 | 332 | 333 | class IsIP(DirtyEquals[IP]): 334 | """ 335 | A class that checks if a value is a valid IP address, optionally checking IP version, netmask. 336 | """ 337 | 338 | def __init__(self, *, version: Literal[None, 4, 6] = None, netmask: str | None = None): 339 | """ 340 | Args: 341 | version: The version of the IP to check, if omitted, versions 4 and 6 are both accepted. 342 | netmask: The netmask of the IP to check, if omitted, any netmask is accepted. Requires version. 343 | 344 | ```py title="IsIP" 345 | from ipaddress import IPv4Address, IPv4Network, IPv6Address 346 | 347 | from dirty_equals import IsIP 348 | 349 | assert '179.27.154.96' == IsIP 350 | assert '179.27.154.96' == IsIP(version=4) 351 | assert '2001:0db8:0a0b:12f0:0000:0000:0000:0001' == IsIP(version=6) 352 | assert IPv4Address('127.0.0.1') == IsIP 353 | assert IPv4Network('43.48.0.0/12') == IsIP 354 | assert IPv6Address('::eeff:ae3f:d473') == IsIP 355 | assert '54.43.53.219/10' == IsIP(version=4, netmask='255.192.0.0') 356 | assert '54.43.53.219/10' == IsIP(version=4, netmask=4290772992) 357 | assert '::ffff:aebf:d473/12' == IsIP(version=6, netmask='fff0::') 358 | assert 3232235521 == IsIP 359 | ``` 360 | """ 361 | self.version = version 362 | if netmask and not self.version: 363 | raise TypeError('To check the netmask you must specify the IP version') 364 | self.netmask = netmask 365 | super().__init__(version=version or Omit, netmask=netmask or Omit) 366 | 367 | def equals(self, other: Any) -> bool: 368 | if isinstance(other, (IPv4Network, IPv6Network)): 369 | ip = other 370 | elif isinstance(other, (str, bytes, int, IPv4Address, IPv6Address)): 371 | ip = ip_network(other, strict=False) 372 | else: 373 | return False 374 | 375 | if self.version: 376 | if self.netmask: 377 | version_check = self.version == ip.version 378 | address_format = {4: IPv4Address, 6: IPv6Address}[self.version] 379 | netmask_check = int(address_format(self.netmask)) == int(ip.netmask) 380 | return version_check and netmask_check 381 | elif self.version != ip.version: 382 | return False 383 | 384 | return True 385 | 386 | 387 | class IsDataclassType(DirtyEquals[Any]): 388 | """ 389 | Checks that an object is a dataclass type. 390 | 391 | Inherits from [`DirtyEquals`][dirty_equals.DirtyEquals]. 392 | 393 | ```py title="IsDataclassType" 394 | from dataclasses import dataclass 395 | from dirty_equals import IsDataclassType 396 | 397 | @dataclass 398 | class Foo: 399 | a: int 400 | b: int 401 | 402 | foo = Foo(1, 2) 403 | 404 | assert Foo == IsDataclassType 405 | assert foo != IsDataclassType 406 | ``` 407 | """ 408 | 409 | def equals(self, other: Any) -> bool: 410 | return is_dataclass(other) and isinstance(other, type) 411 | 412 | 413 | class IsDataclass(DirtyEquals[Any]): 414 | """ 415 | Checks that an object is an instance of a dataclass. 416 | 417 | Inherits from [`DirtyEquals`][dirty_equals.DirtyEquals] and it can be initialised with specific keyword arguments to 418 | check exactness of dataclass fields, by comparing the instance `__dict__` with [`IsDict`][dirty_equals.IsDict]. 419 | Moreover it is possible to check for strictness and partialness of the dataclass, by setting the `strict` and 420 | `partial` attributes using the `.settings(strict=..., partial=...)` method. 421 | 422 | Remark that passing no kwargs to `IsDataclass` initialization means fields are not checked, not that the dataclass 423 | is empty, namely `IsDataclass()` is the same as `IsDataclass`. 424 | 425 | ```py title="IsDataclass" 426 | from dataclasses import dataclass 427 | from dirty_equals import IsInt, IsDataclass 428 | 429 | @dataclass 430 | class Foo: 431 | a: int 432 | b: int 433 | c: str 434 | 435 | foo = Foo(1, 2, 'c') 436 | 437 | assert foo == IsDataclass 438 | assert foo == IsDataclass(a=IsInt, b=2, c='c') 439 | assert foo == IsDataclass(b=2, a=1).settings(partial=True) 440 | assert foo != IsDataclass(a=IsInt, b=2).settings(strict=True) 441 | assert foo == IsDataclass(a=IsInt, b=2).settings(strict=True, partial=True) 442 | assert foo != IsDataclass(b=2, a=1).settings(strict=True, partial=True) 443 | ``` 444 | """ 445 | 446 | def __init__(self, **fields: Any): 447 | """ 448 | Args: 449 | fields: key-value pairs of field-value to check for. 450 | """ 451 | self.strict = False 452 | self.partial = False 453 | self._post_init() 454 | super().__init__(**fields) 455 | 456 | def _post_init(self) -> None: 457 | pass 458 | 459 | def equals(self, other: Any) -> bool: 460 | if is_dataclass(other) and not isinstance(other, type): 461 | if self._repr_kwargs: 462 | return self._fields_check(other) 463 | else: 464 | return True 465 | else: 466 | return False 467 | 468 | def settings( 469 | self, 470 | *, 471 | strict: bool | None = None, 472 | partial: bool | None = None, 473 | ) -> IsDataclass: 474 | """Allows to customise the behaviour of `IsDataclass`, technically a new `IsDataclass` to allow chaining.""" 475 | new_cls = self.__class__(**self._repr_kwargs) 476 | new_cls.__dict__ = self.__dict__.copy() 477 | 478 | if strict is not None: 479 | new_cls.strict = strict 480 | if partial is not None: 481 | new_cls.partial = partial 482 | 483 | return new_cls 484 | 485 | def _fields_check(self, other: Any) -> bool: 486 | """ 487 | Checks exactness of fields using [`IsDict`][dirty_equals.IsDict] with given settings. 488 | 489 | Remark that if this method is called, then `other` is an instance of a dataclass, therefore we can call 490 | `dataclasses.asdict` to convert to a dict. 491 | """ 492 | return asdict(other) == IsDict(self._repr_kwargs).settings(strict=self.strict, partial=self.partial) 493 | 494 | 495 | class IsPartialDataclass(IsDataclass): 496 | """ 497 | Inherits from [`IsDataclass`][dirty_equals.IsDataclass] with `partial=True` by default. 498 | 499 | ```py title="IsPartialDataclass" 500 | from dataclasses import dataclass 501 | from dirty_equals import IsInt, IsPartialDataclass 502 | 503 | @dataclass 504 | class Foo: 505 | a: int 506 | b: int 507 | c: str = 'c' 508 | 509 | foo = Foo(1, 2, 'c') 510 | 511 | assert foo == IsPartialDataclass 512 | assert foo == IsPartialDataclass(a=1) 513 | assert foo == IsPartialDataclass(b=2, a=IsInt) 514 | assert foo != IsPartialDataclass(b=2, a=IsInt).settings(strict=True) 515 | assert Foo != IsPartialDataclass 516 | ``` 517 | """ 518 | 519 | def _post_init(self) -> None: 520 | self.partial = True 521 | 522 | 523 | class IsStrictDataclass(IsDataclass): 524 | """ 525 | Inherits from [`IsDataclass`][dirty_equals.IsDataclass] with `strict=True` by default. 526 | 527 | ```py title="IsStrictDataclass" 528 | from dataclasses import dataclass 529 | from dirty_equals import IsInt, IsStrictDataclass 530 | 531 | @dataclass 532 | class Foo: 533 | a: int 534 | b: int 535 | c: str = 'c' 536 | 537 | foo = Foo(1, 2, 'c') 538 | 539 | assert foo == IsStrictDataclass 540 | assert foo == IsStrictDataclass( 541 | a=IsInt, 542 | b=2, 543 | ).settings(partial=True) 544 | assert foo != IsStrictDataclass( 545 | a=IsInt, 546 | b=2, 547 | ).settings(partial=False) 548 | assert foo != IsStrictDataclass(b=2, a=IsInt, c='c') 549 | ``` 550 | """ 551 | 552 | def _post_init(self) -> None: 553 | self.strict = True 554 | 555 | 556 | class IsEnum(DirtyEquals[Enum]): 557 | """ 558 | Checks if an instance is an Enum. 559 | 560 | Inherits from [`DirtyEquals`][dirty_equals.DirtyEquals]. 561 | 562 | ```py title="IsEnum" 563 | from enum import Enum, auto 564 | from dirty_equals import IsEnum 565 | 566 | class ExampleEnum(Enum): 567 | a = auto() 568 | b = auto() 569 | 570 | a = ExampleEnum.a 571 | assert a == IsEnum 572 | assert a == IsEnum(ExampleEnum) 573 | ``` 574 | """ 575 | 576 | def __init__(self, enum_cls: type[Enum] = Enum): 577 | """ 578 | Args: 579 | enum_cls: Enum class to check against. 580 | """ 581 | self._enum_cls = enum_cls 582 | self._enum_values = {i.value for i in enum_cls} 583 | 584 | def equals(self, other: Any) -> bool: 585 | if isinstance(other, Enum): 586 | return isinstance(other, self._enum_cls) 587 | else: 588 | return other in self._enum_values 589 | -------------------------------------------------------------------------------- /dirty_equals/_sequence.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Any, Container, Dict, List, Optional, Sized, Tuple, Type, TypeVar, Union, overload 2 | 3 | from ._base import DirtyEquals 4 | from ._utils import Omit, plain_repr 5 | 6 | if TYPE_CHECKING: 7 | from typing import TypeAlias 8 | 9 | __all__ = 'HasLen', 'Contains', 'IsListOrTuple', 'IsList', 'IsTuple' 10 | T = TypeVar('T', List[Any], Tuple[Any, ...]) 11 | LengthType: 'TypeAlias' = 'Union[None, int, Tuple[int, Union[int, Any]]]' 12 | 13 | 14 | class HasLen(DirtyEquals[Sized]): 15 | """ 16 | Check that some has a given length, or length in a given range. 17 | """ 18 | 19 | @overload 20 | def __init__(self, length: int): ... 21 | 22 | @overload 23 | def __init__(self, min_length: int, max_length: Union[int, Any]): ... 24 | 25 | def __init__(self, min_length: int, max_length: Union[None, int, Any] = None): # type: ignore[misc] 26 | """ 27 | Args: 28 | min_length: Expected length if `max_length` is not given, else minimum length. 29 | max_length: Expected maximum length, use an ellipsis `...` to indicate that there's no maximum. 30 | 31 | ```py title="HasLen" 32 | from dirty_equals import HasLen 33 | 34 | assert [1, 2, 3] == HasLen(3) # (1)! 35 | assert '123' == HasLen(3, ...) # (2)! 36 | assert (1, 2, 3) == HasLen(3, 5) # (3)! 37 | assert (1, 2, 3) == HasLen(0, ...) # (4)! 38 | ``` 39 | 40 | 1. Length must be 3. 41 | 2. Length must be 3 or higher. 42 | 3. Length must be between 3 and 5 inclusive. 43 | 4. Length is required but can take any value. 44 | """ 45 | if max_length is None: 46 | self.length: LengthType = min_length 47 | super().__init__(self.length) 48 | else: 49 | self.length = (min_length, max_length) 50 | super().__init__(*_length_repr(self.length)) 51 | 52 | def equals(self, other: Any) -> bool: 53 | return _length_correct(self.length, other) 54 | 55 | 56 | class Contains(DirtyEquals[Container[Any]]): 57 | """ 58 | Check that an object contains one or more values. 59 | """ 60 | 61 | def __init__(self, contained_value: Any, *more_contained_values: Any): 62 | """ 63 | Args: 64 | contained_value: value that must be contained in the compared object. 65 | *more_contained_values: more values that must be contained in the compared object. 66 | 67 | ```py title="Contains" 68 | from dirty_equals import Contains 69 | 70 | assert [1, 2, 3] == Contains(1) 71 | assert [1, 2, 3] == Contains(1, 2) 72 | assert (1, 2, 3) == Contains(1) 73 | assert 'abc' == Contains('b') 74 | assert {'a': 1, 'b': 2} == Contains('a') 75 | assert [1, 2, 3] != Contains(10) 76 | ``` 77 | """ 78 | self.contained_values: Tuple[Any, ...] = (contained_value,) + more_contained_values 79 | super().__init__(*self.contained_values) 80 | 81 | def equals(self, other: Any) -> bool: 82 | return all(v in other for v in self.contained_values) 83 | 84 | 85 | class IsListOrTuple(DirtyEquals[T]): 86 | """ 87 | Check that some object is a list or tuple and optionally its values match some constraints. 88 | """ 89 | 90 | allowed_type: Union[Type[T], Tuple[Type[List[Any]], Type[Tuple[Any, ...]]]] = (list, tuple) 91 | 92 | @overload 93 | def __init__(self, *items: Any, check_order: bool = True, length: 'LengthType' = None): ... 94 | 95 | @overload 96 | def __init__(self, positions: Dict[int, Any], length: 'LengthType' = None): ... 97 | 98 | def __init__( 99 | self, 100 | *items: Any, 101 | positions: Optional[Dict[int, Any]] = None, 102 | check_order: bool = True, 103 | length: 'LengthType' = None, 104 | ): 105 | """ 106 | `IsListOrTuple` and its subclasses can be initialised in two ways: 107 | 108 | Args: 109 | *items: Positional members of an object to check. These must start from the zeroth position, but 110 | (depending on the value of `length`) may not include all values of the list/tuple being checked. 111 | check_order: Whether to enforce the order of the items. 112 | length (Union[int, Tuple[int, Union[int, Any]]]): length constraints, int or tuple matching the arguments 113 | of [`HasLen`][dirty_equals.HasLen]. 114 | 115 | or, 116 | 117 | Args: 118 | positions (Dict[int, Any]): Instead of `*items`, a dictionary of positions and 119 | values to check and be provided. 120 | length (Union[int, Tuple[int, Union[int, Any]]]): length constraints, int or tuple matching the arguments 121 | of [`HasLen`][dirty_equals.HasLen]. 122 | 123 | ```py title="IsListOrTuple" 124 | from dirty_equals import AnyThing, IsListOrTuple 125 | 126 | assert [1, 2, 3] == IsListOrTuple(1, 2, 3) 127 | assert (1, 3, 2) == IsListOrTuple(1, 2, 3, check_order=False) 128 | assert [{'a': 1}, {'a': 2}] == ( 129 | IsListOrTuple({'a': 2}, {'a': 1}, check_order=False) # (1)! 130 | ) 131 | assert [1, 2, 3, 3] != IsListOrTuple(1, 2, 3, check_order=False) # (2)! 132 | 133 | assert [1, 2, 3, 4, 5] == IsListOrTuple(1, 2, 3, length=...) # (3)! 134 | assert [1, 2, 3, 4, 5] != IsListOrTuple(1, 2, 3, length=(8, 10)) # (4)! 135 | 136 | assert ['a', 'b', 'c', 'd'] == (IsListOrTuple(positions={2: 'c', 3: 'd'})) # (5)! 137 | assert ['a', 'b', 'c', 'd'] == ( 138 | IsListOrTuple(positions={2: 'c', 3: 'd'}, length=4) # (6)! 139 | ) 140 | 141 | assert [1, 2, 3, 4] == IsListOrTuple(3, check_order=False, length=(0, ...)) # (7)! 142 | 143 | assert [1, 2, 3] == IsListOrTuple(AnyThing, AnyThing, 3) # (8)! 144 | ``` 145 | 146 | 1. Unlike using sets for comparison, we can do order-insensitive comparisons on objects that are not hashable. 147 | 2. And we won't get caught out by duplicate values 148 | 3. Here we're just checking the first 3 items, the compared list or tuple can be of any length 149 | 4. Compared list is not long enough 150 | 5. Compare using `positions`, here no length if enforced 151 | 6. Compare using `positions` but with a length constraint 152 | 7. Here we're just confirming that the value `3` is in the list 153 | 8. If you don't care about the first few values of a list or tuple, 154 | you can use [`AnyThing`][dirty_equals.AnyThing] in your arguments. 155 | """ 156 | if positions is not None: 157 | self.positions: Optional[Dict[int, Any]] = positions 158 | if items: 159 | raise TypeError(f'{self.__class__.__name__} requires either args or positions, not both') 160 | if not check_order: 161 | raise TypeError('check_order=False is not compatible with positions') 162 | else: 163 | self.positions = None 164 | self.items = items 165 | self.check_order = check_order 166 | 167 | self.length: Any = length 168 | if self.length is not None and not isinstance(self.length, int): 169 | if self.length == Ellipsis: 170 | self.length = 0, ... 171 | else: 172 | self.length = tuple(self.length) 173 | 174 | super().__init__( 175 | *items, 176 | positions=Omit if positions is None else positions, 177 | length=_length_repr(self.length), 178 | check_order=self.check_order and Omit, 179 | ) 180 | 181 | def equals(self, other: Any) -> bool: 182 | if not isinstance(other, self.allowed_type): 183 | return False 184 | 185 | if not _length_correct(self.length, other): 186 | return False 187 | 188 | if self.check_order: 189 | if self.positions is None: 190 | if self.length is None: 191 | return list(self.items) == list(other) 192 | else: 193 | return list(self.items) == list(other[: len(self.items)]) 194 | else: 195 | return all(v == other[k] for k, v in self.positions.items()) 196 | else: 197 | # order insensitive comparison 198 | # if we haven't checked length yet, check it now 199 | if self.length is None and len(other) != len(self.items): 200 | return False 201 | 202 | other_copy = list(other) 203 | for item in self.items: 204 | try: 205 | other_copy.remove(item) 206 | except ValueError: 207 | return False 208 | return True 209 | 210 | 211 | class IsList(IsListOrTuple[List[Any]]): 212 | """ 213 | All the same functionality as [`IsListOrTuple`][dirty_equals.IsListOrTuple], but the compared value must be a list. 214 | 215 | ```py title="IsList" 216 | from dirty_equals import IsList 217 | 218 | assert [1, 2, 3] == IsList(1, 2, 3) 219 | assert [1, 2, 3] == IsList(positions={2: 3}) 220 | assert [1, 2, 3] == IsList(1, 2, 3, check_order=False) 221 | assert [1, 2, 3, 4] == IsList(1, 2, 3, length=4) 222 | assert [1, 2, 3, 4] == IsList(1, 2, 3, length=(4, 5)) 223 | assert [1, 2, 3, 4] == IsList(1, 2, 3, length=...) 224 | 225 | assert (1, 2, 3) != IsList(1, 2, 3) 226 | ``` 227 | """ 228 | 229 | allowed_type = list 230 | 231 | 232 | class IsTuple(IsListOrTuple[Tuple[Any, ...]]): 233 | """ 234 | All the same functionality as [`IsListOrTuple`][dirty_equals.IsListOrTuple], but the compared value must be a tuple. 235 | 236 | ```py title="IsTuple" 237 | from dirty_equals import IsTuple 238 | 239 | assert (1, 2, 3) == IsTuple(1, 2, 3) 240 | assert (1, 2, 3) == IsTuple(positions={2: 3}) 241 | assert (1, 2, 3) == IsTuple(1, 2, 3, check_order=False) 242 | assert (1, 2, 3, 4) == IsTuple(1, 2, 3, length=4) 243 | assert (1, 2, 3, 4) == IsTuple(1, 2, 3, length=(4, 5)) 244 | assert (1, 2, 3, 4) == IsTuple(1, 2, 3, length=...) 245 | 246 | assert [1, 2, 3] != IsTuple(1, 2, 3) 247 | ``` 248 | """ 249 | 250 | allowed_type = tuple 251 | 252 | 253 | def _length_repr(length: 'LengthType') -> Any: 254 | if length is None: 255 | return Omit 256 | elif isinstance(length, int): 257 | return length 258 | else: 259 | if len(length) != 2: 260 | raise TypeError(f'length must be a tuple of length 2, not {len(length)}') 261 | max_value = length[1] if isinstance(length[1], int) else plain_repr('...') 262 | return length[0], max_value 263 | 264 | 265 | def _length_correct(length: 'LengthType', other: 'Sized') -> bool: 266 | if isinstance(length, int): 267 | if len(other) != length: 268 | return False 269 | elif isinstance(length, tuple): 270 | other_len = len(other) 271 | min_length, max_length = length 272 | if other_len < min_length: 273 | return False 274 | if isinstance(max_length, int) and other_len > max_length: 275 | return False 276 | return True 277 | -------------------------------------------------------------------------------- /dirty_equals/_strings.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import Any, Literal, Optional, Pattern, Tuple, Type, TypeVar, Union 3 | 4 | from ._base import DirtyEquals 5 | from ._utils import Omit, plain_repr 6 | 7 | T = TypeVar('T', str, bytes) 8 | 9 | __all__ = 'IsStr', 'IsBytes', 'IsAnyStr' 10 | 11 | 12 | class IsAnyStr(DirtyEquals[T]): 13 | """ 14 | Comparison of `str` or `bytes` objects. 15 | 16 | This class allow comparison with both `str` and `bytes` but is subclassed 17 | by [`IsStr`][dirty_equals.IsStr] and [`IsBytes`][dirty_equals.IsBytes] which restrict comparison to 18 | `str` or `bytes` respectively. 19 | """ 20 | 21 | expected_types: Tuple[Type[Any], ...] = (str, bytes) 22 | 23 | def __init__( 24 | self, 25 | *, 26 | min_length: Optional[int] = None, 27 | max_length: Optional[int] = None, 28 | case: Literal['upper', 'lower', None] = None, 29 | regex: Union[None, T, Pattern[T]] = None, 30 | regex_flags: int = 0, 31 | ): 32 | """ 33 | Args: 34 | min_length: minimum length of the string/bytes 35 | max_length: maximum length of the string/bytes 36 | case: check case of the string/bytes 37 | regex: regular expression to match the string/bytes with, `re.fullmatch` is used. 38 | This can be a compiled regex, or a string or bytes. 39 | regex_flags: optional flags for the regular expression 40 | 41 | Examples: 42 | ```py title="IsAnyStr" 43 | from dirty_equals import IsAnyStr 44 | 45 | assert 'foobar' == IsAnyStr() 46 | assert b'foobar' == IsAnyStr() 47 | assert 123 != IsAnyStr() 48 | assert 'foobar' == IsAnyStr(regex='foo...') 49 | assert 'foobar' == IsAnyStr(regex=b'foo...') # (1)! 50 | 51 | assert 'foobar' == IsAnyStr(min_length=6) 52 | assert 'foobar' != IsAnyStr(min_length=8) 53 | 54 | assert 'foobar' == IsAnyStr(case='lower') 55 | assert 'Foobar' != IsAnyStr(case='lower') 56 | ``` 57 | 58 | 1. `regex` can be either a string or bytes, `IsAnyStr` will take care of conversion so checks work. 59 | """ 60 | self.min_length = min_length 61 | self.max_length = max_length 62 | self.case = case 63 | self._flex = len(self.expected_types) > 1 64 | if regex is None: 65 | self.regex: Union[None, T, Pattern[T]] = None 66 | self.regex_flags: int = 0 67 | else: 68 | self.regex, self.regex_flags = self._prepare_regex(regex, regex_flags) 69 | super().__init__( 70 | min_length=Omit if min_length is None else min_length, 71 | max_length=Omit if max_length is None else max_length, 72 | case=case or Omit, 73 | regex=regex or Omit, 74 | regex_flags=Omit if regex_flags == 0 else plain_repr(repr(re.RegexFlag(regex_flags))), 75 | ) 76 | 77 | def equals(self, other: Any) -> bool: 78 | if type(other) not in self.expected_types: 79 | return False 80 | 81 | if self.regex is not None: 82 | if self._flex and isinstance(other, str): 83 | other = other.encode() 84 | 85 | if not re.fullmatch(self.regex, other, flags=self.regex_flags): 86 | return False 87 | 88 | len_ = len(other) 89 | if self.min_length is not None and len_ < self.min_length: 90 | return False 91 | 92 | if self.max_length is not None and len_ > self.max_length: 93 | return False 94 | 95 | if self.case == 'upper' and not other.isupper(): 96 | return False 97 | 98 | if self.case == 'lower' and not other.islower(): 99 | return False 100 | 101 | return True 102 | 103 | def _prepare_regex(self, regex: Union[T, Pattern[T]], regex_flags: int) -> Tuple[Union[T, Pattern[T]], int]: 104 | if isinstance(regex, re.Pattern): 105 | if self._flex: 106 | # less performant, but more flexible 107 | if regex_flags == 0 and regex.flags != re.UNICODE: 108 | regex_flags = regex.flags & ~re.UNICODE 109 | regex = regex.pattern 110 | 111 | elif regex_flags != 0: 112 | regex = regex.pattern 113 | 114 | if self._flex and isinstance(regex, str): 115 | regex = regex.encode() # type: ignore[assignment] 116 | 117 | return regex, regex_flags 118 | 119 | 120 | class IsStr(IsAnyStr[str]): 121 | """ 122 | Checks if the value is a string, and optionally meets some constraints. 123 | 124 | `IsStr` is a subclass of [`IsAnyStr`][dirty_equals.IsAnyStr] and therefore allows all the same arguments. 125 | 126 | Examples: 127 | ```py title="IsStr" 128 | from dirty_equals import IsStr 129 | 130 | assert 'foobar' == IsStr() 131 | assert b'foobar' != IsStr() 132 | assert 'foobar' == IsStr(regex='foo...') 133 | 134 | assert 'FOOBAR' == IsStr(min_length=5, max_length=10, case='upper') 135 | ``` 136 | """ 137 | 138 | expected_types = (str,) 139 | 140 | 141 | class IsBytes(IsAnyStr[bytes]): 142 | """ 143 | Checks if the value is a bytes object, and optionally meets some constraints. 144 | 145 | `IsBytes` is a subclass of [`IsAnyStr`][dirty_equals.IsAnyStr] and therefore allows all the same arguments. 146 | 147 | Examples: 148 | ```py title="IsBytes" 149 | from dirty_equals import IsBytes 150 | 151 | assert b'foobar' == IsBytes() 152 | assert 'foobar' != IsBytes() 153 | assert b'foobar' == IsBytes(regex=b'foo...') 154 | 155 | assert b'FOOBAR' == IsBytes(min_length=5, max_length=10, case='upper') 156 | ``` 157 | """ 158 | 159 | expected_types = (bytes,) 160 | -------------------------------------------------------------------------------- /dirty_equals/_utils.py: -------------------------------------------------------------------------------- 1 | __all__ = 'plain_repr', 'PlainRepr', 'Omit', 'get_dict_arg' 2 | 3 | from typing import Any, Dict, Tuple 4 | 5 | 6 | class PlainRepr: 7 | """ 8 | Hack to allow repr of string without quotes. 9 | """ 10 | 11 | def __init__(self, v: str): 12 | self.v = v 13 | 14 | def __repr__(self) -> str: 15 | return self.v 16 | 17 | 18 | def plain_repr(v: str) -> PlainRepr: 19 | return PlainRepr(v) 20 | 21 | 22 | # used to omit arguments from repr 23 | Omit = object() 24 | 25 | 26 | def get_dict_arg( 27 | name: str, expected_args: Tuple[Dict[Any, Any], ...], expected_kwargs: Dict[str, Any] 28 | ) -> Dict[Any, Any]: 29 | """ 30 | Used to enforce init logic similar to `dict(...)`. 31 | """ 32 | if expected_kwargs: 33 | value = expected_kwargs 34 | if expected_args: 35 | raise TypeError(f'{name} requires either a single argument or kwargs, not both') 36 | elif not expected_args: 37 | value = {} 38 | elif len(expected_args) == 1: 39 | value = expected_args[0] 40 | 41 | if not isinstance(value, dict): 42 | raise TypeError(f'expected_values must be a dict, got {type(value)}') 43 | else: 44 | raise TypeError(f'{name} expected at most 1 argument, got {len(expected_args)}') 45 | 46 | return value 47 | -------------------------------------------------------------------------------- /dirty_equals/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samuelcolvin/dirty-equals/9e6f0be981f982fdaae16671ab14aefc3ab8664a/dirty_equals/py.typed -------------------------------------------------------------------------------- /dirty_equals/version.py: -------------------------------------------------------------------------------- 1 | VERSION = '0.9.0' 2 | -------------------------------------------------------------------------------- /docs/CNAME: -------------------------------------------------------------------------------- 1 | dirty-equals.helpmanual.io 2 | -------------------------------------------------------------------------------- /docs/img/dirty-equals-logo-base.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 16 | 40 | 42 | 47 | 53 | 62 | 71 | dirty-equals 82 | 83 | 84 | -------------------------------------------------------------------------------- /docs/img/dirty-equals-logo-favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | 43 | 45 | 50 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /docs/img/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samuelcolvin/dirty-equals/9e6f0be981f982fdaae16671ab14aefc3ab8664a/docs/img/favicon.png -------------------------------------------------------------------------------- /docs/img/logo-text.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /docs/img/logo-white.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 |

2 | dirty-equals 3 |

4 |

5 | Doing dirty (but extremely useful) things with equals. 6 |

7 |

8 | 9 | CI 10 | 11 | 12 | Coverage 13 | 14 | 15 | pypi 16 | 17 | 18 | versions 19 | 20 | 21 | license 22 | 23 |

24 | 25 | --- 26 | 27 | {{ version }} 28 | 29 | **dirty-equals** is a python library that (mis)uses the `__eq__` method to make python code (generally unit tests) 30 | more declarative and therefore easier to read and write. 31 | 32 | *dirty-equals* can be used in whatever context you like, but it comes into its own when writing unit tests for 33 | applications where you're commonly checking the response to API calls and the contents of a database. 34 | 35 | ## Usage 36 | 37 | Here's a trivial example of what *dirty-equals* can do: 38 | 39 | ```{.py title="Trivial Usage" test="skip"} 40 | from dirty_equals import IsPositive 41 | 42 | assert 1 == IsPositive # (1)! 43 | assert -2 == IsPositive # this will fail! (2) 44 | ``` 45 | 46 | 1. This `assert` will pass since `1` is indeed positive, so the result of `1 == IsPositive` is `True`. 47 | 2. This will fail (raise a `AssertionError`) since `-2` is not positive, 48 | so the result of `-2 == IsPositive` is `False`. 49 | 50 | **Not that interesting yet!**, but consider the following unit test code using **dirty-equals**: 51 | 52 | ```py title="More Powerful Usage" lint="skip" 53 | from dirty_equals import IsJson, IsNow, IsPositiveInt, IsStr 54 | 55 | 56 | def test_user_endpoint(client: 'HttpClient', db_conn: 'Database'): 57 | client.post('/users/create/', data=...) 58 | 59 | user_data = db_conn.fetchrow('select * from users') 60 | assert user_data == { 61 | 'id': IsPositiveInt, # (1)! 62 | 'username': 'samuelcolvin', # (2)! 63 | 'avatar_file': IsStr(regex=r'/[a-z0-9\-]{10}/example\.png'), # (3)! 64 | 'settings_json': IsJson({'theme': 'dark', 'language': 'en'}), # (4)! 65 | 'created_ts': IsNow(delta=3), # (5)! 66 | } 67 | ``` 68 | 69 | 1. We don't actually care what the `id` is, just that it's present, it's an `int` and it's positive. 70 | 2. We can use a normal key and value here since we know exactly what value `username` should have before we test it. 71 | 3. `avatar_file` is a string, but we don't know all of the string before the `assert`, 72 | just the format (regex) it should match. 73 | 4. `settings_json` is a `JSON` string, but it's simpler and more robust to confirm it represents a particular python 74 | object rather than compare strings. 75 | 5. `created_at` is a `datetime`, although we don't know (or care) about its exact value; 76 | since the user was just created we know it must be close to now. `delta` is optional, it defaults to 2 seconds. 77 | 78 | Without **dirty-equals**, you'd have to compare individual fields and/or modify some fields before comparison 79 | - the test would not be declarative or as clear. 80 | 81 | **dirty-equals** can do so much more than that, for example: 82 | 83 | * [`IsPartialDict`][dirty_equals.IsPartialDict] lets you compare a subset of a dictionary 84 | * [`IsStrictDict`][dirty_equals.IsStrictDict] lets you confirm order in a dictionary 85 | * [`IsList`][dirty_equals.IsList] and [`IsTuple`][dirty_equals.IsTuple] lets you compare partial lists and tuples, 86 | with or without order constraints 87 | * nesting any of these types inside any others 88 | * [`IsInstance`][dirty_equals.IsInstance] lets you simply confirm the type of an object 89 | * You can even use [boolean operators](./usage.md#boolean-logic) `|` and `&` to combine multiple conditions 90 | * and much more... 91 | 92 | ## Installation 93 | 94 | Simply: 95 | 96 | ```bash 97 | pip install dirty-equals 98 | ``` 99 | 100 | **dirty-equals** requires **Python 3.8+**. 101 | -------------------------------------------------------------------------------- /docs/internals.md: -------------------------------------------------------------------------------- 1 | # Internals 2 | ## How the magic of `DirtyEquals.__eq__` works? 3 | When you call `x == y`, Python first calls `x.__eq__(y)`. This would not help us 4 | much, because we would have to keep an eye on order of the arguments when 5 | comparing to `DirtyEquals` objects. But that's where were another feature of 6 | Python comes in. 7 | 8 | When `x.__eq__(y)` returns the `NotImplemented` object, then Python will try to 9 | call `y.__eq__(x)`. Objects in the standard library return that value when they 10 | don't know how to compare themselves to objects of `type(y)` (Without checking 11 | the C source I can't be certain if this assumption holds for all classes, but it 12 | works for all the basic ones). 13 | In [`pathlib.PurePath`](https://github.com/python/cpython/blob/aebbd7579a421208f48dd6884b67dbd3278b71ad/Lib/pathlib.py#L751) 14 | you can see an example how that is implemented in Python. 15 | 16 | > By default, object implements `__eq__()` by using `is`, 17 | > returning `NotImplemented` in the case of a false comparison: 18 | > `True if x is y else NotImplemented`. 19 | 20 | See the Python documentation for more information ([`object.__eq__`](https://docs.python.org/3/reference/datamodel.html#object.__eq__)). 21 | -------------------------------------------------------------------------------- /docs/plugins.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import re 4 | 5 | from mkdocs.config import Config 6 | from mkdocs.structure.files import Files 7 | from mkdocs.structure.pages import Page 8 | 9 | try: 10 | import pytest 11 | except ImportError: 12 | pytest = None 13 | 14 | logger = logging.getLogger('mkdocs.test_examples') 15 | 16 | 17 | def on_pre_build(config: Config): 18 | pass 19 | 20 | 21 | def on_files(files: Files, config: Config) -> Files: 22 | return remove_files(files) 23 | 24 | 25 | def remove_files(files: Files) -> Files: 26 | to_remove = [] 27 | for file in files: 28 | if file.src_path in {'plugins.py'}: 29 | to_remove.append(file) 30 | elif file.src_path.startswith('__pycache__/'): 31 | to_remove.append(file) 32 | 33 | logger.debug('removing files: %s', [f.src_path for f in to_remove]) 34 | for f in to_remove: 35 | files.remove(f) 36 | 37 | return files 38 | 39 | 40 | def on_page_markdown(markdown: str, page: Page, config: Config, files: Files) -> str: 41 | markdown = remove_code_fence_attributes(markdown) 42 | return add_version(markdown, page) 43 | 44 | 45 | def add_version(markdown: str, page: Page) -> str: 46 | if page.file.src_uri == 'index.md': 47 | version_ref = os.getenv('GITHUB_REF') 48 | if version_ref and version_ref.startswith('refs/tags/'): 49 | version = re.sub('^refs/tags/', '', version_ref.lower()) 50 | url = f'https://github.com/samuelcolvin/dirty-equals/releases/tag/{version}' 51 | version_str = f'Documentation for version: [{version}]({url})' 52 | elif sha := os.getenv('GITHUB_SHA'): 53 | sha = sha[:7] 54 | url = f'https://github.com/samuelcolvin/dirty-equals/commit/{sha}' 55 | version_str = f'Documentation for development version: [{sha}]({url})' 56 | else: 57 | version_str = 'Documentation for development version' 58 | markdown = re.sub(r'{{ *version *}}', version_str, markdown) 59 | return markdown 60 | 61 | 62 | def remove_code_fence_attributes(markdown: str) -> str: 63 | """ 64 | There's no way to add attributes to code fences that works with both pycharm and mkdocs, hence we use 65 | `py key="value"` to provide attributes to pytest-examples, then remove those attributes here. 66 | 67 | https://youtrack.jetbrains.com/issue/IDEA-297873 & https://python-markdown.github.io/extensions/fenced_code_blocks/ 68 | """ 69 | 70 | def remove_attrs(match: re.Match[str]) -> str: 71 | suffix = re.sub( 72 | r' (?:test|lint|upgrade|group|requires|output|rewrite_assert)=".+?"', '', match.group(2), flags=re.M 73 | ) 74 | return f'{match.group(1)}{suffix}' 75 | 76 | return re.sub(r'^( *``` *py)(.*)', remove_attrs, markdown, flags=re.M) 77 | -------------------------------------------------------------------------------- /docs/types/boolean.md: -------------------------------------------------------------------------------- 1 | # Boolean Types 2 | 3 | ::: dirty_equals.IsTrueLike 4 | 5 | ::: dirty_equals.IsFalseLike 6 | -------------------------------------------------------------------------------- /docs/types/custom.md: -------------------------------------------------------------------------------- 1 | # Custom Types 2 | 3 | ::: dirty_equals._base.DirtyEquals 4 | options: 5 | merge_init_into_class: false 6 | 7 | ## Custom Type Example 8 | 9 | To demonstrate the use of custom types, we'll create a custom type that matches any even number. 10 | 11 | We won't inherit from [`IsNumeric`][dirty_equals.IsNumeric] in this case to keep the example simple. 12 | 13 | ```py title="IsEven" 14 | from decimal import Decimal 15 | from typing import Any, Union 16 | 17 | from dirty_equals import DirtyEquals, IsOneOf 18 | 19 | 20 | class IsEven(DirtyEquals[Union[int, float, Decimal]]): 21 | def equals(self, other: Any) -> bool: 22 | return other % 2 == 0 23 | 24 | 25 | assert 2 == IsEven 26 | assert 3 != IsEven 27 | assert 'foobar' != IsEven 28 | assert 3 == IsEven | IsOneOf(3) 29 | ``` 30 | 31 | There are a few advantages of inheriting from [`DirtyEquals`][dirty_equals.DirtyEquals] compared to just 32 | implementing your own class with an `__eq__` method: 33 | 34 | 1. `TypeError` and `ValueError` in `equals` are caught and result in a not-equals result. 35 | 2. A useful `__repr__` is generated, and modified if the `==` operation returns `True`, 36 | see [pytest compatibility](../usage.md#__repr__-and-pytest-compatibility) 37 | 3. [boolean logic](../usage.md#boolean-logic) works out of the box 38 | 4. [Uninitialised usage](../usage.md#initialised-vs-class-comparison) 39 | (`IsEven` rather than `IsEven()`) works out of the box 40 | -------------------------------------------------------------------------------- /docs/types/datetime.md: -------------------------------------------------------------------------------- 1 | # Date and Time Types 2 | 3 | ::: dirty_equals.IsDatetime 4 | 5 | ### Timezones 6 | 7 | Timezones are hard, anyone who claims otherwise is either a genius, a liar, or an idiot. 8 | 9 | `IsDatetime` and its subtypes (e.g. [`IsNow`][dirty_equals.IsNow]) can be used in two modes, 10 | based on the `enforce_tz` parameter: 11 | 12 | * `enforce_tz=True` (the default): 13 | * if the datetime wrapped by `IsDatetime` is timezone naive, the compared value must also be timezone naive. 14 | * if the datetime wrapped by `IsDatetime` has a timezone, the compared value must have a 15 | timezone with the same offset. 16 | * `enforce_tz=False`: 17 | * if the datetime wrapped by `IsDatetime` is timezone naive, the compared value can either be naive or have a 18 | timezone all that matters is the datetime values match. 19 | * if the datetime wrapped by `IsDatetime` has a timezone, the compared value needs to represent the same point in 20 | time - either way it must have a timezone. 21 | 22 | Example 23 | 24 | ```py title="IsDatetime & timezones" requires="3.9" 25 | from datetime import datetime 26 | from zoneinfo import ZoneInfo 27 | 28 | from dirty_equals import IsDatetime 29 | 30 | tz_london = ZoneInfo('Europe/London') 31 | new_year_london = datetime(2000, 1, 1, tzinfo=tz_london) 32 | 33 | tz_nyc = ZoneInfo('America/New_York') 34 | new_year_eve_nyc = datetime(1999, 12, 31, 19, 0, 0, tzinfo=tz_nyc) 35 | 36 | assert new_year_eve_nyc == IsDatetime(approx=new_year_london, enforce_tz=False) 37 | assert new_year_eve_nyc != IsDatetime(approx=new_year_london, enforce_tz=True) 38 | 39 | new_year_naive = datetime(2000, 1, 1) 40 | 41 | assert new_year_naive != IsDatetime(approx=new_year_london, enforce_tz=False) 42 | assert new_year_naive != IsDatetime(approx=new_year_eve_nyc, enforce_tz=False) 43 | assert new_year_london == IsDatetime(approx=new_year_naive, enforce_tz=False) 44 | assert new_year_eve_nyc != IsDatetime(approx=new_year_naive, enforce_tz=False) 45 | ``` 46 | 47 | ::: dirty_equals.IsNow 48 | 49 | ::: dirty_equals.IsDate 50 | 51 | ::: dirty_equals.IsToday 52 | -------------------------------------------------------------------------------- /docs/types/dict.md: -------------------------------------------------------------------------------- 1 | # Dictionary Types 2 | 3 | ::: dirty_equals.IsDict 4 | 5 | ::: dirty_equals.IsPartialDict 6 | 7 | ::: dirty_equals.IsIgnoreDict 8 | 9 | ::: dirty_equals.IsStrictDict 10 | -------------------------------------------------------------------------------- /docs/types/inspection.md: -------------------------------------------------------------------------------- 1 | # Type Inspection 2 | 3 | ::: dirty_equals.IsInstance 4 | 5 | ::: dirty_equals.HasName 6 | 7 | ::: dirty_equals.HasRepr 8 | 9 | ::: dirty_equals.HasAttributes 10 | -------------------------------------------------------------------------------- /docs/types/numeric.md: -------------------------------------------------------------------------------- 1 | # Numeric Types 2 | 3 | ::: dirty_equals.IsInt 4 | options: 5 | merge_init_into_class: false 6 | separate_signature: false 7 | 8 | ::: dirty_equals.IsFloat 9 | options: 10 | merge_init_into_class: false 11 | separate_signature: false 12 | 13 | ::: dirty_equals.IsPositive 14 | options: 15 | merge_init_into_class: false 16 | 17 | ::: dirty_equals.IsNegative 18 | options: 19 | merge_init_into_class: false 20 | 21 | ::: dirty_equals.IsNonNegative 22 | options: 23 | merge_init_into_class: false 24 | 25 | ::: dirty_equals.IsNonPositive 26 | options: 27 | merge_init_into_class: false 28 | 29 | ::: dirty_equals.IsPositiveInt 30 | options: 31 | merge_init_into_class: false 32 | 33 | ::: dirty_equals.IsNegativeInt 34 | options: 35 | merge_init_into_class: false 36 | 37 | ::: dirty_equals.IsPositiveFloat 38 | options: 39 | merge_init_into_class: false 40 | 41 | ::: dirty_equals.IsNegativeFloat 42 | options: 43 | merge_init_into_class: false 44 | 45 | ::: dirty_equals.IsFloatInf 46 | options: 47 | merge_init_into_class: false 48 | 49 | ::: dirty_equals.IsFloatInfPos 50 | options: 51 | merge_init_into_class: false 52 | 53 | ::: dirty_equals.IsFloatInfNeg 54 | options: 55 | merge_init_into_class: false 56 | 57 | ::: dirty_equals.IsFloatNan 58 | options: 59 | merge_init_into_class: false 60 | 61 | ::: dirty_equals.IsApprox 62 | 63 | ::: dirty_equals.IsNumber 64 | options: 65 | merge_init_into_class: false 66 | 67 | ::: dirty_equals.IsNumeric 68 | -------------------------------------------------------------------------------- /docs/types/other.md: -------------------------------------------------------------------------------- 1 | # Other Types 2 | 3 | ::: dirty_equals.FunctionCheck 4 | 5 | ::: dirty_equals.IsInstance 6 | 7 | ::: dirty_equals.IsJson 8 | 9 | ::: dirty_equals.IsUUID 10 | 11 | ::: dirty_equals.AnyThing 12 | 13 | ::: dirty_equals.IsOneOf 14 | 15 | ::: dirty_equals.IsUrl 16 | 17 | ::: dirty_equals.IsHash 18 | 19 | ::: dirty_equals.IsIP 20 | 21 | ::: dirty_equals.IsDataclassType 22 | 23 | ::: dirty_equals.IsDataclass 24 | 25 | ::: dirty_equals.IsPartialDataclass 26 | 27 | ::: dirty_equals.IsStrictDataclass 28 | 29 | ::: dirty_equals.IsEnum 30 | -------------------------------------------------------------------------------- /docs/types/sequence.md: -------------------------------------------------------------------------------- 1 | # Sequence Types 2 | 3 | ::: dirty_equals.IsListOrTuple 4 | 5 | ::: dirty_equals.IsList 6 | 7 | ::: dirty_equals.IsTuple 8 | 9 | ::: dirty_equals.HasLen 10 | 11 | ::: dirty_equals.Contains 12 | -------------------------------------------------------------------------------- /docs/types/string.md: -------------------------------------------------------------------------------- 1 | # String Types 2 | 3 | ::: dirty_equals.IsAnyStr 4 | 5 | ::: dirty_equals.IsStr 6 | 7 | ::: dirty_equals.IsBytes 8 | -------------------------------------------------------------------------------- /docs/usage.md: -------------------------------------------------------------------------------- 1 | ## Boolean Logic 2 | 3 | *dirty-equals* types can be combined based on either `&` 4 | (and, all checks must be `True` for the combined check to be `True`) or `|` 5 | (or, any check can be `True` for the combined check to be `True`). 6 | 7 | Types can also be inverted using the `~` operator, this is equivalent to using `!=` instead of `==`. 8 | 9 | Example: 10 | ```py title="Boolean Combination of Types" 11 | from dirty_equals import Contains, HasLen 12 | 13 | assert ['a', 'b', 'c'] == HasLen(3) & Contains('a') # (1)! 14 | assert ['a', 'b', 'c'] == HasLen(3) | Contains('z') # (2)! 15 | 16 | assert ['a', 'b', 'c'] != Contains('z') 17 | assert ['a', 'b', 'c'] == ~Contains('z') 18 | ``` 19 | 20 | 1. The object on the left has to both have length 3 **and** contain `"a"` 21 | 2. The object on the left has to either have length 3 **or** contain `"z"` 22 | 23 | ## Initialised vs. Class comparison 24 | 25 | !!! warning 26 | 27 | This does not work with PyPy. 28 | 29 | *dirty-equals* allows comparison with types regardless of whether they've been initialised. 30 | 31 | This saves users adding `()` in lots of places. 32 | 33 | Example: 34 | 35 | ```py title="Initialised vs. Uninitialised" 36 | from dirty_equals import IsInt 37 | 38 | # these two cases are the same 39 | assert 1 == IsInt 40 | assert 1 == IsInt() 41 | ``` 42 | 43 | !!! Note 44 | Types that require at least on argument when being initialised (like [`IsApprox`][dirty_equals.IsApprox]) 45 | cannot be used like this, comparisons will just return `False`. 46 | 47 | ## `__repr__` and pytest compatibility 48 | 49 | dirty-equals types have reasonable `__repr__` methods, which describe types and generally are a close match 50 | of how they would be created: 51 | 52 | ```py title="__repr__" 53 | from dirty_equals import IsApprox, IsInt 54 | 55 | assert repr(IsInt) == 'IsInt' 56 | assert repr(IsInt()) == 'IsInt()' 57 | assert repr(IsApprox(42)) == 'IsApprox(approx=42)' 58 | ``` 59 | 60 | However, the repr method of types changes when an equals (`==`) operation on them returns a `True`, in this case 61 | the `__repr__` method will return `repr(other)`. 62 | 63 | ```py title="repr() after comparison" 64 | from dirty_equals import IsInt 65 | 66 | v = IsInt() 67 | assert 42 == v 68 | assert repr(v) == '42' 69 | ``` 70 | 71 | This black magic is designed to make the output of pytest when asserts on large objects fail as simple as 72 | possible to read. 73 | 74 | Consider the following unit test: 75 | 76 | ```py title="pytest error example" 77 | from datetime import datetime 78 | 79 | from dirty_equals import IsNow, IsPositiveInt 80 | 81 | 82 | def test_partial_dict(): 83 | api_response_data = { 84 | 'id': 1, # (1)! 85 | 'first_name': 'John', 86 | 'last_name': 'Doe', 87 | 'created_at': datetime.now().isoformat(), 88 | 'phone': '+44 123456789', 89 | } 90 | 91 | assert api_response_data == { 92 | 'id': IsPositiveInt(), 93 | 'first_name': 'John', 94 | 'last_name': 'Doe', 95 | 'created_at': IsNow(iso_string=True), 96 | # phone number is missing, so the test will fail 97 | } 98 | ``` 99 | 100 | 1. For simplicity we've hardcoded `id` here, but in a test it could be any positive int, 101 | hence why we need `IsPositiveInt()` 102 | 103 | Here's an except from the output of `pytest -vv` show the error details: 104 | 105 | ```txt title="pytest output" 106 | E Common items: 107 | E {'created_at': '2022-02-25T15:41:38.493512', 108 | E 'first_name': 'John', 109 | E 'id': 1, 110 | E 'last_name': 'Doe'} 111 | E Left contains 1 more item: 112 | E {'phone': '+44 123456789'} 113 | E Full diff: 114 | E { 115 | E 'created_at': '2022-02-25T15:41:38.493512', 116 | E 'first_name': 'John', 117 | E 'id': 1, 118 | E 'last_name': 'Doe', 119 | E + 'phone': '+44 123456789', 120 | E } 121 | ``` 122 | 123 | It's easy to see that the `phone` key is missing, `id` and `created_at` are represented by the exact 124 | values they were compared to, so don't show as different in the "Full diff" section. 125 | 126 | !!! Warning 127 | This black magic only works when using initialised types, if `IsPositiveInt` was used instead `IsPositiveInt()` 128 | in the above example, the output would not be as clean. 129 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: dirty-equals 2 | site_description: Doing dirty (but extremely useful) things with equals. 3 | site_url: https://dirty-equals.helpmanual.io 4 | 5 | theme: 6 | name: material 7 | palette: 8 | - scheme: default 9 | primary: blue grey 10 | accent: indigo 11 | toggle: 12 | icon: material/lightbulb 13 | name: Switch to dark mode 14 | - scheme: slate 15 | primary: blue grey 16 | accent: indigo 17 | toggle: 18 | icon: material/lightbulb-outline 19 | name: Switch to light mode 20 | features: 21 | - search.suggest 22 | - search.highlight 23 | - content.tabs.link 24 | - content.code.annotate 25 | icon: 26 | repo: fontawesome/brands/github-alt 27 | logo: img/logo-white.svg 28 | favicon: img/favicon.png 29 | language: en 30 | 31 | repo_name: samuelcolvin/dirty-equals 32 | repo_url: https://github.com/samuelcolvin/dirty-equals 33 | edit_uri: '' 34 | nav: 35 | - Introduction: index.md 36 | - Usage: usage.md 37 | - Types: 38 | - types/numeric.md 39 | - types/datetime.md 40 | - types/dict.md 41 | - types/sequence.md 42 | - types/string.md 43 | - types/inspection.md 44 | - types/boolean.md 45 | - types/other.md 46 | - types/custom.md 47 | - Internals: internals.md 48 | 49 | markdown_extensions: 50 | - toc: 51 | permalink: true 52 | - admonition 53 | - pymdownx.details 54 | - pymdownx.superfences 55 | - pymdownx.highlight: 56 | anchor_linenums: true 57 | - pymdownx.inlinehilite 58 | - pymdownx.snippets 59 | - attr_list 60 | - md_in_html 61 | - pymdownx.emoji: 62 | emoji_index: !!python/name:material.extensions.emoji.twemoji 63 | emoji_generator: !!python/name:material.extensions.emoji.to_svg 64 | 65 | extra: 66 | version: 67 | provider: mike 68 | analytics: 69 | provider: google 70 | property: G-FLP20728CW 71 | social: 72 | - icon: fontawesome/brands/github-alt 73 | link: https://github.com/samuelcolvin/dirty-equals 74 | - icon: fontawesome/brands/twitter 75 | link: https://twitter.com/samuel_colvin 76 | 77 | watch: 78 | - dirty_equals 79 | 80 | plugins: 81 | - mike: 82 | alias_type: symlink 83 | canonical_version: latest 84 | - search 85 | - mkdocstrings: 86 | handlers: 87 | python: 88 | options: 89 | show_root_heading: true 90 | show_root_full_path: false 91 | show_source: false 92 | heading_level: 2 93 | merge_init_into_class: true 94 | show_signature_annotations: true 95 | separate_signature: true 96 | signature_crossrefs: true 97 | import: 98 | - url: https://docs.python.org/3/objects.inv 99 | - url: https://docs.pydantic.dev/latest/objects.inv 100 | - mkdocs-simple-hooks: 101 | hooks: 102 | on_pre_build: 'docs.plugins:on_pre_build' 103 | on_files: 'docs.plugins:on_files' 104 | on_page_markdown: 'docs.plugins:on_page_markdown' 105 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ['hatchling'] 3 | build-backend = 'hatchling.build' 4 | 5 | [tool.hatch.version] 6 | path = 'dirty_equals/version.py' 7 | 8 | [project] 9 | name = 'dirty-equals' 10 | description = 'Doing dirty (but extremely useful) things with equals.' 11 | authors = [{name = 'Samuel Colvin', email = 's@muelcolvin.com'}] 12 | license = 'MIT' 13 | readme = 'README.md' 14 | classifiers = [ 15 | 'Development Status :: 4 - Beta', 16 | 'Framework :: Pytest', 17 | 'Intended Audience :: Developers', 18 | 'Intended Audience :: Education', 19 | 'Intended Audience :: Information Technology', 20 | 'Intended Audience :: Science/Research', 21 | 'Intended Audience :: System Administrators', 22 | 'Operating System :: Unix', 23 | 'Operating System :: POSIX :: Linux', 24 | 'Environment :: Console', 25 | 'Environment :: MacOS X', 26 | 'License :: OSI Approved :: MIT License', 27 | 'Programming Language :: Python :: 3 :: Only', 28 | 'Programming Language :: Python :: 3.8', 29 | 'Programming Language :: Python :: 3.9', 30 | 'Programming Language :: Python :: 3.10', 31 | 'Programming Language :: Python :: 3.11', 32 | 'Programming Language :: Python :: 3.12', 33 | 'Programming Language :: Python :: 3.13', 34 | 'Topic :: Software Development :: Libraries :: Python Modules', 35 | 'Topic :: Internet', 36 | 'Typing :: Typed', 37 | ] 38 | requires-python = '>=3.8' 39 | dependencies = [ 40 | 'pytz>=2021.3;python_version<"3.9"', 41 | ] 42 | optional-dependencies = {pydantic = ['pydantic>=2.4.2'] } 43 | dynamic = ['version'] 44 | 45 | [project.urls] 46 | Homepage = 'https://github.com/samuelcolvin/dirty-equals' 47 | Documentation = 'https://dirty-equals.helpmanual.io' 48 | Funding = 'https://github.com/sponsors/samuelcolvin' 49 | Source = 'https://github.com/samuelcolvin/dirty-equals' 50 | Changelog = 'https://github.com/samuelcolvin/dirty-equals/releases' 51 | 52 | [tool.ruff] 53 | line-length = 120 54 | lint.extend-select = ['Q', 'RUF100', 'C90', 'UP', 'I'] 55 | lint.ignore = ['E721'] 56 | lint.flake8-quotes = {inline-quotes = 'single', multiline-quotes = 'double'} 57 | lint.mccabe = { max-complexity = 14 } 58 | lint.pydocstyle = { convention = 'google' } 59 | format.quote-style = 'single' 60 | target-version = 'py38' 61 | 62 | [tool.pytest.ini_options] 63 | testpaths = "tests" 64 | filterwarnings = "error" 65 | 66 | [tool.coverage.run] 67 | source = ["dirty_equals"] 68 | branch = true 69 | 70 | [tool.coverage.report] 71 | precision = 2 72 | exclude_lines = [ 73 | "pragma: no cover", 74 | "raise NotImplementedError", 75 | "raise NotImplemented", 76 | "if TYPE_CHECKING:", 77 | "@overload", 78 | ] 79 | 80 | [tool.mypy] 81 | strict = true 82 | warn_return_any = false 83 | show_error_codes = true 84 | -------------------------------------------------------------------------------- /requirements/all.txt: -------------------------------------------------------------------------------- 1 | -r ./docs.txt 2 | -r ./linting.txt 3 | -r ./tests.txt 4 | -r ./pyproject.txt 5 | -------------------------------------------------------------------------------- /requirements/docs.in: -------------------------------------------------------------------------------- 1 | mike 2 | mkdocs 3 | mkdocs-material 4 | mkdocs-simple-hooks 5 | mkdocstrings[python] 6 | -------------------------------------------------------------------------------- /requirements/docs.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.12 3 | # by the following command: 4 | # 5 | # pip-compile --constraint=requirements/linting.txt --constraint=requirements/tests.txt --output-file=requirements/docs.txt requirements/docs.in 6 | # 7 | babel==2.16.0 8 | # via mkdocs-material 9 | certifi==2024.7.4 10 | # via requests 11 | charset-normalizer==3.3.2 12 | # via requests 13 | click==8.1.7 14 | # via 15 | # -c requirements/tests.txt 16 | # mkdocs 17 | # mkdocstrings 18 | colorama==0.4.6 19 | # via 20 | # griffe 21 | # mkdocs-material 22 | ghp-import==2.1.0 23 | # via mkdocs 24 | griffe==0.48.0 25 | # via mkdocstrings-python 26 | idna==3.7 27 | # via requests 28 | importlib-metadata==8.2.0 29 | # via mike 30 | importlib-resources==6.4.0 31 | # via mike 32 | jinja2==3.1.4 33 | # via 34 | # mike 35 | # mkdocs 36 | # mkdocs-material 37 | # mkdocstrings 38 | markdown==3.6 39 | # via 40 | # mkdocs 41 | # mkdocs-autorefs 42 | # mkdocs-material 43 | # mkdocstrings 44 | # pymdown-extensions 45 | markupsafe==2.1.5 46 | # via 47 | # jinja2 48 | # mkdocs 49 | # mkdocs-autorefs 50 | # mkdocstrings 51 | mergedeep==1.3.4 52 | # via 53 | # mkdocs 54 | # mkdocs-get-deps 55 | mike==2.1.3 56 | # via -r requirements/docs.in 57 | mkdocs==1.6.0 58 | # via 59 | # -r requirements/docs.in 60 | # mike 61 | # mkdocs-autorefs 62 | # mkdocs-material 63 | # mkdocs-simple-hooks 64 | # mkdocstrings 65 | mkdocs-autorefs==1.0.1 66 | # via mkdocstrings 67 | mkdocs-get-deps==0.2.0 68 | # via mkdocs 69 | mkdocs-material==9.5.31 70 | # via -r requirements/docs.in 71 | mkdocs-material-extensions==1.3.1 72 | # via mkdocs-material 73 | mkdocs-simple-hooks==0.1.5 74 | # via -r requirements/docs.in 75 | mkdocstrings[python]==0.25.2 76 | # via 77 | # -r requirements/docs.in 78 | # mkdocstrings-python 79 | mkdocstrings-python==1.10.7 80 | # via mkdocstrings 81 | packaging==24.1 82 | # via 83 | # -c requirements/tests.txt 84 | # mkdocs 85 | paginate==0.5.6 86 | # via mkdocs-material 87 | pathspec==0.12.1 88 | # via 89 | # -c requirements/tests.txt 90 | # mkdocs 91 | platformdirs==4.2.2 92 | # via 93 | # -c requirements/tests.txt 94 | # mkdocs-get-deps 95 | # mkdocstrings 96 | pygments==2.18.0 97 | # via 98 | # -c requirements/tests.txt 99 | # mkdocs-material 100 | pymdown-extensions==10.9 101 | # via 102 | # mkdocs-material 103 | # mkdocstrings 104 | pyparsing==3.1.2 105 | # via mike 106 | python-dateutil==2.9.0.post0 107 | # via ghp-import 108 | pyyaml==6.0.2 109 | # via 110 | # mike 111 | # mkdocs 112 | # mkdocs-get-deps 113 | # pymdown-extensions 114 | # pyyaml-env-tag 115 | pyyaml-env-tag==0.1 116 | # via 117 | # mike 118 | # mkdocs 119 | regex==2024.7.24 120 | # via mkdocs-material 121 | requests==2.32.3 122 | # via mkdocs-material 123 | six==1.16.0 124 | # via python-dateutil 125 | urllib3==2.2.2 126 | # via requests 127 | verspec==0.1.0 128 | # via mike 129 | watchdog==4.0.2 130 | # via mkdocs 131 | zipp==3.20.0 132 | # via importlib-metadata 133 | -------------------------------------------------------------------------------- /requirements/linting.in: -------------------------------------------------------------------------------- 1 | mypy 2 | pydantic 3 | ruff 4 | types-pytz 5 | -------------------------------------------------------------------------------- /requirements/linting.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.12 3 | # by the following command: 4 | # 5 | # pip-compile --output-file=requirements/linting.txt requirements/linting.in 6 | # 7 | annotated-types==0.7.0 8 | # via pydantic 9 | mypy==1.11.1 10 | # via -r requirements/linting.in 11 | mypy-extensions==1.0.0 12 | # via mypy 13 | pydantic==2.8.2 14 | # via -r requirements/linting.in 15 | pydantic-core==2.20.1 16 | # via pydantic 17 | ruff==0.5.7 18 | # via -r requirements/linting.in 19 | types-pytz==2024.1.0.20240417 20 | # via -r requirements/linting.in 21 | typing-extensions==4.12.2 22 | # via 23 | # mypy 24 | # pydantic 25 | # pydantic-core 26 | -------------------------------------------------------------------------------- /requirements/pyproject.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.12 3 | # by the following command: 4 | # 5 | # pip-compile --constraint=requirements/docs.txt --constraint=requirements/linting.txt --constraint=requirements/tests.txt --extra=pydantic --output-file=requirements/pyproject.txt pyproject.toml 6 | # 7 | annotated-types==0.7.0 8 | # via 9 | # -c requirements/linting.txt 10 | # pydantic 11 | pydantic==2.8.2 12 | # via 13 | # -c requirements/linting.txt 14 | # dirty-equals (pyproject.toml) 15 | pydantic-core==2.20.1 16 | # via 17 | # -c requirements/linting.txt 18 | # pydantic 19 | typing-extensions==4.12.2 20 | # via 21 | # -c requirements/linting.txt 22 | # pydantic 23 | # pydantic-core 24 | -------------------------------------------------------------------------------- /requirements/tests.in: -------------------------------------------------------------------------------- 1 | coverage[toml] 2 | packaging 3 | pytest 4 | pytest-mock 5 | pytest-pretty 6 | pytest-examples 7 | pytz 8 | -------------------------------------------------------------------------------- /requirements/tests.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.12 3 | # by the following command: 4 | # 5 | # pip-compile --constraint=requirements/linting.txt --output-file=requirements/tests.txt requirements/tests.in 6 | # 7 | black==24.8.0 8 | # via pytest-examples 9 | click==8.1.7 10 | # via black 11 | coverage[toml]==7.6.1 12 | # via -r requirements/tests.in 13 | iniconfig==2.0.0 14 | # via pytest 15 | markdown-it-py==3.0.0 16 | # via rich 17 | mdurl==0.1.2 18 | # via markdown-it-py 19 | mypy-extensions==1.0.0 20 | # via 21 | # -c requirements/linting.txt 22 | # black 23 | packaging==24.1 24 | # via 25 | # -r requirements/tests.in 26 | # black 27 | # pytest 28 | pathspec==0.12.1 29 | # via black 30 | platformdirs==4.2.2 31 | # via black 32 | pluggy==1.5.0 33 | # via pytest 34 | pygments==2.18.0 35 | # via rich 36 | pytest==8.3.2 37 | # via 38 | # -r requirements/tests.in 39 | # pytest-examples 40 | # pytest-mock 41 | # pytest-pretty 42 | pytest-examples==0.0.13 43 | # via -r requirements/tests.in 44 | pytest-mock==3.14.0 45 | # via -r requirements/tests.in 46 | pytest-pretty==1.2.0 47 | # via -r requirements/tests.in 48 | pytz==2024.1 49 | # via -r requirements/tests.in 50 | rich==13.7.1 51 | # via pytest-pretty 52 | ruff==0.5.7 53 | # via 54 | # -c requirements/linting.txt 55 | # pytest-examples 56 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samuelcolvin/dirty-equals/9e6f0be981f982fdaae16671ab14aefc3ab8664a/tests/__init__.py -------------------------------------------------------------------------------- /tests/mypy_checks.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module is run with mypy to check types can be used correctly externally. 3 | """ 4 | 5 | import sys 6 | 7 | sys.path.append('.') 8 | 9 | from dirty_equals import HasName, HasRepr, IsStr 10 | 11 | assert 123 == HasName('int') 12 | assert 123 == HasRepr('123') 13 | assert 123 == HasName(IsStr(regex='i..')) 14 | assert 123 == HasRepr(IsStr(regex=r'\d{3}')) 15 | 16 | # type ignore is required (if it wasn't, there would be an error) 17 | assert 123 != HasName(123) # type: ignore[arg-type] 18 | -------------------------------------------------------------------------------- /tests/test_base.py: -------------------------------------------------------------------------------- 1 | import platform 2 | import pprint 3 | from functools import singledispatch 4 | 5 | import packaging.version 6 | import pytest 7 | 8 | from dirty_equals import Contains, DirtyEquals, IsApprox, IsInt, IsList, IsNegative, IsOneOf, IsPositive, IsStr 9 | from dirty_equals.version import VERSION 10 | 11 | 12 | def test_or(): 13 | assert 'foo' == IsStr | IsInt 14 | assert 1 == IsStr | IsInt 15 | assert -1 == IsStr | IsNegative | IsPositive 16 | 17 | v = IsStr | IsInt 18 | with pytest.raises(AssertionError): 19 | assert 1.5 == v 20 | assert str(v) == 'IsStr | IsInt' 21 | 22 | 23 | def test_and(): 24 | assert 4 == IsPositive & IsInt(lt=5) 25 | 26 | v = IsStr & IsInt 27 | with pytest.raises(AssertionError): 28 | assert 1 == v 29 | assert str(v) == 'IsStr & IsInt' 30 | 31 | 32 | def test_not(): 33 | assert 'foo' != IsInt 34 | assert 'foo' == ~IsInt 35 | 36 | 37 | def test_value_eq(): 38 | v = IsStr() 39 | 40 | with pytest.raises(AttributeError, match='value is not available until __eq__ has been called'): 41 | v.value 42 | 43 | assert 'foo' == v 44 | assert repr(v) == str(v) == "'foo'" == pprint.pformat(v) 45 | assert v.value == 'foo' 46 | 47 | 48 | def test_value_ne(): 49 | v = IsStr() 50 | 51 | with pytest.raises(AssertionError): 52 | assert 1 == v 53 | 54 | assert repr(v) == str(v) == 'IsStr()' == pprint.pformat(v) 55 | with pytest.raises(AttributeError, match='value is not available until __eq__ has been called'): 56 | v.value 57 | 58 | 59 | def test_dict_compare(): 60 | v = {'foo': 1, 'bar': 2, 'spam': 3} 61 | assert v == {'foo': IsInt, 'bar': IsPositive, 'spam': ~IsStr} 62 | assert v == {'foo': IsInt() & IsApprox(1), 'bar': IsPositive() | IsNegative(), 'spam': ~IsStr()} 63 | 64 | 65 | @pytest.mark.skipif(platform.python_implementation() == 'PyPy', reason='PyPy does not metaclass dunder methods') 66 | def test_not_repr(): 67 | v = ~IsInt 68 | assert str(v) == '~IsInt' 69 | 70 | with pytest.raises(AssertionError): 71 | assert 1 == v 72 | 73 | assert str(v) == '~IsInt' 74 | 75 | 76 | def test_not_repr_instance(): 77 | v = ~IsInt() 78 | assert str(v) == '~IsInt()' 79 | 80 | with pytest.raises(AssertionError): 81 | assert 1 == v 82 | 83 | assert str(v) == '~IsInt()' 84 | 85 | 86 | def test_repr(): 87 | v = ~IsInt 88 | assert str(v) == '~IsInt' 89 | 90 | assert '1' == v 91 | 92 | assert str(v) == "'1'" 93 | 94 | 95 | @pytest.mark.parametrize( 96 | 'v,v_repr', 97 | [ 98 | (IsInt, 'IsInt'), 99 | (~IsInt, '~IsInt'), 100 | (IsInt & IsPositive, 'IsInt & IsPositive'), 101 | (IsInt | IsPositive, 'IsInt | IsPositive'), 102 | (IsInt(), 'IsInt()'), 103 | (~IsInt(), '~IsInt()'), 104 | (IsInt() & IsPositive(), 'IsInt() & IsPositive()'), 105 | (IsInt() | IsPositive(), 'IsInt() | IsPositive()'), 106 | (IsInt() & IsPositive, 'IsInt() & IsPositive'), 107 | (IsInt() | IsPositive, 'IsInt() | IsPositive'), 108 | (IsPositive & IsInt(lt=5), 'IsPositive & IsInt(lt=5)'), 109 | (IsOneOf(1, 2, 3), 'IsOneOf(1, 2, 3)'), 110 | ], 111 | ) 112 | def test_repr_class(v, v_repr): 113 | assert repr(v) == str(v) == v_repr == pprint.pformat(v) 114 | 115 | 116 | def test_is_approx_without_init(): 117 | assert 1 != IsApprox 118 | 119 | 120 | def test_ne_repr(): 121 | v = IsInt 122 | assert repr(v) == str(v) == 'IsInt' == pprint.pformat(v) 123 | 124 | assert 'x' != v 125 | 126 | assert repr(v) == str(v) == 'IsInt' == pprint.pformat(v) 127 | 128 | 129 | def test_pprint(): 130 | v = [IsList(length=...), 1, [IsList(length=...), 2], 3, IsInt()] 131 | lorem = ['lorem', 'ipsum', 'dolor', 'sit', 'amet'] * 2 132 | with pytest.raises(AssertionError): 133 | assert [lorem, 1, [lorem, 2], 3, '4'] == v 134 | 135 | assert repr(v) == (f'[{lorem}, 1, [{lorem}, 2], 3, IsInt()]') 136 | assert pprint.pformat(v) == ( 137 | "[['lorem',\n" 138 | " 'ipsum',\n" 139 | " 'dolor',\n" 140 | " 'sit',\n" 141 | " 'amet',\n" 142 | " 'lorem',\n" 143 | " 'ipsum',\n" 144 | " 'dolor',\n" 145 | " 'sit',\n" 146 | " 'amet'],\n" 147 | ' 1,\n' 148 | " [['lorem',\n" 149 | " 'ipsum',\n" 150 | " 'dolor',\n" 151 | " 'sit',\n" 152 | " 'amet',\n" 153 | " 'lorem',\n" 154 | " 'ipsum',\n" 155 | " 'dolor',\n" 156 | " 'sit',\n" 157 | " 'amet'],\n" 158 | ' 2],\n' 159 | ' 3,\n' 160 | ' IsInt()]' 161 | ) 162 | 163 | 164 | def test_pprint_not_equal(): 165 | v = IsList(*range(30)) # need a big value to trigger pprint 166 | with pytest.raises(AssertionError): 167 | assert [] == v 168 | 169 | assert ( 170 | pprint.pformat(v) 171 | == ( 172 | 'IsList(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, ' 173 | '15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29)' 174 | ) 175 | == repr(v) 176 | == str(v) 177 | ) 178 | 179 | 180 | @pytest.mark.parametrize( 181 | 'value,dirty', 182 | [ 183 | (1, IsOneOf(1, 2, 3)), 184 | (4, ~IsOneOf(1, 2, 3)), 185 | ([1, 2, 3], Contains(1) | IsOneOf([])), 186 | ([], Contains(1) | IsOneOf([])), 187 | ([2], ~(Contains(1) | IsOneOf([]))), 188 | ], 189 | ) 190 | def test_is_one_of(value, dirty): 191 | assert value == dirty 192 | 193 | 194 | def test_version(): 195 | packaging.version.parse(VERSION) 196 | 197 | 198 | def test_singledispatch(): 199 | @singledispatch 200 | def dispatch(value): 201 | return 'generic' 202 | 203 | assert dispatch(IsStr()) == 'generic' 204 | 205 | @dispatch.register 206 | def _(value: DirtyEquals): 207 | return 'DirtyEquals' 208 | 209 | assert dispatch(IsStr()) == 'DirtyEquals' 210 | 211 | @dispatch.register 212 | def _(value: IsStr): 213 | return 'IsStr' 214 | 215 | assert dispatch(IsStr()) == 'IsStr' 216 | -------------------------------------------------------------------------------- /tests/test_boolean.py: -------------------------------------------------------------------------------- 1 | import platform 2 | 3 | import pytest 4 | 5 | from dirty_equals import IsFalseLike, IsTrueLike 6 | 7 | 8 | @pytest.mark.parametrize( 9 | 'other, expected', 10 | [ 11 | (False, IsFalseLike), 12 | (True, ~IsFalseLike), 13 | ([], IsFalseLike), 14 | ([1], ~IsFalseLike), 15 | ((), IsFalseLike), 16 | ('', IsFalseLike), 17 | ('', IsFalseLike(allow_strings=True)), 18 | ((1, 2), ~IsFalseLike), 19 | ({}, IsFalseLike), 20 | ({1: 'a'}, ~IsFalseLike), 21 | (set(), IsFalseLike), 22 | ({'a', 'b', 'c'}, ~IsFalseLike), 23 | (None, IsFalseLike), 24 | (0, IsFalseLike), 25 | (1, ~IsFalseLike), 26 | (0.0, IsFalseLike), 27 | (1.0, ~IsFalseLike), 28 | ('0', IsFalseLike(allow_strings=True)), 29 | ('1', ~IsFalseLike(allow_strings=True)), 30 | ('0.0', IsFalseLike(allow_strings=True)), 31 | ('0.000', IsFalseLike(allow_strings=True)), 32 | ('1.0', ~IsFalseLike(allow_strings=True)), 33 | ('False', IsFalseLike(allow_strings=True)), 34 | ('True', ~IsFalseLike(allow_strings=True)), 35 | (0, IsFalseLike(allow_strings=True)), 36 | ], 37 | ) 38 | def test_is_false_like(other, expected): 39 | assert other == expected 40 | 41 | 42 | def test_is_false_like_repr(): 43 | assert repr(IsFalseLike) == 'IsFalseLike' 44 | assert repr(IsFalseLike()) == 'IsFalseLike()' 45 | assert repr(IsFalseLike(allow_strings=True)) == 'IsFalseLike(allow_strings=True)' 46 | 47 | 48 | @pytest.mark.skipif(platform.python_implementation() == 'PyPy', reason='PyPy does not metaclass dunder methods') 49 | def test_dirty_not_equals(): 50 | with pytest.raises(AssertionError): 51 | assert 0 != IsFalseLike 52 | 53 | 54 | def test_dirty_not_equals_instance(): 55 | with pytest.raises(AssertionError): 56 | assert 0 != IsFalseLike() 57 | 58 | 59 | def test_invalid_initialization(): 60 | with pytest.raises(TypeError, match='takes 1 positional argument but 2 were given'): 61 | IsFalseLike(True) 62 | 63 | 64 | @pytest.mark.parametrize( 65 | 'other, expected', 66 | [ 67 | (False, ~IsTrueLike), 68 | (True, IsTrueLike), 69 | ([], ~IsTrueLike), 70 | ([1], IsTrueLike), 71 | ((), ~IsTrueLike), 72 | ((1, 2), IsTrueLike), 73 | ({}, ~IsTrueLike), 74 | ({1: 'a'}, IsTrueLike), 75 | (set(), ~IsTrueLike), 76 | ({'a', 'b', 'c'}, IsTrueLike), 77 | (None, ~IsTrueLike), 78 | (0, ~IsTrueLike), 79 | (1, IsTrueLike), 80 | (0.0, ~IsTrueLike), 81 | (1.0, IsTrueLike), 82 | ], 83 | ) 84 | def test_is_true_like(other, expected): 85 | assert other == expected 86 | -------------------------------------------------------------------------------- /tests/test_datetime.py: -------------------------------------------------------------------------------- 1 | from datetime import date, datetime, timedelta, timezone 2 | from unittest.mock import Mock 3 | 4 | import pytest 5 | import pytz 6 | 7 | from dirty_equals import IsDate, IsDatetime, IsNow, IsToday 8 | 9 | try: 10 | from zoneinfo import ZoneInfo 11 | except ImportError: 12 | ZoneInfo = None 13 | 14 | 15 | @pytest.mark.parametrize( 16 | 'value,dirty,expect_match', 17 | [ 18 | pytest.param(datetime(2000, 1, 1), IsDatetime(approx=datetime(2000, 1, 1)), True, id='same'), 19 | # Note: this requires the system timezone to be UTC 20 | pytest.param(946684800, IsDatetime(approx=datetime(2000, 1, 1), unix_number=True), True, id='unix-int'), 21 | # Note: this requires the system timezone to be UTC 22 | pytest.param(946684800.123, IsDatetime(approx=datetime(2000, 1, 1), unix_number=True), True, id='unix-float'), 23 | pytest.param(946684800, IsDatetime(approx=datetime(2000, 1, 1)), False, id='unix-different'), 24 | pytest.param( 25 | '2000-01-01T00:00', IsDatetime(approx=datetime(2000, 1, 1), iso_string=True), True, id='iso-string-true' 26 | ), 27 | pytest.param('2000-01-01T00:00', IsDatetime(approx=datetime(2000, 1, 1)), False, id='iso-string-different'), 28 | pytest.param('broken', IsDatetime(approx=datetime(2000, 1, 1)), False, id='iso-string-wrong'), 29 | pytest.param( 30 | '28/01/87', IsDatetime(approx=datetime(1987, 1, 28), format_string='%d/%m/%y'), True, id='string-format' 31 | ), 32 | pytest.param('28/01/87', IsDatetime(approx=datetime(2000, 1, 1)), False, id='string-format-different'), 33 | pytest.param('foobar', IsDatetime(approx=datetime(2000, 1, 1)), False, id='string-format-wrong'), 34 | pytest.param(datetime(2000, 1, 1).isoformat(), IsNow(iso_string=True), False, id='isnow-str-different'), 35 | pytest.param([1, 2, 3], IsDatetime(approx=datetime(2000, 1, 1)), False, id='wrong-type'), 36 | pytest.param( 37 | datetime(2020, 1, 1, 12, 13, 14), IsDatetime(approx=datetime(2020, 1, 1, 12, 13, 14)), True, id='tz-same' 38 | ), 39 | pytest.param( 40 | datetime(2020, 1, 1, 12, 13, 14, tzinfo=timezone.utc), 41 | IsDatetime(approx=datetime(2020, 1, 1, 12, 13, 14), enforce_tz=False), 42 | True, 43 | id='tz-utc', 44 | ), 45 | pytest.param( 46 | datetime(2020, 1, 1, 12, 13, 14, tzinfo=timezone.utc), 47 | IsDatetime(approx=datetime(2020, 1, 1, 12, 13, 14)), 48 | False, 49 | id='tz-utc-different', 50 | ), 51 | pytest.param( 52 | datetime(2020, 1, 1, 12, 13, 14), 53 | IsDatetime(approx=datetime(2020, 1, 1, 12, 13, 14, tzinfo=timezone.utc), enforce_tz=False), 54 | False, 55 | id='tz-approx-tz', 56 | ), 57 | pytest.param( 58 | datetime(2020, 1, 1, 12, 13, 14, tzinfo=timezone(offset=timedelta(hours=1))), 59 | IsDatetime(approx=datetime(2020, 1, 1, 12, 13, 14), enforce_tz=False), 60 | True, 61 | id='tz-1-hour', 62 | ), 63 | pytest.param( 64 | pytz.timezone('Europe/London').localize(datetime(2022, 2, 15, 15, 15)), 65 | IsDatetime( 66 | approx=pytz.timezone('America/New_York').localize(datetime(2022, 2, 15, 10, 15)), enforce_tz=False 67 | ), 68 | True, 69 | id='tz-both-tz', 70 | ), 71 | pytest.param( 72 | pytz.timezone('Europe/London').localize(datetime(2022, 2, 15, 15, 15)), 73 | IsDatetime(approx=pytz.timezone('America/New_York').localize(datetime(2022, 2, 15, 10, 15))), 74 | False, 75 | id='tz-both-tz-different', 76 | ), 77 | pytest.param(datetime(2000, 1, 1), IsDatetime(ge=datetime(2000, 1, 1)), True, id='ge'), 78 | pytest.param(datetime(1999, 1, 1), IsDatetime(ge=datetime(2000, 1, 1)), False, id='ge-not'), 79 | pytest.param(datetime(2000, 1, 2), IsDatetime(gt=datetime(2000, 1, 1)), True, id='gt'), 80 | pytest.param(datetime(2000, 1, 1), IsDatetime(gt=datetime(2000, 1, 1)), False, id='gt-not'), 81 | ], 82 | ) 83 | def test_is_datetime(value, dirty, expect_match): 84 | if expect_match: 85 | assert value == dirty 86 | else: 87 | assert value != dirty 88 | 89 | 90 | @pytest.mark.skipif(ZoneInfo is None, reason='requires zoneinfo') 91 | def test_is_datetime_zoneinfo(): 92 | london = datetime(2022, 2, 15, 15, 15, tzinfo=ZoneInfo('Europe/London')) 93 | ny = datetime(2022, 2, 15, 10, 15, tzinfo=ZoneInfo('America/New_York')) 94 | assert london != IsDatetime(approx=ny) 95 | assert london == IsDatetime(approx=ny, enforce_tz=False) 96 | 97 | 98 | def test_is_now_dt(): 99 | is_now = IsNow() 100 | dt = datetime.now() 101 | assert dt == is_now 102 | assert str(is_now) == repr(dt) 103 | 104 | 105 | def test_is_now_str(): 106 | assert datetime.now().isoformat() == IsNow(iso_string=True) 107 | 108 | 109 | def test_repr(): 110 | v = IsDatetime(approx=datetime(2032, 1, 2, 3, 4, 5), iso_string=True) 111 | assert str(v) == 'IsDatetime(approx=datetime.datetime(2032, 1, 2, 3, 4, 5), iso_string=True)' 112 | 113 | 114 | @pytest.mark.skipif(ZoneInfo is None, reason='requires zoneinfo') 115 | def test_is_now_tz(): 116 | utc_now = datetime.now(timezone.utc) 117 | now_ny = utc_now.astimezone(ZoneInfo('America/New_York')) 118 | assert now_ny == IsNow(tz='America/New_York') 119 | # depends on the time of year and DST 120 | assert now_ny == IsNow(tz=timezone(timedelta(hours=-5))) | IsNow(tz=timezone(timedelta(hours=-4))) 121 | 122 | now = datetime.now() 123 | assert now == IsNow 124 | assert now.timestamp() == IsNow(unix_number=True) 125 | assert now.timestamp() != IsNow 126 | assert now.isoformat() == IsNow(iso_string=True) 127 | assert now.isoformat() != IsNow 128 | 129 | assert utc_now == IsNow(tz=timezone.utc) 130 | 131 | 132 | def test_delta(): 133 | assert IsNow(delta=timedelta(hours=2)).delta == timedelta(seconds=7200) 134 | assert IsNow(delta=3600).delta == timedelta(seconds=3600) 135 | assert IsNow(delta=3600.1).delta == timedelta(seconds=3600, microseconds=100000) 136 | 137 | 138 | def test_is_now_relative(monkeypatch): 139 | mock = Mock(return_value=datetime(2020, 1, 1, 12, 13, 14)) 140 | monkeypatch.setattr(IsNow, '_get_now', mock) 141 | assert IsNow() == datetime(2020, 1, 1, 12, 13, 14) 142 | 143 | 144 | @pytest.mark.skipif(ZoneInfo is None, reason='requires zoneinfo') 145 | def test_tz(): 146 | new_year_london = datetime(2000, 1, 1, tzinfo=ZoneInfo('Europe/London')) 147 | 148 | new_year_eve_nyc = datetime(1999, 12, 31, 19, 0, 0, tzinfo=ZoneInfo('America/New_York')) 149 | 150 | assert new_year_eve_nyc == IsDatetime(approx=new_year_london, enforce_tz=False) 151 | assert new_year_eve_nyc != IsDatetime(approx=new_year_london, enforce_tz=True) 152 | 153 | new_year_naive = datetime(2000, 1, 1) 154 | 155 | assert new_year_naive != IsDatetime(approx=new_year_london, enforce_tz=False) 156 | assert new_year_naive != IsDatetime(approx=new_year_eve_nyc, enforce_tz=False) 157 | assert new_year_london == IsDatetime(approx=new_year_naive, enforce_tz=False) 158 | assert new_year_eve_nyc != IsDatetime(approx=new_year_naive, enforce_tz=False) 159 | 160 | 161 | @pytest.mark.parametrize( 162 | 'value,dirty,expect_match', 163 | [ 164 | pytest.param(date(2000, 1, 1), IsDate(approx=date(2000, 1, 1)), True, id='same'), 165 | pytest.param('2000-01-01', IsDate(approx=date(2000, 1, 1), iso_string=True), True, id='iso-string-true'), 166 | pytest.param('2000-01-01', IsDate(approx=date(2000, 1, 1)), False, id='iso-string-different'), 167 | pytest.param('2000-01-01T00:00', IsDate(approx=date(2000, 1, 1)), False, id='iso-string-different'), 168 | pytest.param('broken', IsDate(approx=date(2000, 1, 1)), False, id='iso-string-wrong'), 169 | pytest.param('28/01/87', IsDate(approx=date(1987, 1, 28), format_string='%d/%m/%y'), True, id='string-format'), 170 | pytest.param('28/01/87', IsDate(approx=date(2000, 1, 1)), False, id='string-format-different'), 171 | pytest.param('foobar', IsDate(approx=date(2000, 1, 1)), False, id='string-format-wrong'), 172 | pytest.param([1, 2, 3], IsDate(approx=date(2000, 1, 1)), False, id='wrong-type'), 173 | pytest.param( 174 | datetime(2000, 1, 1, 10, 11, 12), IsDate(approx=date(2000, 1, 1)), False, id='wrong-type-datetime' 175 | ), 176 | pytest.param(date(2020, 1, 1), IsDate(approx=date(2020, 1, 1)), True, id='tz-same'), 177 | pytest.param(date(2000, 1, 1), IsDate(ge=date(2000, 1, 1)), True, id='ge'), 178 | pytest.param(date(1999, 1, 1), IsDate(ge=date(2000, 1, 1)), False, id='ge-not'), 179 | pytest.param(date(2000, 1, 2), IsDate(gt=date(2000, 1, 1)), True, id='gt'), 180 | pytest.param(date(2000, 1, 1), IsDate(gt=date(2000, 1, 1)), False, id='gt-not'), 181 | pytest.param(date(2000, 1, 1), IsDate(gt=date(2000, 1, 1), delta=10), False, id='delta-int'), 182 | pytest.param(date(2000, 1, 1), IsDate(gt=date(2000, 1, 1), delta=10.5), False, id='delta-float'), 183 | pytest.param( 184 | date(2000, 1, 1), IsDate(gt=date(2000, 1, 1), delta=timedelta(seconds=10)), False, id='delta-timedelta' 185 | ), 186 | ], 187 | ) 188 | def test_is_date(value, dirty, expect_match): 189 | if expect_match: 190 | assert value == dirty 191 | else: 192 | assert value != dirty 193 | 194 | 195 | def test_is_today(): 196 | today = date.today() 197 | assert today == IsToday 198 | assert today + timedelta(days=2) != IsToday 199 | assert today.isoformat() == IsToday(iso_string=True) 200 | assert today.isoformat() != IsToday() 201 | assert today.strftime('%Y/%m/%d') == IsToday(format_string='%Y/%m/%d') 202 | assert today.strftime('%Y/%m/%d') != IsToday() 203 | -------------------------------------------------------------------------------- /tests/test_dict.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from dirty_equals import IsDict, IsIgnoreDict, IsPartialDict, IsPositiveInt, IsStr, IsStrictDict 4 | 5 | 6 | @pytest.mark.parametrize( 7 | 'input_value,expected', 8 | [ 9 | ({}, IsDict), 10 | ({}, IsDict()), 11 | ({'a': 1}, IsDict(a=1)), 12 | ({1: 2}, IsDict({1: 2})), 13 | ({'a': 1, 'b': 2}, IsDict(a=1, b=2)), 14 | ({'b': 2, 'a': 1}, IsDict(a=1, b=2)), 15 | ({'a': 1, 'b': None}, IsDict(a=1, b=None)), 16 | ({'a': 1, 'b': 3}, ~IsDict(a=1, b=2)), 17 | # partial dict 18 | ({1: 10, 2: 20}, IsPartialDict({1: 10})), 19 | ({1: 10}, IsPartialDict({1: 10})), 20 | ({1: 10, 2: 20}, IsPartialDict({1: 10})), 21 | ({1: 10, 2: 20}, IsDict({1: 10}).settings(partial=True)), 22 | ({1: 10}, ~IsPartialDict({1: 10, 2: 20})), 23 | ({1: 10, 2: None}, ~IsPartialDict({1: 10, 2: 20})), 24 | # ignore dict 25 | ({}, IsIgnoreDict()), 26 | ({'a': 1, 'b': 2}, IsIgnoreDict(a=1, b=2)), 27 | ({'a': 1, 'b': None}, IsIgnoreDict(a=1)), 28 | ({1: 10, 2: None}, IsIgnoreDict({1: 10})), 29 | ({'a': 1, 'b': 2}, ~IsIgnoreDict(a=1)), 30 | ({1: 10, 2: False}, ~IsIgnoreDict({1: 10})), 31 | ({1: 10, 2: False}, IsIgnoreDict({1: 10}).settings(ignore={False})), 32 | # strict dict 33 | ({}, IsStrictDict()), 34 | ({'a': 1, 'b': 2}, IsStrictDict(a=1, b=2)), 35 | ({'a': 1, 'b': 2}, ~IsStrictDict(b=2, a=1)), 36 | ({1: 10, 2: 20}, IsStrictDict({1: 10, 2: 20})), 37 | ({1: 10, 2: 20}, ~IsStrictDict({2: 20, 1: 10})), 38 | ({1: 10, 2: 20}, ~IsDict({2: 20, 1: 10}).settings(strict=True)), 39 | # combining types 40 | ({'a': 1, 'b': 2, 'c': 3}, IsStrictDict(a=1, c=3).settings(partial=True)), 41 | ({'a': 1, 'b': 2, 'c': 3}, IsStrictDict(a=1, b=2).settings(partial=True)), 42 | ({'a': 1, 'b': 2, 'c': 3}, IsStrictDict(b=2, c=3).settings(partial=True)), 43 | ({'a': 1, 'c': 3, 'b': 2}, ~IsStrictDict(b=2, c=3).settings(partial=True)), 44 | ], 45 | ) 46 | def test_is_dict(input_value, expected): 47 | assert input_value == expected 48 | 49 | 50 | def test_ne_repr_partial_dict(): 51 | v = IsPartialDict({1: 10, 2: 20}) 52 | 53 | with pytest.raises(AssertionError): 54 | assert 1 == v 55 | 56 | assert str(v) == 'IsPartialDict(1=10, 2=20)' 57 | 58 | 59 | def test_ne_repr_strict_dict(): 60 | v = IsStrictDict({1: 10, 2: 20}) 61 | 62 | with pytest.raises(AssertionError): 63 | assert 1 == v 64 | 65 | assert str(v) == 'IsStrictDict(1=10, 2=20)' 66 | 67 | 68 | def test_args_and_kwargs(): 69 | with pytest.raises(TypeError, match='IsDict requires either a single argument or kwargs, not both'): 70 | IsDict(1, x=4) 71 | 72 | 73 | def test_multiple_args(): 74 | with pytest.raises(TypeError, match='IsDict expected at most 1 argument, got 2'): 75 | IsDict(1, 2) 76 | 77 | 78 | def test_arg_not_dict(): 79 | with pytest.raises(TypeError, match="expected_values must be a dict, got "): 80 | IsDict(1) 81 | 82 | 83 | def test_combine_partial_ignore(): 84 | d = IsPartialDict(a=2, b=2, c=3) 85 | with pytest.raises(TypeError, match='partial and ignore cannot be used together'): 86 | d.settings(ignore={1}) 87 | 88 | 89 | def ignore_42(value): 90 | return value == 42 91 | 92 | 93 | def test_callable_ignore(): 94 | assert {'a': 1} == IsDict(a=1).settings(ignore=ignore_42) 95 | assert {'a': 1, 'b': 42} == IsDict(a=1).settings(ignore=ignore_42) 96 | assert {'a': 1, 'b': 43} != IsDict(a=1).settings(ignore=ignore_42) 97 | 98 | 99 | @pytest.mark.parametrize( 100 | 'd,expected_repr', 101 | [ 102 | (IsDict, 'IsDict'), 103 | (IsDict(), 'IsDict()'), 104 | (IsPartialDict, 'IsPartialDict'), 105 | (IsPartialDict(), 'IsPartialDict()'), 106 | (IsDict().settings(partial=True), 'IsDict[partial=True]()'), 107 | (IsIgnoreDict(), 'IsIgnoreDict()'), 108 | (IsIgnoreDict().settings(ignore={7}), 'IsIgnoreDict[ignore={7}]()'), 109 | (IsIgnoreDict().settings(ignore={None}), 'IsIgnoreDict()'), 110 | (IsIgnoreDict().settings(ignore=None), 'IsIgnoreDict[ignore=None]()'), 111 | (IsDict().settings(ignore=ignore_42), 'IsDict[ignore=ignore_42]()'), 112 | (IsDict().settings(ignore={7}), 'IsDict[ignore={7}]()'), 113 | (IsDict().settings(ignore={None}), 'IsDict[ignore={None}]()'), 114 | (IsPartialDict().settings(partial=False), 'IsPartialDict[partial=False]()'), 115 | (IsStrictDict, 'IsStrictDict'), 116 | (IsStrictDict(), 'IsStrictDict()'), 117 | (IsDict().settings(strict=True), 'IsDict[strict=True]()'), 118 | (IsStrictDict().settings(strict=False), 'IsStrictDict[strict=False]()'), 119 | ], 120 | ) 121 | def test_not_equals_repr(d, expected_repr): 122 | assert repr(d) == expected_repr 123 | 124 | 125 | def test_ignore(): 126 | def custom_ignore(v: int) -> bool: 127 | return v % 2 == 0 128 | 129 | assert {'a': 1, 'b': 2, 'c': 3, 'd': 4} == IsDict(a=1, c=3).settings(ignore=custom_ignore) 130 | 131 | 132 | def test_ignore_with_is_str(): 133 | api_data = {'id': 123, 'token': 't-abc123', 'dob': None, 'street_address': None} 134 | 135 | token_is_str = IsStr(regex=r't\-.+') 136 | assert api_data == IsIgnoreDict(id=IsPositiveInt, token=token_is_str) 137 | assert token_is_str.value == 't-abc123' 138 | 139 | 140 | def test_unhashable_value(): 141 | a = {'a': 1} 142 | api_data = {'b': a, 'c': None} 143 | assert api_data == IsIgnoreDict(b=a) 144 | -------------------------------------------------------------------------------- /tests/test_docs.py: -------------------------------------------------------------------------------- 1 | import platform 2 | import sys 3 | from pathlib import Path 4 | 5 | import pytest 6 | from pytest_examples import CodeExample, EvalExample, find_examples 7 | 8 | root_dir = Path(__file__).parent.parent 9 | 10 | examples = find_examples( 11 | root_dir / 'dirty_equals', 12 | root_dir / 'docs', 13 | ) 14 | 15 | 16 | @pytest.mark.skipif(platform.python_implementation() == 'PyPy', reason='PyPy does not allow metaclass dunder methods') 17 | @pytest.mark.skipif(sys.version_info >= (3, 12), reason="pytest-examples doesn't yet support 3.12") 18 | @pytest.mark.parametrize('example', examples, ids=str) 19 | def test_docstrings(example: CodeExample, eval_example: EvalExample): 20 | prefix_settings = example.prefix_settings() 21 | # E711 and E712 refer to `== True` and `== None` and need to be ignored 22 | # I001 refers is a problem with black and ruff disagreeing about blank lines :shrug: 23 | eval_example.set_config(ruff_ignore=['E711', 'E712', 'I001']) 24 | 25 | requires = prefix_settings.get('requires') 26 | if requires: 27 | requires_version = tuple(int(v) for v in requires.split('.')) 28 | if sys.version_info < requires_version: 29 | pytest.skip(f'requires python {requires}') 30 | 31 | if prefix_settings.get('test') != 'skip': 32 | if eval_example.update_examples: 33 | eval_example.run_print_update(example) 34 | else: 35 | eval_example.run_print_check(example) 36 | 37 | if prefix_settings.get('lint') != 'skip': 38 | if eval_example.update_examples: 39 | eval_example.format(example) 40 | else: 41 | eval_example.lint(example) 42 | -------------------------------------------------------------------------------- /tests/test_inspection.py: -------------------------------------------------------------------------------- 1 | import platform 2 | import sys 3 | 4 | import pytest 5 | 6 | from dirty_equals import AnyThing, HasAttributes, HasName, HasRepr, IsInstance, IsInt, IsStr 7 | 8 | 9 | class Foo: 10 | def __init__(self, a=1, b=2): 11 | self.a = a 12 | self.b = b 13 | 14 | def spam(self): 15 | pass 16 | 17 | 18 | def dirty_repr(value): 19 | if hasattr(value, 'equals'): 20 | return repr(value) 21 | return '' 22 | 23 | 24 | def test_is_instance_of(): 25 | assert Foo() == IsInstance(Foo) 26 | assert Foo() == IsInstance[Foo] 27 | assert 1 != IsInstance[Foo] 28 | 29 | 30 | class Bar(Foo): 31 | def __repr__(self): 32 | return f'Bar(a={self.a}, b={self.b})' 33 | 34 | 35 | def test_is_instance_of_inherit(): 36 | assert Bar() == IsInstance(Foo) 37 | assert Foo() == IsInstance(Foo, only_direct_instance=True) 38 | assert Bar() != IsInstance(Foo, only_direct_instance=True) 39 | 40 | assert Foo != IsInstance(Foo) 41 | assert Bar != IsInstance(Foo) 42 | assert type != IsInstance(Foo) 43 | 44 | 45 | def test_is_instance_of_repr(): 46 | assert repr(IsInstance) == 'IsInstance' 47 | assert repr(IsInstance(Foo)) == "IsInstance()" 48 | 49 | 50 | def even(x): 51 | return x % 2 == 0 52 | 53 | 54 | @pytest.mark.parametrize( 55 | 'value,dirty', 56 | [ 57 | (Foo, HasName('Foo')), 58 | (Foo, HasName['Foo']), 59 | (Foo(), HasName('Foo')), 60 | (Foo(), ~HasName('Foo', allow_instances=False)), 61 | (Bar, ~HasName('Foo')), 62 | (int, HasName('int')), 63 | (42, HasName('int')), 64 | (even, HasName('even')), 65 | (Foo().spam, HasName('spam')), 66 | (Foo.spam, HasName('spam')), 67 | (Foo, HasName(IsStr(regex='F..'))), 68 | (Bar, ~HasName(IsStr(regex='F..'))), 69 | ], 70 | ids=dirty_repr, 71 | ) 72 | def test_has_name(value, dirty): 73 | assert value == dirty 74 | 75 | 76 | pypy38 = pytest.mark.skipif( 77 | platform.python_implementation() == 'PyPy' and sys.version_info[:2] == (3, 8), 78 | reason='pypy3.8 fails with this specific case 🤷', 79 | ) 80 | 81 | 82 | @pytest.mark.parametrize( 83 | 'value,dirty', 84 | [ 85 | (Foo(1, 2), HasAttributes(a=1, b=2)), 86 | (Foo(1, 's'), HasAttributes(a=IsInt(), b=IsStr())), 87 | (Foo(1, 2), ~HasAttributes(a=IsInt(), b=IsStr())), 88 | (Foo(1, 2), ~HasAttributes(a=1, b=2, c=3)), 89 | pytest.param(Foo(1, 2), HasAttributes(a=1, b=2, spam=AnyThing), marks=pypy38), 90 | (Foo(1, 2), ~HasAttributes(a=1, b=2, missing=AnyThing)), 91 | ], 92 | ids=dirty_repr, 93 | ) 94 | def test_has_attributes(value, dirty): 95 | assert value == dirty 96 | 97 | 98 | @pytest.mark.parametrize( 99 | 'value,dirty', 100 | [ 101 | (Bar(1, 2), HasRepr('Bar(a=1, b=2)')), 102 | (Bar(1, 2), HasRepr['Bar(a=1, b=2)']), 103 | (4, ~HasRepr('Bar(a=1, b=2)')), 104 | (Foo(), HasRepr(IsStr(regex=r''))), 105 | (Foo, HasRepr("")), 106 | (42, HasRepr('42')), 107 | (43, ~HasRepr('42')), 108 | ], 109 | ids=dirty_repr, 110 | ) 111 | def test_has_repr(value, dirty): 112 | assert value == dirty 113 | -------------------------------------------------------------------------------- /tests/test_list_tuple.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from dirty_equals import AnyThing, Contains, HasLen, IsInt, IsList, IsListOrTuple, IsNegative, IsTuple 4 | 5 | 6 | @pytest.mark.parametrize( 7 | 'other,dirty', 8 | [ 9 | ([], IsList), 10 | ((), IsTuple), 11 | ([], IsList()), 12 | ([1], IsList(length=1)), 13 | ((), IsTuple()), 14 | ([1, 2, 3], IsList(1, 2, 3)), 15 | ((1, 2, 3), IsTuple(1, 2, 3)), 16 | ((1, 2, 3), IsListOrTuple(1, 2, 3)), 17 | ([1, 2, 3], IsListOrTuple(1, 2, 3)), 18 | ([1, 2, 3, 4, 5], IsList(1, 2, 3, length=5)), 19 | ([1, 2, 3, 4, 5], IsList(1, 2, 3, length=(4, 6))), 20 | ([1, 2, 3, 4, 5], IsList(1, 2, 3, length=[4, 6])), 21 | ([1, 2, 3, 4, 5], IsList(1, 2, 3, length=(4, ...))), 22 | ([3, 2, 1], IsList(1, 2, 3, check_order=False)), 23 | ([{1: 2}, 7], IsList(7, {1: 2}, check_order=False)), 24 | ([1, 2, 3, 4], IsList(positions={0: 1, 2: 3, -1: 4})), 25 | ([1, 2, 3], IsList(AnyThing, 2, 3)), 26 | ([1, 2, 3], IsList(1, 2, IsInt)), 27 | ([3, 2, 1], IsList(1, 2, IsInt, check_order=False)), 28 | ([1, 2, 2], IsList(2, 2, 1, check_order=False)), 29 | ([], HasLen(0)), 30 | ([1, 2, 3], HasLen(3)), 31 | ('123', HasLen(3)), 32 | (b'123', HasLen(3)), 33 | ({'a': 1, 'b': 2, 'c': 3}, HasLen(3)), 34 | ([1, 2], HasLen(1, 2)), 35 | ([1, 2], HasLen(2, 3)), 36 | ([1, 2, 3], HasLen(2, ...)), 37 | ([1, 2, 3], HasLen(0, ...)), 38 | ([1, 2, 3], Contains(1)), 39 | ([1, 2, 3], Contains(1, 2)), 40 | ((1, 2, 3), Contains(1)), 41 | ({1, 2, 3}, Contains(1)), 42 | ('abc', Contains('b')), 43 | ({'a': 1, 'b': 2}, Contains('a')), 44 | ([{'a': 1}, {'b': 2}], Contains({'a': 1})), 45 | ], 46 | ) 47 | def test_dirty_equals(other, dirty): 48 | assert other == dirty 49 | 50 | 51 | @pytest.mark.parametrize( 52 | 'other,dirty', 53 | [ 54 | ([], IsTuple), 55 | ((), IsList), 56 | ([1], IsList), 57 | ([1, 2, 3], IsTuple(1, 2, 3)), 58 | ((1, 2, 3), IsList(1, 2, 3)), 59 | ([1, 2, 3, 4], IsList(1, 2, 3)), 60 | ([1, 2, 3, 4, 5], IsList(1, 2, 3, length=6)), 61 | ([1, 2, 3, 4, 5], IsList(1, 2, 3, length=(6, 8))), 62 | ([1, 2, 3, 4, 5], IsList(1, 2, 3, length=(0, 2))), 63 | ([1, 2, 3, 4, 5], IsList(1, 2, 3, length=[1, 2])), 64 | ([1, 2, 3, 4, 5], IsList(1, 2, 3, length=(6, ...))), 65 | ([3, 2, 1, 4], IsList(1, 2, 3, check_order=False)), 66 | ([1, 2, 3, 4], IsList(positions={0: 1, 2: 3, -1: 5})), 67 | ([1, 2, 3], IsList(1, 2, IsNegative)), 68 | ([1, 2, 2], IsList(1, 2, 3, check_order=False)), 69 | ([1, 2, 3], IsList(1, 2, 2, check_order=False)), 70 | ([1], HasLen(0)), 71 | ([], HasLen(1)), 72 | ('abc', HasLen(2)), 73 | ([1, 2, 3], HasLen(1, 2)), 74 | ([1], HasLen(2, 3)), 75 | ([1], HasLen(2, ...)), 76 | (123, HasLen(0, ...)), 77 | ([1, 2, 3], Contains(10)), 78 | ([1, 2, 3], Contains(1, 'a')), 79 | ([1, 2, 3], Contains(1, 'a')), 80 | ([{'a': 1}, {'b': 2}], Contains({'a': 2})), 81 | ({1, 2, 3}, Contains({1: 2})), 82 | ], 83 | ) 84 | def test_dirty_not_equals(other, dirty): 85 | assert other != dirty 86 | 87 | 88 | def test_args_and_positions(): 89 | with pytest.raises(TypeError, match='IsList requires either args or positions, not both'): 90 | IsList(1, 2, positions={0: 1}) 91 | 92 | 93 | def test_positions_with_check_order(): 94 | with pytest.raises(TypeError, match='check_order=False is not compatible with positions'): 95 | IsList(check_order=False, positions={0: 1}) 96 | 97 | 98 | def test_wrong_length_length(): 99 | with pytest.raises(TypeError, match='length must be a tuple of length 2, not 3'): 100 | IsList(1, 2, length=(1, 2, 3)) 101 | 102 | 103 | @pytest.mark.parametrize( 104 | 'dirty,repr_str', 105 | [ 106 | (IsList, 'IsList'), 107 | (IsTuple(1, 2, 3), 'IsTuple(1, 2, 3)'), 108 | (IsList(positions={1: 10, 2: 20}), 'IsList(positions={1: 10, 2: 20})'), 109 | (IsTuple(1, 2, 3, length=4), 'IsTuple(1, 2, 3, length=4)'), 110 | (IsTuple(1, 2, 3, length=(6, ...)), 'IsTuple(1, 2, 3, length=(6, ...))'), 111 | (IsTuple(1, 2, 3, length=(6, 'x')), 'IsTuple(1, 2, 3, length=(6, ...))'), 112 | (IsTuple(1, 2, 3, length=(6, 10)), 'IsTuple(1, 2, 3, length=(6, 10))'), 113 | (IsTuple(1, 2, 3, check_order=False), 'IsTuple(1, 2, 3, check_order=False)'), 114 | (HasLen(42), 'HasLen(42)'), 115 | (HasLen(0, ...), 'HasLen(0, ...)'), 116 | ], 117 | ) 118 | def test_repr(dirty, repr_str): 119 | assert repr(dirty) == repr_str 120 | 121 | 122 | def test_no_contains_value(): 123 | with pytest.raises(TypeError): 124 | Contains() 125 | -------------------------------------------------------------------------------- /tests/test_numeric.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from dirty_equals import ( 4 | IsApprox, 5 | IsFloat, 6 | IsFloatInf, 7 | IsFloatInfNeg, 8 | IsFloatInfPos, 9 | IsFloatNan, 10 | IsInt, 11 | IsNegative, 12 | IsNegativeFloat, 13 | IsNegativeInt, 14 | IsNonNegative, 15 | IsNonPositive, 16 | IsPositive, 17 | IsPositiveFloat, 18 | IsPositiveInt, 19 | ) 20 | 21 | 22 | @pytest.mark.parametrize( 23 | 'other,dirty', 24 | [ 25 | (1, IsInt), 26 | (1, IsInt()), 27 | (1, IsInt(exactly=1)), 28 | (1, IsPositiveInt), 29 | (-1, IsNegativeInt), 30 | (-1.0, IsFloat), 31 | (-1.0, IsFloat(exactly=-1.0)), 32 | (1.0, IsPositiveFloat), 33 | (-1.0, IsNegativeFloat), 34 | (1, IsPositive), 35 | (1.0, IsPositive), 36 | (-1, IsNegative), 37 | (-1.0, IsNegative), 38 | (5, IsInt(gt=4)), 39 | (5, IsInt(ge=5)), 40 | (5, IsInt(lt=6)), 41 | (5, IsInt(le=5)), 42 | (1, IsApprox(1)), 43 | (1, IsApprox(2, delta=1)), 44 | (100, IsApprox(99)), 45 | (-100, IsApprox(-99)), 46 | (0, IsNonNegative), 47 | (1, IsNonNegative), 48 | (0.0, IsNonNegative), 49 | (1.0, IsNonNegative), 50 | (0, IsNonPositive), 51 | (-1, IsNonPositive), 52 | (0.0, IsNonPositive), 53 | (-1.0, IsNonPositive), 54 | (-1, IsNonPositive & IsInt), 55 | (1, IsNonNegative & IsInt), 56 | (float('inf'), IsFloatInf), 57 | (-float('inf'), IsFloatInf), 58 | (float('-inf'), IsFloatInf), 59 | (float('inf'), IsFloatInfPos), 60 | (-float('-inf'), IsFloatInfPos), 61 | (-float('inf'), IsFloatInfNeg), 62 | (float('-inf'), IsFloatInfNeg), 63 | (float('nan'), IsFloatNan), 64 | (-float('nan'), IsFloatNan), 65 | (float('-nan'), IsFloatNan), 66 | ], 67 | ) 68 | def test_dirty_equals(other, dirty): 69 | assert other == dirty 70 | 71 | 72 | @pytest.mark.parametrize( 73 | 'other,dirty', 74 | [ 75 | (1.0, IsInt), 76 | (1.2, IsInt), 77 | (1, IsInt(exactly=2)), 78 | (True, IsInt), 79 | (False, IsInt), 80 | (1.0, IsInt()), 81 | (-1, IsPositiveInt), 82 | (0, IsPositiveInt), 83 | (1, IsNegativeInt), 84 | (0, IsNegativeInt), 85 | (1, IsFloat), 86 | (1, IsFloat(exactly=1.0)), 87 | (1.1234, IsFloat(exactly=1.0)), 88 | (-1.0, IsPositiveFloat), 89 | (0.0, IsPositiveFloat), 90 | (1.0, IsNegativeFloat), 91 | (0.0, IsNegativeFloat), 92 | (-1, IsPositive), 93 | (-1.0, IsPositive), 94 | (4, IsInt(gt=4)), 95 | (4, IsInt(ge=5)), 96 | (6, IsInt(lt=6)), 97 | (6, IsInt(le=5)), 98 | (-1, IsNonNegative), 99 | (-1.0, IsNonNegative), 100 | (1, IsNonPositive), 101 | (1.0, IsNonPositive), 102 | (-1.0, IsNonPositive & IsInt), 103 | (1.0, IsNonNegative & IsInt), 104 | (1, IsFloatNan), 105 | (1.0, IsFloatNan), 106 | (1, IsFloatInf), 107 | (1.0, IsFloatInf), 108 | (-float('inf'), IsFloatInfPos), 109 | (float('-inf'), IsFloatInfPos), 110 | (-float('-inf'), IsFloatInfNeg), 111 | (-float('-inf'), IsFloatInfNeg), 112 | ], 113 | ids=repr, 114 | ) 115 | def test_dirty_not_equals(other, dirty): 116 | assert other != dirty 117 | 118 | 119 | def test_invalid_approx_gt(): 120 | with pytest.raises(TypeError, match='"approx" cannot be combined with "gt", "lt", "ge", or "le"'): 121 | IsInt(approx=1, gt=1) 122 | 123 | 124 | def test_invalid_exactly_approx(): 125 | with pytest.raises(TypeError, match='"exactly" cannot be combined with "approx"'): 126 | IsInt(exactly=1, approx=1) 127 | 128 | 129 | def test_invalid_exactly_gt(): 130 | with pytest.raises(TypeError, match='"exactly" cannot be combined with "gt", "lt", "ge", or "le"'): 131 | IsInt(exactly=1, gt=1) 132 | 133 | 134 | def test_not_int(): 135 | d = IsInt() 136 | with pytest.raises(AssertionError): 137 | assert '1' == d 138 | assert repr(d) == 'IsInt()' 139 | 140 | 141 | def test_not_negative(): 142 | d = IsNegativeInt 143 | with pytest.raises(AssertionError): 144 | assert 1 == d 145 | assert repr(d) == 'IsNegativeInt' 146 | -------------------------------------------------------------------------------- /tests/test_other.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from dataclasses import dataclass 3 | from enum import Enum, auto 4 | from hashlib import md5, sha1, sha256 5 | from ipaddress import IPv4Address, IPv4Network, IPv6Address, IPv6Network 6 | 7 | import pytest 8 | 9 | from dirty_equals import ( 10 | FunctionCheck, 11 | IsDataclass, 12 | IsDataclassType, 13 | IsEnum, 14 | IsHash, 15 | IsInt, 16 | IsIP, 17 | IsJson, 18 | IsPartialDataclass, 19 | IsStr, 20 | IsStrictDataclass, 21 | IsUrl, 22 | IsUUID, 23 | ) 24 | 25 | 26 | class FooEnum(Enum): 27 | a = auto() 28 | b = auto() 29 | c = 'c' 30 | 31 | 32 | @dataclass 33 | class Foo: 34 | a: int 35 | b: int 36 | c: str 37 | 38 | 39 | foo = Foo(1, 2, 'c') 40 | 41 | 42 | @pytest.mark.parametrize( 43 | 'other,dirty', 44 | [ 45 | (uuid.uuid4(), IsUUID()), 46 | (uuid.uuid4(), IsUUID), 47 | (uuid.uuid4(), IsUUID(4)), 48 | ('edf9f29e-45c7-431c-99db-28ea44df9785', IsUUID), 49 | ('edf9f29e-45c7-431c-99db-28ea44df9785', IsUUID(4)), 50 | ('edf9f29e45c7431c99db28ea44df9785', IsUUID(4)), 51 | (uuid.uuid3(uuid.UUID('edf9f29e-45c7-431c-99db-28ea44df9785'), 'abc'), IsUUID), 52 | (uuid.uuid3(uuid.UUID('edf9f29e-45c7-431c-99db-28ea44df9785'), 'abc'), IsUUID(3)), 53 | (uuid.uuid1(), IsUUID(1)), 54 | (str(uuid.uuid1()), IsUUID(1)), 55 | ('ea9e828d-fd18-3898-99f3-5a46dbcee036', IsUUID(3)), 56 | ], 57 | ) 58 | def test_is_uuid_true(other, dirty): 59 | assert other == dirty 60 | 61 | 62 | @pytest.mark.parametrize( 63 | 'other,dirty', 64 | [ 65 | ('foobar', IsUUID()), 66 | ([1, 2, 3], IsUUID()), 67 | ('edf9f29e-45c7-431c-99db-28ea44df9785', IsUUID(5)), 68 | (uuid.uuid3(uuid.UUID('edf9f29e-45c7-431c-99db-28ea44df9785'), 'abc'), IsUUID(4)), 69 | (uuid.uuid1(), IsUUID(4)), 70 | ('edf9f29e-45c7-431c-99db-28ea44df9785', IsUUID(1)), 71 | ('ea9e828d-fd18-3898-99f3-5a46dbcee036', IsUUID(4)), 72 | ], 73 | ) 74 | def test_is_uuid_false(other, dirty): 75 | assert other != dirty 76 | 77 | 78 | def test_is_uuid_false_repr(): 79 | is_uuid = IsUUID() 80 | with pytest.raises(AssertionError): 81 | assert '123' == is_uuid 82 | assert str(is_uuid) == 'IsUUID(*)' 83 | 84 | 85 | def test_is_uuid4_false_repr(): 86 | is_uuid = IsUUID(4) 87 | with pytest.raises(AssertionError): 88 | assert '123' == is_uuid 89 | assert str(is_uuid) == 'IsUUID(4)' 90 | 91 | 92 | @pytest.mark.parametrize('json_value', ['null', '"xyz"', '[1, 2, 3]', '{"a": 1}']) 93 | def test_is_json_any_true(json_value): 94 | assert json_value == IsJson() 95 | assert json_value == IsJson 96 | 97 | 98 | def test_is_json_any_false(): 99 | is_json = IsJson() 100 | with pytest.raises(AssertionError): 101 | assert 'foobar' == is_json 102 | assert str(is_json) == 'IsJson(*)' 103 | 104 | 105 | @pytest.mark.parametrize( 106 | 'json_value,expected_value', 107 | [ 108 | ('null', None), 109 | ('"xyz"', 'xyz'), 110 | ('[1, 2, 3]', [1, 2, 3]), 111 | ('{"a": 1}', {'a': 1}), 112 | ], 113 | ) 114 | def test_is_json_specific_true(json_value, expected_value): 115 | assert json_value == IsJson(expected_value) 116 | assert json_value == IsJson[expected_value] 117 | 118 | 119 | def test_is_json_invalid(): 120 | assert 'invalid json' != IsJson 121 | assert 123 != IsJson 122 | assert [1, 2] != IsJson 123 | 124 | 125 | def test_is_json_kwargs(): 126 | assert '{"a": 1, "b": 2}' == IsJson(a=1, b=2) 127 | assert '{"a": 1, "b": 3}' != IsJson(a=1, b=2) 128 | 129 | 130 | def test_is_json_specific_false(): 131 | is_json = IsJson([1, 2, 3]) 132 | with pytest.raises(AssertionError): 133 | assert '{"a": 1}' == is_json 134 | assert str(is_json) == 'IsJson([1, 2, 3])' 135 | 136 | 137 | def test_equals_function(): 138 | func_argument = None 139 | 140 | def foo(v): 141 | nonlocal func_argument 142 | func_argument = v 143 | return v % 2 == 0 144 | 145 | assert 4 == FunctionCheck(foo) 146 | assert func_argument == 4 147 | assert 5 != FunctionCheck(foo) 148 | 149 | 150 | def test_equals_function_fail(): 151 | def foobar(v): 152 | return False 153 | 154 | c = FunctionCheck(foobar) 155 | 156 | with pytest.raises(AssertionError): 157 | assert 4 == c 158 | 159 | assert str(c) == 'FunctionCheck(foobar)' 160 | 161 | 162 | def test_json_both(): 163 | with pytest.raises(TypeError, match='IsJson requires either an argument or kwargs, not both'): 164 | IsJson(1, a=2) 165 | 166 | 167 | @pytest.mark.parametrize( 168 | 'other,dirty', 169 | [ 170 | (IPv4Address('127.0.0.1'), IsIP()), 171 | (IPv4Network('43.48.0.0/12'), IsIP()), 172 | (IPv6Address('::eeff:ae3f:d473'), IsIP()), 173 | (IPv6Network('::eeff:ae3f:d473/128'), IsIP()), 174 | ('2001:0db8:0a0b:12f0:0000:0000:0000:0001', IsIP()), 175 | ('179.27.154.96', IsIP), 176 | ('43.62.123.119', IsIP(version=4)), 177 | ('::ffff:2b3e:7b77', IsIP(version=6)), 178 | ('0:0:0:0:0:ffff:2b3e:7b77', IsIP(version=6)), 179 | ('54.43.53.219/10', IsIP(version=4, netmask='255.192.0.0')), 180 | ('::ffff:aebf:d473/12', IsIP(version=6, netmask='fff0::')), 181 | ('2001:0db8:0a0b:12f0:0000:0000:0000:0001', IsIP(version=6)), 182 | (3232235521, IsIP()), 183 | (b'\xc0\xa8\x00\x01', IsIP()), 184 | (338288524927261089654018896845572831328, IsIP(version=6)), 185 | (b'\x20\x01\x06\x58\x02\x2a\xca\xfe\x02\x00\x00\x00\x00\x00\x00\x01', IsIP(version=6)), 186 | ], 187 | ) 188 | def test_is_ip_true(other, dirty): 189 | assert other == dirty 190 | 191 | 192 | @pytest.mark.parametrize( 193 | 'other,dirty', 194 | [ 195 | ('foobar', IsIP()), 196 | ([1, 2, 3], IsIP()), 197 | ('210.115.28.193', IsIP(version=6)), 198 | ('::ffff:d273:1cc1', IsIP(version=4)), 199 | ('210.115.28.193/12', IsIP(version=6, netmask='255.255.255.0')), 200 | ('::ffff:d273:1cc1', IsIP(version=6, netmask='fff0::')), 201 | (3232235521, IsIP(version=6)), 202 | (338288524927261089654018896845572831328, IsIP(version=4)), 203 | ], 204 | ) 205 | def test_is_ip_false(other, dirty): 206 | assert other != dirty 207 | 208 | 209 | def test_not_ip_repr(): 210 | is_ip = IsIP() 211 | with pytest.raises(AssertionError): 212 | assert '123' == is_ip 213 | assert str(is_ip) == 'IsIP()' 214 | 215 | 216 | def test_ip_bad_netmask(): 217 | with pytest.raises(TypeError, match='To check the netmask you must specify the IP version'): 218 | IsIP(netmask='255.255.255.0') 219 | 220 | 221 | @pytest.mark.parametrize( 222 | 'other,dirty', 223 | [ 224 | ('f1e069787ECE74531d112559945c6871', IsHash('md5')), 225 | ('40bd001563085fc35165329ea1FF5c5ecbdbbeef', IsHash('sha-1')), 226 | ('a665a45920422f9d417e4867eFDC4fb8a04a1f3fff1fa07e998e86f7f7a27ae3', IsHash('sha-256')), 227 | (b'f1e069787ECE74531d112559945c6871', IsHash('md5')), 228 | (bytearray(b'f1e069787ECE74531d112559945c6871'), IsHash('md5')), 229 | ], 230 | ) 231 | def test_is_hash_true(other, dirty): 232 | assert other == dirty 233 | 234 | 235 | @pytest.mark.parametrize( 236 | 'other,dirty', 237 | [ 238 | ('foobar', IsHash('md5')), 239 | (b'\x81 UnicodeDecodeError', IsHash('md5')), 240 | ([1, 2, 3], IsHash('sha-1')), 241 | ('f1e069787ECE74531d112559945c6871d', IsHash('md5')), 242 | ('400bd001563085fc35165329ea1FF5c5ecbdbbeef', IsHash('sha-1')), 243 | ('a665a45920422g9d417e4867eFDC4fb8a04a1f3fff1fa07e998e86f7f7a27ae3', IsHash('sha-256')), 244 | ], 245 | ) 246 | def test_is_hash_false(other, dirty): 247 | assert other != dirty 248 | 249 | 250 | @pytest.mark.parametrize( 251 | 'hash_type', 252 | ['md5', 'sha-1', 'sha-256'], 253 | ) 254 | def test_is_hash_md5_false_repr(hash_type): 255 | is_hash = IsHash(hash_type) 256 | with pytest.raises(AssertionError): 257 | assert '123' == is_hash 258 | assert str(is_hash) == f"IsHash('{hash_type}')" 259 | 260 | 261 | @pytest.mark.parametrize( 262 | 'hash_func, hash_type', 263 | [(md5, 'md5'), (sha1, 'sha-1'), (sha256, 'sha-256')], 264 | ) 265 | def test_hashlib_hashes(hash_func, hash_type): 266 | assert hash_func(b'dirty equals').hexdigest() == IsHash(hash_type) 267 | 268 | 269 | def test_wrong_hash_type(): 270 | with pytest.raises(ValueError, match='Hash type must be one of the following values: md5, sha-1, sha-256'): 271 | assert '123' == IsHash('ntlm') 272 | 273 | 274 | @pytest.mark.parametrize( 275 | 'other,dirty', 276 | [ 277 | ('https://example.com', IsUrl), 278 | ('https://example.com', IsUrl(scheme='https')), 279 | ('postgres://user:pass@localhost:5432/app', IsUrl(postgres_dsn=True)), 280 | ], 281 | ) 282 | def test_is_url_true(other, dirty): 283 | assert other == dirty 284 | 285 | 286 | @pytest.mark.parametrize( 287 | 'other,dirty', 288 | [ 289 | ('https://example.com', IsUrl(postgres_dsn=True)), 290 | ('https://example.com', IsUrl(scheme='http')), 291 | ('definitely not a url', IsUrl), 292 | (42, IsUrl), 293 | ('https://anotherexample.com', IsUrl(postgres_dsn=True)), 294 | ], 295 | ) 296 | def test_is_url_false(other, dirty): 297 | assert other != dirty 298 | 299 | 300 | def test_is_url_invalid_kwargs(): 301 | with pytest.raises( 302 | TypeError, 303 | match='IsURL only checks these attributes: scheme, host, host_type, user, password, port, path, query, ' 304 | 'fragment', 305 | ): 306 | IsUrl(https=True) 307 | 308 | 309 | def test_is_url_too_many_url_types(): 310 | with pytest.raises( 311 | ValueError, 312 | match='You can only check against one Pydantic url type at a time', 313 | ): 314 | assert 'https://example.com' == IsUrl(any_url=True, http_url=True, postgres_dsn=True) 315 | 316 | 317 | @pytest.mark.parametrize( 318 | 'other,dirty', 319 | [ 320 | (Foo, IsDataclassType), 321 | (Foo, IsDataclassType()), 322 | ], 323 | ) 324 | def test_is_dataclass_type_true(other, dirty): 325 | assert other == dirty 326 | 327 | 328 | @pytest.mark.parametrize( 329 | 'other,dirty', 330 | [ 331 | (foo, IsDataclassType), 332 | (foo, IsDataclassType()), 333 | (Foo, IsDataclass), 334 | ], 335 | ) 336 | def test_is_dataclass_type_false(other, dirty): 337 | assert other != dirty 338 | 339 | 340 | @pytest.mark.parametrize( 341 | 'other,dirty', 342 | [ 343 | (foo, IsDataclass), 344 | (foo, IsDataclass()), 345 | (foo, IsDataclass(a=IsInt, b=2, c=IsStr)), 346 | (foo, IsDataclass(a=1, c='c', b=2).settings(strict=False, partial=False)), 347 | (foo, IsDataclass(a=1, b=2, c='c').settings(strict=True, partial=False)), 348 | (foo, IsStrictDataclass(a=1, b=2, c='c')), 349 | (foo, IsDataclass(c='c', a=1).settings(strict=False, partial=True)), 350 | (foo, IsPartialDataclass(c='c', a=1)), 351 | (foo, IsDataclass(b=2, c='c').settings(strict=True, partial=True)), 352 | (foo, IsStrictDataclass(b=2, c='c').settings(partial=True)), 353 | (foo, IsPartialDataclass(b=2, c='c').settings(strict=True)), 354 | ], 355 | ) 356 | def test_is_dataclass_true(other, dirty): 357 | assert other == dirty 358 | 359 | 360 | @pytest.mark.parametrize( 361 | 'other,dirty', 362 | [ 363 | (foo, IsDataclassType), 364 | (Foo, IsDataclass), 365 | (foo, IsDataclass(a=1)), 366 | (foo, IsDataclass(a=IsStr, b=IsInt, c=IsStr)), 367 | (foo, IsDataclass(b=2, a=1, c='c').settings(strict=True)), 368 | (foo, IsStrictDataclass(b=2, a=1, c='c')), 369 | (foo, IsDataclass(b=2, a=1).settings(partial=False)), 370 | ], 371 | ) 372 | def test_is_dataclass_false(other, dirty): 373 | assert other != dirty 374 | 375 | 376 | @pytest.mark.parametrize( 377 | 'other,dirty', 378 | [ 379 | (FooEnum.a, IsEnum), 380 | (FooEnum.b, IsEnum(FooEnum)), 381 | (2, IsEnum(FooEnum)), 382 | ('c', IsEnum(FooEnum)), 383 | ], 384 | ) 385 | def test_is_enum_true(other, dirty): 386 | assert other == dirty 387 | 388 | 389 | @pytest.mark.parametrize( 390 | 'other,dirty', 391 | [ 392 | (FooEnum, IsEnum), 393 | (FooEnum, IsEnum(FooEnum)), 394 | (4, IsEnum(FooEnum)), 395 | ], 396 | ) 397 | def test_is_enum_false(other, dirty): 398 | assert other != dirty 399 | -------------------------------------------------------------------------------- /tests/test_strings.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | import pytest 4 | 5 | from dirty_equals import IsAnyStr, IsBytes, IsStr 6 | 7 | 8 | @pytest.mark.parametrize( 9 | 'value,dirty,match', 10 | [ 11 | # IsStr tests 12 | ('foo', IsStr, True), 13 | ('foo', IsStr(), True), 14 | (b'foo', IsStr, False), 15 | ('foo', IsStr(regex='fo{2}'), True), 16 | ('foo', IsStr(regex=b'fo{2}'), False), 17 | ('foo', IsStr(regex=re.compile('fo{2}')), True), 18 | ('Foo', IsStr(regex=re.compile('fo{2}', flags=re.I)), True), 19 | ('Foo', IsStr(regex=re.compile('fo{2}'), regex_flags=re.I), True), 20 | ('foo', IsStr(regex='fo'), False), 21 | ('foo', IsStr(regex='foo', max_length=2), False), 22 | ('foo\nbar', IsStr(regex='fo.*', regex_flags=re.S), True), 23 | ('foo\nbar', IsStr(regex='fo.*'), False), 24 | ('foo', IsStr(min_length=3), True), 25 | ('fo', IsStr(min_length=3), False), 26 | ('foo', IsStr(max_length=3), True), 27 | ('foobar', IsStr(max_length=3), False), 28 | ('foo', IsStr(case='lower'), True), 29 | ('FOO', IsStr(case='lower'), False), 30 | ('FOO', IsStr(case='upper'), True), 31 | ('foo', IsStr(case='upper'), False), 32 | # IsBytes tests 33 | (b'foo', IsBytes, True), 34 | (b'foo', IsBytes(), True), 35 | ('foo', IsBytes, False), 36 | (b'foo', IsBytes(regex=b'fo{2}'), True), 37 | (b'Foo', IsBytes(regex=re.compile(b'fo{2}', flags=re.I)), True), 38 | (b'Foo', IsBytes(regex=re.compile(b'fo{2}'), regex_flags=re.I), True), 39 | (b'foo', IsBytes(regex=b'fo'), False), 40 | (b'foo', IsBytes(regex=b'foo', max_length=2), False), 41 | (b'foo\nbar', IsBytes(regex=b'fo.*', regex_flags=re.S), True), 42 | (b'foo\nbar', IsBytes(regex=b'fo.*'), False), 43 | (b'foo', IsBytes(min_length=3), True), 44 | (b'fo', IsBytes(min_length=3), False), 45 | # IsAnyStr tests 46 | (b'foo', IsAnyStr, True), 47 | (b'foo', IsAnyStr(), True), 48 | ('foo', IsAnyStr, True), 49 | (b'foo', IsAnyStr(regex=b'fo{2}'), True), 50 | ('foo', IsAnyStr(regex=b'fo{2}'), True), 51 | (b'foo', IsAnyStr(regex='fo{2}'), True), 52 | ('foo', IsAnyStr(regex='fo{2}'), True), 53 | (b'Foo', IsAnyStr(regex=re.compile(b'fo{2}', flags=re.I)), True), 54 | ('Foo', IsAnyStr(regex=re.compile(b'fo{2}', flags=re.I)), True), 55 | (b'Foo', IsAnyStr(regex=re.compile(b'fo{2}'), regex_flags=re.I), True), 56 | ('Foo', IsAnyStr(regex=re.compile(b'fo{2}'), regex_flags=re.I), True), 57 | (b'Foo', IsAnyStr(regex=re.compile('fo{2}', flags=re.I)), True), 58 | ('Foo', IsAnyStr(regex=re.compile('fo{2}', flags=re.I)), True), 59 | (b'Foo', IsAnyStr(regex=re.compile('fo{2}'), regex_flags=re.I), True), 60 | ('Foo', IsAnyStr(regex=re.compile('fo{2}'), regex_flags=re.I), True), 61 | (b'foo\nbar', IsAnyStr(regex=b'fo.*', regex_flags=re.S), True), 62 | (b'foo\nbar', IsAnyStr(regex=b'fo.*'), False), 63 | ('foo', IsAnyStr(regex=b'foo', max_length=2), False), 64 | (b'foo', IsAnyStr(regex=b'foo', max_length=2), False), 65 | (b'foo', IsAnyStr(min_length=3), True), 66 | ('foo', IsAnyStr(min_length=3), True), 67 | (b'fo', IsAnyStr(min_length=3), False), 68 | ], 69 | ) 70 | def test_dirty_equals_true(value, dirty, match): 71 | if match: 72 | assert value == dirty 73 | else: 74 | assert value != dirty 75 | 76 | 77 | def test_regex_true(): 78 | assert 'whatever' == IsStr(regex='whatever') 79 | reg = IsStr(regex='wh.*er') 80 | assert 'whatever' == reg 81 | assert str(reg) == "'whatever'" 82 | 83 | 84 | def test_regex_bytes_true(): 85 | assert b'whatever' == IsBytes(regex=b'whatever') 86 | assert b'whatever' == IsBytes(regex=b'wh.*er') 87 | 88 | 89 | def test_regex_false(): 90 | reg = IsStr(regex='wh.*er') 91 | with pytest.raises(AssertionError): 92 | assert 'WHATEVER' == reg 93 | assert str(reg) == "IsStr(regex='wh.*er')" 94 | 95 | 96 | def test_regex_false_type_error(): 97 | assert 123 != IsStr(regex='wh.*er') 98 | 99 | reg = IsBytes(regex=b'wh.*er') 100 | with pytest.raises(AssertionError): 101 | assert 'whatever' == reg 102 | assert str(reg) == "IsBytes(regex=b'wh.*er')" 103 | 104 | 105 | def test_is_any_str(): 106 | assert 'foobar' == IsAnyStr 107 | assert b'foobar' == IsAnyStr 108 | assert 123 != IsAnyStr 109 | assert 'foo' == IsAnyStr(regex='foo') 110 | assert 'foo' == IsAnyStr(regex=b'foo') 111 | assert b'foo' == IsAnyStr(regex='foo') 112 | assert b'foo' == IsAnyStr(regex=b'foo') 113 | --------------------------------------------------------------------------------