├── .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 |
4 |
5 |
6 |
7 | Doing dirty (but extremely useful) things with equals.
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
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 |
84 |
--------------------------------------------------------------------------------
/docs/img/dirty-equals-logo-favicon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
56 |
--------------------------------------------------------------------------------
/docs/img/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/samuelcolvin/dirty-equals/9e6f0be981f982fdaae16671ab14aefc3ab8664a/docs/img/favicon.png
--------------------------------------------------------------------------------
/docs/img/logo-text.svg:
--------------------------------------------------------------------------------
1 |
22 |
--------------------------------------------------------------------------------
/docs/img/logo-white.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/docs/index.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Doing dirty (but extremely useful) things with equals.
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
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 |
--------------------------------------------------------------------------------