pax_global_header 0000666 0000000 0000000 00000000064 14656734655 0014537 g ustar 00root root 0000000 0000000 52 comment=da29a2db8293c4c974f303974a6e5ffdb462c9b4 dirty-equals-0.8.0/ 0000775 0000000 0000000 00000000000 14656734655 0014167 5 ustar 00root root 0000000 0000000 dirty-equals-0.8.0/.codecov.yml 0000664 0000000 0000000 00000000223 14656734655 0016407 0 ustar 00root root 0000000 0000000 coverage: precision: 2 range: [90, 100] status: patch: false project: false comment: layout: 'header, diff, flags, files, footer' dirty-equals-0.8.0/.github/ 0000775 0000000 0000000 00000000000 14656734655 0015527 5 ustar 00root root 0000000 0000000 dirty-equals-0.8.0/.github/FUNDING.yml 0000664 0000000 0000000 00000000025 14656734655 0017341 0 ustar 00root root 0000000 0000000 github: samuelcolvin dirty-equals-0.8.0/.github/workflows/ 0000775 0000000 0000000 00000000000 14656734655 0017564 5 ustar 00root root 0000000 0000000 dirty-equals-0.8.0/.github/workflows/ci.yml 0000664 0000000 0000000 00000011261 14656734655 0020703 0 ustar 00root root 0000000 0000000 name: CI on: push: branches: - main tags: - '**' pull_request: types: [opened, synchronize] jobs: test: name: test ${{ matrix.python-version }} on ${{ matrix.os }} strategy: fail-fast: false matrix: os: [ubuntu, macos] python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13'] # test pypy on ubuntu only to speed up CI, no reason why macos X pypy should fail separately include: - os: 'ubuntu' python-version: 'pypy-3.9' - os: 'ubuntu' python-version: 'pypy-3.10' runs-on: ${{ matrix.os }}-latest env: PYTHON: ${{ matrix.python-version }} OS: ${{ matrix.os }} steps: - uses: actions/checkout@v4 - name: set up python uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} allow-prereleases: true - run: pip install -r requirements/tests.txt -r requirements/pyproject.txt - run: make test - run: coverage xml - uses: codecov/codecov-action@v3 with: file: ./coverage.xml env_vars: PYTHON,OS lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: '3.12' - run: pip install -r requirements/linting.txt - uses: pre-commit/action@v3.0.0 with: extra_args: --all-files env: SKIP: no-commit-to-branch docs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: set up python uses: actions/setup-python@v5 with: python-version: '3.12' - name: install run: pip install -r requirements/docs.txt - name: install mkdocs-material-insiders if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/') run: pip install https://files.scolvin.com/${MKDOCS_TOKEN}/mkdocs_material-9.4.2+insiders.4.42.0-py3-none-any.whl env: MKDOCS_TOKEN: ${{ secrets.mkdocs_token }} - name: build site run: mkdocs build --strict - name: store docs site uses: actions/upload-artifact@v3 with: name: docs path: site check: # This job does nothing and is only used for the branch protection if: always() needs: [lint, test, docs] runs-on: ubuntu-latest steps: - name: Decide whether the needed jobs succeeded or failed uses: re-actors/alls-green@release/v1 id: all-green with: jobs: ${{ toJSON(needs) }} publish_docs: needs: [check] if: "success() && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/'))" runs-on: ubuntu-latest steps: - name: checkout docs-site uses: actions/checkout@v4 with: ref: docs-site - uses: actions/checkout@v4 - name: set up python uses: actions/setup-python@v5 with: python-version: '3.12' - name: install run: pip install -r requirements/docs.txt - name: install mkdocs-material-insiders run: pip install https://files.scolvin.com/${MKDOCS_TOKEN}/mkdocs_material-9.4.2+insiders.4.42.0-py3-none-any.whl env: MKDOCS_TOKEN: ${{ secrets.mkdocs_token }} - name: Set git credentials run: | git config --global user.name "${{ github.actor }}" git config --global user.email "${{ github.actor }}@users.noreply.github.com" - run: mike deploy -b docs-site dev --push if: github.ref == 'refs/heads/main' - name: check version if: "startsWith(github.ref, 'refs/tags/')" id: check-version uses: samuelcolvin/check-python-version@v3.2 with: version_file_path: 'dirty_equals/version.py' - run: mike deploy -b docs-site ${{ steps.check-version.outputs.VERSION_MAJOR_MINOR }} latest --update-aliases --push if: "startsWith(github.ref, 'refs/tags/') && !fromJSON(steps.check-version.outputs.IS_PRERELEASE)" deploy: needs: [check] if: "success() && startsWith(github.ref, 'refs/tags/')" runs-on: ubuntu-latest environment: release permissions: id-token: write steps: - uses: actions/checkout@v4 - name: set up python uses: actions/setup-python@v5 with: python-version: '3.12' - name: install run: pip install -U build - name: check version id: check-version uses: samuelcolvin/check-python-version@v3.2 with: version_file_path: 'dirty_equals/version.py' - name: build run: python -m build - name: Upload package to PyPI uses: pypa/gh-action-pypi-publish@release/v1 dirty-equals-0.8.0/.github/workflows/upload-previews.yml 0000664 0000000 0000000 00000001625 14656734655 0023441 0 ustar 00root root 0000000 0000000 name: Upload Previews on: workflow_run: workflows: [CI] types: [completed] permissions: statuses: write jobs: upload-previews: if: ${{ github.event.workflow_run.conclusion == 'success' }} runs-on: ubuntu-latest steps: - uses: actions/setup-python@v1 with: python-version: '3.10' - run: pip install click==8.0.4 - run: pip install smokeshow - uses: dawidd6/action-download-artifact@v2 with: workflow: ci.yml commit: ${{ github.event.workflow_run.head_sha }} - run: smokeshow upload docs env: SMOKESHOW_GITHUB_STATUS_DESCRIPTION: Docs Preview SMOKESHOW_GITHUB_CONTEXT: docs SMOKESHOW_GITHUB_TOKEN: ${{ secrets.github_token }} SMOKESHOW_GITHUB_PR_HEAD_SHA: ${{ github.event.workflow_run.head_sha }} SMOKESHOW_AUTH_KEY: ${{ secrets.smokeshow_auth_key }} dirty-equals-0.8.0/.gitignore 0000664 0000000 0000000 00000000326 14656734655 0016160 0 ustar 00root root 0000000 0000000 *.py[cod] .idea/ env/ env*/ .coverage .cache/ htmlcov/ media/ sandbox/ .pytest_cache/ *.egg-info/ /build/ /dist/ npm-debug.log* yarn-debug.log* yarn-error.log* /TODO.md /.mypy_cache/ /.ruff_cache/ /scratch/ /site/ dirty-equals-0.8.0/.pre-commit-config.yaml 0000664 0000000 0000000 00000001176 14656734655 0020455 0 ustar 00root root 0000000 0000000 repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.0.1 hooks: - id: no-commit-to-branch - id: check-yaml args: ['--unsafe'] - id: check-toml - id: end-of-file-fixer - id: trailing-whitespace - id: check-added-large-files - repo: local hooks: - id: format name: Format entry: make format types: [python] language: system pass_filenames: false - id: lint name: Lint entry: make lint types: [python] language: system pass_filenames: false - id: mypy name: Mypy entry: make mypy types: [python] language: system pass_filenames: false dirty-equals-0.8.0/LICENSE 0000664 0000000 0000000 00000002070 14656734655 0015173 0 ustar 00root root 0000000 0000000 The MIT License (MIT) Copyright (c) 2022 Samuel Colvin Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. dirty-equals-0.8.0/Makefile 0000664 0000000 0000000 00000003321 14656734655 0015626 0 ustar 00root root 0000000 0000000 .DEFAULT_GOAL := all sources = dirty_equals tests .PHONY: install install: pip install -U pip pre-commit pip-tools pip install -r requirements/all.txt pre-commit install .PHONY: refresh-lockfiles refresh-lockfiles: @echo "Replacing requirements/*.txt files using pip-compile" find requirements/ -name '*.txt' ! -name 'all.txt' -type f -delete make update-lockfiles .PHONY: update-lockfiles update-lockfiles: @echo "Updating requirements/*.txt files using pip-compile" pip-compile -q -o requirements/linting.txt requirements/linting.in pip-compile -q -o requirements/tests.txt -c requirements/linting.txt requirements/tests.in pip-compile -q -o requirements/docs.txt -c requirements/linting.txt -c requirements/tests.txt requirements/docs.in pip-compile -q -o requirements/pyproject.txt \ --extra pydantic \ -c requirements/linting.txt -c requirements/tests.txt -c requirements/docs.txt \ pyproject.toml pip install --dry-run -r requirements/all.txt .PHONY: format format: ruff check --fix-only $(sources) ruff format $(sources) .PHONY: lint lint: ruff check $(sources) ruff format --check $(sources) .PHONY: test test: TZ=utc coverage run -m pytest python tests/mypy_checks.py .PHONY: testcov testcov: test @coverage report --show-missing @coverage html .PHONY: mypy mypy: mypy dirty_equals tests/mypy_checks.py .PHONY: docs docs: mkdocs build --strict .PHONY: all all: lint mypy testcov docs .PHONY: clean clean: rm -rf `find . -name __pycache__` rm -f `find . -type f -name '*.py[co]' ` rm -f `find . -type f -name '*~' ` rm -f `find . -type f -name '.*~' ` rm -rf .cache rm -rf .pytest_cache rm -rf .mypy_cache rm -rf htmlcov rm -rf *.egg-info rm -f .coverage rm -f .coverage.* rm -rf build dirty-equals-0.8.0/README.md 0000664 0000000 0000000 00000007571 14656734655 0015460 0 ustar 00root root 0000000 0000000
Doing dirty (but extremely useful) things with equals.
--- **Documentation**: [dirty-equals.helpmanual.io](https://dirty-equals.helpmanual.io) **Source Code**: [github.com/samuelcolvin/dirty-equals](https://github.com/samuelcolvin/dirty-equals) --- **dirty-equals** is a python library that (mis)uses the `__eq__` method to make python code (generally unit tests) more declarative and therefore easier to read and write. *dirty-equals* can be used in whatever context you like, but it comes into its own when writing unit tests for applications where you're commonly checking the response to API calls and the contents of a database. ## Usage Here's a trivial example of what *dirty-equals* can do: ```py from dirty_equals import IsPositive assert 1 == IsPositive assert -2 == IsPositive # this will fail! ``` **That doesn't look very useful yet!**, but consider the following unit test code using *dirty-equals*: ```py title="More Powerful Usage" from dirty_equals import IsJson, IsNow, IsPositiveInt, IsStr ... # user_data is a dict returned from a database or API which we want to test assert user_data == { # we want to check that id is a positive int 'id': IsPositiveInt, # we know avatar_file should be a string, but we need a regex as we don't know whole value 'avatar_file': IsStr(regex=r'/[a-z0-9\-]{10}/example\.png'), # settings_json is JSON, but it's more robust to compare the value it encodes, not strings 'settings_json': IsJson({'theme': 'dark', 'language': 'en'}), # created_ts is datetime, we don't know the exact value, but we know it should be close to now 'created_ts': IsNow(delta=3), } ``` Without *dirty-equals*, you'd have to compare individual fields and/or modify some fields before comparison - the test would not be declarative or as clear. *dirty-equals* can do so much more than that, for example: * [`IsPartialDict`](https://dirty-equals.helpmanual.io/types/dict/#dirty_equals.IsPartialDict) lets you compare a subset of a dictionary * [`IsStrictDict`](https://dirty-equals.helpmanual.io/types/dict/#dirty_equals.IsStrictDict) lets you confirm order in a dictionary * [`IsList`](https://dirty-equals.helpmanual.io/types/sequence/#dirty_equals.IsList) and [`IsTuple`](https://dirty-equals.helpmanual.io/types/sequence/#dirty_equals.IsTuple) lets you compare partial lists and tuples, with or without order constraints * nesting any of these types inside any others * [`IsInstance`](https://dirty-equals.helpmanual.io/types/other/#dirty_equals.IsInstance) lets you simply confirm the type of an object * You can even use [boolean operators](https://dirty-equals.helpmanual.io/usage/#boolean-logic) `|` and `&` to combine multiple conditions * and much more... ## Installation Simply: ```bash pip install dirty-equals ``` **dirty-equals** requires **Python 3.8+**. dirty-equals-0.8.0/dirty_equals/ 0000775 0000000 0000000 00000000000 14656734655 0016674 5 ustar 00root root 0000000 0000000 dirty-equals-0.8.0/dirty_equals/__init__.py 0000664 0000000 0000000 00000004066 14656734655 0021013 0 ustar 00root root 0000000 0000000 from ._base import AnyThing, DirtyEquals, IsOneOf from ._boolean import IsFalseLike, IsTrueLike from ._datetime import IsDate, IsDatetime, IsNow, IsToday from ._dict import IsDict, IsIgnoreDict, IsPartialDict, IsStrictDict from ._inspection import HasAttributes, HasName, HasRepr, IsInstance from ._numeric import ( IsApprox, IsFloat, IsFloatInf, IsFloatInfNeg, IsFloatInfPos, IsFloatNan, IsInt, IsNegative, IsNegativeFloat, IsNegativeInt, IsNonNegative, IsNonPositive, IsNumber, IsNumeric, IsPositive, IsPositiveFloat, IsPositiveInt, ) from ._other import ( FunctionCheck, IsDataclass, IsDataclassType, IsEnum, IsHash, IsIP, IsJson, IsPartialDataclass, IsStrictDataclass, IsUrl, IsUUID, ) from ._sequence import Contains, HasLen, IsList, IsListOrTuple, IsTuple from ._strings import IsAnyStr, IsBytes, IsStr from .version import VERSION __all__ = ( # base 'DirtyEquals', 'AnyThing', 'IsOneOf', # boolean 'IsTrueLike', 'IsFalseLike', # dataclass 'IsDataclass', 'IsDataclassType', 'IsPartialDataclass', 'IsStrictDataclass', # datetime 'IsDatetime', 'IsNow', 'IsDate', 'IsToday', # dict 'IsDict', 'IsPartialDict', 'IsIgnoreDict', 'IsStrictDict', # enum 'IsEnum', # sequence 'Contains', 'HasLen', 'IsList', 'IsTuple', 'IsListOrTuple', # numeric 'IsNumeric', 'IsApprox', 'IsNumber', 'IsPositive', 'IsNegative', 'IsNonPositive', 'IsNonNegative', 'IsInt', 'IsPositiveInt', 'IsNegativeInt', 'IsFloat', 'IsPositiveFloat', 'IsNegativeFloat', 'IsFloatInf', 'IsFloatInfNeg', 'IsFloatInfPos', 'IsFloatNan', # inspection 'HasAttributes', 'HasName', 'HasRepr', 'IsInstance', # other 'FunctionCheck', 'IsJson', 'IsUUID', 'IsUrl', 'IsHash', 'IsIP', # strings 'IsStr', 'IsBytes', 'IsAnyStr', # version '__version__', ) __version__ = VERSION dirty-equals-0.8.0/dirty_equals/_base.py 0000664 0000000 0000000 00000017742 14656734655 0020332 0 ustar 00root root 0000000 0000000 import io from abc import ABCMeta from pprint import PrettyPrinter from typing import TYPE_CHECKING, Any, Dict, Generic, Iterable, Optional, Protocol, Tuple, TypeVar from ._utils import Omit if TYPE_CHECKING: from typing import TypeAlias, Union # noqa: F401 __all__ = 'DirtyEqualsMeta', 'DirtyEquals', 'AnyThing', 'IsOneOf' class DirtyEqualsMeta(ABCMeta): def __eq__(self, other: Any) -> bool: # this is required as fancy things happen when creating generics which include equals checks, without it, # we get some recursive errors if self is DirtyEquals or other is Generic or other is Protocol: return False else: try: return self() == other except TypeError: # we don't want to raise a type error here since somewhere deep in pytest it does something like # type(a) == type(b), if we raised TypeError we would upset the pytest error message return False def __or__(self, other: Any) -> 'DirtyOr': # type: ignore[override] return DirtyOr(self, other) def __and__(self, other: Any) -> 'DirtyAnd': return DirtyAnd(self, other) def __invert__(self) -> 'DirtyNot': return DirtyNot(self) def __hash__(self) -> int: return hash(self.__name__) def __repr__(self) -> str: return self.__name__ T = TypeVar('T') class DirtyEquals(Generic[T], metaclass=DirtyEqualsMeta): """ Base type for all *dirty-equals* types. """ __slots__ = '_other', '_was_equal', '_repr_args', '_repr_kwargs' def __init__(self, *repr_args: Any, **repr_kwargs: Any): """ Args: *repr_args: unnamed args to be used in `__repr__` **repr_kwargs: named args to be used in `__repr__` """ self._other: Any = None self._was_equal: Optional[bool] = None self._repr_args: Iterable[Any] = repr_args self._repr_kwargs: Dict[str, Any] = repr_kwargs def equals(self, other: Any) -> bool: """ Abstract method, must be implemented by subclasses. `TypeError` and `ValueError` are caught in `__eq__` and indicate `other` is not equals to this type. """ raise NotImplementedError() @property def value(self) -> T: """ Property to get the value last successfully compared to this object. This is seldom very useful, put it's provided for completeness. Example of usage: ```py title=".values" from dirty_equals import IsStr token_is_str = IsStr(regex=r't-.+') assert 't-123' == token_is_str print(token_is_str.value) #> t-123 ``` """ if self._was_equal: return self._other else: raise AttributeError('value is not available until __eq__ has been called') def __eq__(self, other: Any) -> bool: self._other = other try: self._was_equal = self.equals(other) except (TypeError, ValueError): self._was_equal = False return self._was_equal def __ne__(self, other: Any) -> bool: # We don't set _was_equal to avoid strange errors in pytest self._other = other try: return not self.equals(other) except (TypeError, ValueError): return True def __or__(self, other: Any) -> 'DirtyOr': return DirtyOr(self, other) def __and__(self, other: Any) -> 'DirtyAnd': return DirtyAnd(self, other) def __invert__(self) -> 'DirtyNot': return DirtyNot(self) def _repr_ne(self) -> str: args = [repr(arg) for arg in self._repr_args if arg is not Omit] args += [f'{k}={v!r}' for k, v in self._repr_kwargs.items() if v is not Omit] return f'{self.__class__.__name__}({", ".join(args)})' def __repr__(self) -> str: if self._was_equal: # if we've got the correct value return it to aid in diffs return repr(self._other) else: # else return something which explains what's going on. return self._repr_ne() def _pprint_format(self, pprinter: PrettyPrinter, stream: io.StringIO, *args: Any, **kwargs: Any) -> None: # pytest diffs use pprint to format objects, so we patch pprint to call this method # for DirtyEquals objects. So this method needs to follow the same pattern as __repr__. # We check that the protected _format method actually exists # to be safe and to make linters happy. if self._was_equal and hasattr(pprinter, '_format'): pprinter._format(self._other, stream, *args, **kwargs) else: stream.write(repr(self)) # i.e. self._repr_ne() (for now) # Patch pprint to call _pprint_format for DirtyEquals objects # Check that the protected attribute _dispatch exists to be safe and to make linters happy. # The reason we modify _dispatch rather than _format # is that pytest sometimes uses a subclass of PrettyPrinter which overrides _format. if hasattr(PrettyPrinter, '_dispatch'): # pragma: no branch PrettyPrinter._dispatch[DirtyEquals.__repr__] = lambda pprinter, obj, *args, **kwargs: obj._pprint_format( pprinter, *args, **kwargs ) InstanceOrType: 'TypeAlias' = 'Union[DirtyEquals[Any], DirtyEqualsMeta]' class DirtyOr(DirtyEquals[Any]): def __init__(self, a: 'InstanceOrType', b: 'InstanceOrType', *extra: 'InstanceOrType'): self.dirties = (a, b) + extra super().__init__() def equals(self, other: Any) -> bool: return any(d == other for d in self.dirties) def _repr_ne(self) -> str: return ' | '.join(_repr_ne(d) for d in self.dirties) class DirtyAnd(DirtyEquals[Any]): def __init__(self, a: InstanceOrType, b: InstanceOrType, *extra: InstanceOrType): self.dirties = (a, b) + extra super().__init__() def equals(self, other: Any) -> bool: return all(d == other for d in self.dirties) def _repr_ne(self) -> str: return ' & '.join(_repr_ne(d) for d in self.dirties) class DirtyNot(DirtyEquals[Any]): def __init__(self, subject: InstanceOrType): self.subject = subject super().__init__() def equals(self, other: Any) -> bool: return self.subject != other def _repr_ne(self) -> str: return f'~{_repr_ne(self.subject)}' def _repr_ne(v: InstanceOrType) -> str: if isinstance(v, DirtyEqualsMeta): return repr(v) else: return v._repr_ne() class AnyThing(DirtyEquals[Any]): """ A type which matches any value. `AnyThing` isn't generally very useful on its own, but can be used within other comparisons. ```py title="AnyThing" from dirty_equals import AnyThing, IsList, IsStrictDict assert 1 == AnyThing assert 'foobar' == AnyThing assert [1, 2, 3] == AnyThing assert [1, 2, 3] == IsList(AnyThing, 2, 3) assert {'a': 1, 'b': 2, 'c': 3} == IsStrictDict(a=1, b=AnyThing, c=3) ``` """ def equals(self, other: Any) -> bool: return True class IsOneOf(DirtyEquals[Any]): """ A type which checks that the value is equal to one of the given values. Can be useful with boolean operators. """ def __init__(self, expected_value: Any, *more_expected_values: Any): """ Args: expected_value: Expected value for equals to return true. *more_expected_values: More expected values for equals to return true. ```py title="IsOneOf" from dirty_equals import Contains, IsOneOf assert 1 == IsOneOf(1, 2, 3) assert 4 != IsOneOf(1, 2, 3) # check that a list either contain 1 or is empty assert [1, 2, 3] == Contains(1) | IsOneOf([]) assert [] == Contains(1) | IsOneOf([]) ``` """ self.expected_values: Tuple[Any, ...] = (expected_value,) + more_expected_values super().__init__(*self.expected_values) def equals(self, other: Any) -> bool: return any(other == e for e in self.expected_values) dirty-equals-0.8.0/dirty_equals/_boolean.py 0000664 0000000 0000000 00000004560 14656734655 0021031 0 ustar 00root root 0000000 0000000 from typing import Any from ._base import DirtyEquals from ._utils import Omit class IsTrueLike(DirtyEquals[bool]): """ Check if the value is True like. `IsTrueLike` allows comparison to anything and effectively uses just `return bool(other)`. Example of basic usage: ```py title="IsTrueLike" from dirty_equals import IsTrueLike assert True == IsTrueLike assert 1 == IsTrueLike assert 'true' == IsTrueLike assert 'foobar' == IsTrueLike # any non-empty string is "True" assert '' != IsTrueLike assert [1] == IsTrueLike assert {} != IsTrueLike assert None != IsTrueLike ``` """ def equals(self, other: Any) -> bool: return bool(other) class IsFalseLike(DirtyEquals[bool]): """ Check if the value is False like. `IsFalseLike` allows comparison to anything and effectively uses `return not bool(other)` (with string checks if `allow_strings=True` is set). """ def __init__(self, *, allow_strings: bool = False): """ Args: allow_strings: if `True`, allow comparisons to `False` like strings, case-insensitive, allows `''`, `'false'` and any string where `float(other) == 0` (e.g. `'0'`). Example of basic usage: ```py title="IsFalseLike" from dirty_equals import IsFalseLike assert False == IsFalseLike assert 0 == IsFalseLike assert 'false' == IsFalseLike(allow_strings=True) assert '0' == IsFalseLike(allow_strings=True) assert 'foobar' != IsFalseLike(allow_strings=True) assert 'false' != IsFalseLike assert 'True' != IsFalseLike(allow_strings=True) assert [1] != IsFalseLike assert {} == IsFalseLike assert None == IsFalseLike assert '' == IsFalseLike(allow_strings=True) assert '' == IsFalseLike ``` """ self.allow_strings = allow_strings super().__init__(allow_strings=allow_strings or Omit) def equals(self, other: Any) -> bool: if isinstance(other, str) and self.allow_strings: return self.make_string_check(other) return not bool(other) @staticmethod def make_string_check(other: str) -> bool: if other.lower() in {'false', ''}: return True try: return float(other) == 0 except ValueError: return False dirty-equals-0.8.0/dirty_equals/_datetime.py 0000664 0000000 0000000 00000027120 14656734655 0021203 0 ustar 00root root 0000000 0000000 from __future__ import annotations as _annotations from datetime import date, datetime, timedelta, timezone, tzinfo from typing import TYPE_CHECKING, Any from ._numeric import IsNumeric from ._utils import Omit if TYPE_CHECKING: from zoneinfo import ZoneInfo class IsDatetime(IsNumeric[datetime]): """ Check if the value is a datetime, and matches the given conditions. """ allowed_types = datetime def __init__( self, *, approx: datetime | None = None, delta: timedelta | int | float | None = None, gt: datetime | None = None, lt: datetime | None = None, ge: datetime | None = None, le: datetime | None = None, unix_number: bool = False, iso_string: bool = False, format_string: str | None = None, enforce_tz: bool = True, ): """ Args: approx: A value to approximately compare to. delta: The allowable different when comparing to the value to `approx`, if omitted 2 seconds is used, ints and floats are assumed to represent seconds and converted to `timedelta`s. gt: Value which the compared value should be greater than (after). lt: Value which the compared value should be less than (before). ge: Value which the compared value should be greater than (after) or equal to. le: Value which the compared value should be less than (before) or equal to. unix_number: whether to allow unix timestamp numbers in comparison iso_string: whether to allow iso formatted strings in comparison format_string: if provided, `format_string` is used with `datetime.strptime` to parse strings enforce_tz: whether timezone should be enforced in comparison, see below for more details Examples of basic usage: ```py title="IsDatetime" from datetime import datetime from dirty_equals import IsDatetime y2k = datetime(2000, 1, 1) assert datetime(2000, 1, 1) == IsDatetime(approx=y2k) # Note: this requires the system timezone to be UTC assert 946684800.123 == IsDatetime(approx=y2k, unix_number=True) assert datetime(2000, 1, 1, 0, 0, 9) == IsDatetime(approx=y2k, delta=10) assert '2000-01-01T00:00' == IsDatetime(approx=y2k, iso_string=True) assert datetime(2000, 1, 2) == IsDatetime(gt=y2k) assert datetime(1999, 1, 2) != IsDatetime(gt=y2k) ``` """ if isinstance(delta, (int, float)): delta = timedelta(seconds=delta) super().__init__( approx=approx, delta=delta, # type: ignore[arg-type] gt=gt, lt=lt, ge=ge, le=le, ) self.unix_number = unix_number self.iso_string = iso_string self.format_string = format_string self.enforce_tz = enforce_tz self._repr_kwargs.update( unix_number=Omit if unix_number is False else unix_number, iso_string=Omit if iso_string is False else iso_string, format_string=Omit if format_string is None else format_string, enforce_tz=Omit if enforce_tz is True else format_string, ) def prepare(self, other: Any) -> datetime: if isinstance(other, datetime): dt = other elif isinstance(other, (float, int)): if self.unix_number: dt = datetime.fromtimestamp(other) else: raise TypeError('numbers not allowed') elif isinstance(other, str): if self.iso_string: dt = datetime.fromisoformat(other) elif self.format_string: dt = datetime.strptime(other, self.format_string) else: raise ValueError('not a valid datetime string') else: raise ValueError(f'{type(other)} not valid as datetime') if self.approx is not None and not self.enforce_tz and self.approx.tzinfo is None and dt.tzinfo is not None: dt = dt.replace(tzinfo=None) return dt def approx_equals(self, other: datetime, delta: timedelta) -> bool: if not super().approx_equals(other, delta): return False if self.enforce_tz: if self.approx.tzinfo is None: # type: ignore[union-attr] return other.tzinfo is None else: approx_offset = self.approx.tzinfo.utcoffset(self.approx) # type: ignore[union-attr] other_offset = other.tzinfo.utcoffset(other) # type: ignore[union-attr] return approx_offset == other_offset else: return True def _zoneinfo(tz: str) -> ZoneInfo: """ Instantiate a `ZoneInfo` object from a string, falling back to `pytz.timezone` when `ZoneInfo` is not available (most likely on Python 3.8 and webassembly). """ try: from zoneinfo import ZoneInfo except ImportError: try: import pytz except ImportError as e: raise ImportError('`pytz` or `zoneinfo` required for tz handling') from e else: return pytz.timezone(tz) # type: ignore[return-value] else: return ZoneInfo(tz) class IsNow(IsDatetime): """ Check if a datetime is close to now, this is similar to `IsDatetime(approx=datetime.now())`, but slightly more powerful. """ def __init__( self, *, delta: timedelta | int | float = 2, unix_number: bool = False, iso_string: bool = False, format_string: str | None = None, enforce_tz: bool = True, tz: str | tzinfo | None = None, ): """ Args: delta: The allowable different when comparing to the value to now, if omitted 2 seconds is used, ints and floats are assumed to represent seconds and converted to `timedelta`s. unix_number: whether to allow unix timestamp numbers in comparison iso_string: whether to allow iso formatted strings in comparison format_string: if provided, `format_string` is used with `datetime.strptime` to parse strings enforce_tz: whether timezone should be enforced in comparison, see below for more details tz: either a `ZoneInfo`, a `datetime.timezone` or a string which will be passed to `ZoneInfo`, (or `pytz.timezone` on 3.8) to get a timezone, if provided now will be converted to this timezone. ```py title="IsNow" from datetime import datetime, timezone from dirty_equals import IsNow now = datetime.now() assert now == IsNow assert now.timestamp() == IsNow(unix_number=True) assert now.timestamp() != IsNow assert now.isoformat() == IsNow(iso_string=True) assert now.isoformat() != IsNow utc_now = datetime.utcnow().replace(tzinfo=timezone.utc) assert utc_now == IsNow(tz=timezone.utc) ``` """ if isinstance(tz, str): tz = _zoneinfo(tz) self.tz = tz approx = self._get_now() super().__init__( approx=approx, delta=delta, unix_number=unix_number, iso_string=iso_string, format_string=format_string, enforce_tz=enforce_tz, ) if tz is not None: self._repr_kwargs['tz'] = tz def _get_now(self) -> datetime: if self.tz is None: return datetime.now() else: utc_now = datetime.now(tz=timezone.utc).replace(tzinfo=timezone.utc) return utc_now.astimezone(self.tz) def prepare(self, other: Any) -> datetime: # update approx for every comparing, to check if other value is dirty equal # to current moment of time self.approx = self._get_now() return super().prepare(other) class IsDate(IsNumeric[date]): """ Check if the value is a date, and matches the given conditions. """ allowed_types = date def __init__( self, *, approx: date | None = None, delta: timedelta | int | float | None = None, gt: date | None = None, lt: date | None = None, ge: date | None = None, le: date | None = None, iso_string: bool = False, format_string: str | None = None, ): """ Args: approx: A value to approximately compare to. delta: The allowable different when comparing to the value to now, if omitted 2 seconds is used, ints and floats are assumed to represent seconds and converted to `timedelta`s. gt: Value which the compared value should be greater than (after). lt: Value which the compared value should be less than (before). ge: Value which the compared value should be greater than (after) or equal to. le: Value which the compared value should be less than (before) or equal to. iso_string: whether to allow iso formatted strings in comparison format_string: if provided, `format_string` is used with `datetime.strptime` to parse strings Examples of basic usage: ```py title="IsDate" from datetime import date from dirty_equals import IsDate y2k = date(2000, 1, 1) assert date(2000, 1, 1) == IsDate(approx=y2k) assert '2000-01-01' == IsDate(approx=y2k, iso_string=True) assert date(2000, 1, 2) == IsDate(gt=y2k) assert date(1999, 1, 2) != IsDate(gt=y2k) ``` """ if delta is None: delta = timedelta() elif isinstance(delta, (int, float)): delta = timedelta(seconds=delta) super().__init__(approx=approx, gt=gt, lt=lt, ge=ge, le=le, delta=delta) # type: ignore[arg-type] self.iso_string = iso_string self.format_string = format_string self._repr_kwargs.update( iso_string=Omit if iso_string is False else iso_string, format_string=Omit if format_string is None else format_string, ) def prepare(self, other: Any) -> date: if type(other) is date: dt = other elif isinstance(other, str): if self.iso_string: dt = date.fromisoformat(other) elif self.format_string: dt = datetime.strptime(other, self.format_string).date() else: raise ValueError('not a valid date string') else: raise ValueError(f'{type(other)} not valid as date') return dt class IsToday(IsDate): """ Check if a date is today, this is similar to `IsDate(approx=date.today())`, but slightly more powerful. """ def __init__( self, *, iso_string: bool = False, format_string: str | None = None, ): """ Args: iso_string: whether to allow iso formatted strings in comparison format_string: if provided, `format_string` is used with `datetime.strptime` to parse strings ```py title="IsToday" from datetime import date, timedelta from dirty_equals import IsToday today = date.today() assert today == IsToday assert today.isoformat() == IsToday(iso_string=True) assert today.isoformat() != IsToday assert today + timedelta(days=1) != IsToday assert today.strftime('%Y/%m/%d') == IsToday(format_string='%Y/%m/%d') assert today.strftime('%Y/%m/%d') != IsToday() ``` """ super().__init__(approx=date.today(), iso_string=iso_string, format_string=format_string) dirty-equals-0.8.0/dirty_equals/_dict.py 0000664 0000000 0000000 00000020110 14656734655 0020322 0 ustar 00root root 0000000 0000000 from __future__ import annotations from typing import Any, Callable, Container, Dict, overload from ._base import DirtyEquals, DirtyEqualsMeta from ._utils import get_dict_arg NotGiven = object() class IsDict(DirtyEquals[Dict[Any, Any]]): """ Base class for comparing dictionaries. By default, `IsDict` isn't particularly useful on its own (it behaves pretty much like a normal `dict`), but it can be subclassed (see [`IsPartialDict`][dirty_equals.IsPartialDict] and [`IsStrictDict`][dirty_equals.IsStrictDict]) or modified with `.settings(...)` to powerful things. """ @overload def __init__(self, expected: dict[Any, Any]): ... @overload def __init__(self, **expected: Any): ... def __init__(self, *expected_args: dict[Any, Any], **expected_kwargs: Any): """ Can be created from either keyword arguments or an existing dictionary (same as `dict()`). `IsDict` is not particularly useful on its own, but it can be subclassed or modified with [`.settings(...)`][dirty_equals.IsDict.settings] to facilitate powerful comparison of dictionaries. ```py title="IsDict" from dirty_equals import IsDict assert {'a': 1, 'b': 2} == IsDict(a=1, b=2) assert {1: 2, 3: 4} == IsDict({1: 2, 3: 4}) ``` """ self.expected_values = get_dict_arg('IsDict', expected_args, expected_kwargs) self.strict = False self.partial = False self.ignore: None | Container[Any] | Callable[[Any], bool] = None self._post_init() super().__init__() def _post_init(self) -> None: pass def settings( self, *, strict: bool | None = None, partial: bool | None = None, ignore: None | Container[Any] | Callable[[Any], bool] = NotGiven, # type: ignore[assignment] ) -> IsDict: """ Allows you to customise the behaviour of `IsDict`, technically a new `IsDict` is required to allow chaining. Args: strict (bool): If `True`, the order of key/value pairs must match. partial (bool): If `True`, only keys include in the wrapped dict are checked. ignore (Union[None, Container[Any], Callable[[Any], bool]]): Values to omit from comparison. Can be either a `Container` (e.g. `set` or `list`) of values to ignore, or a function that takes a value and should return `True` if the value should be ignored. ```py title="IsDict.settings(...)" from dirty_equals import IsDict assert {'a': 1, 'b': 2, 'c': None} != IsDict(a=1, b=2) assert {'a': 1, 'b': 2, 'c': None} == IsDict(a=1, b=2).settings(partial=True) # (1)! assert {'b': 2, 'a': 1} == IsDict(a=1, b=2) assert {'b': 2, 'a': 1} != IsDict(a=1, b=2).settings(strict=True) # (2)! # combining partial and strict assert {'a': 1, 'b': None, 'c': 3} == IsDict(a=1, c=3).settings( strict=True, partial=True ) assert {'b': None, 'c': 3, 'a': 1} != IsDict(a=1, c=3).settings( strict=True, partial=True ) ``` 1. This is the same as [`IsPartialDict(a=1, b=2)`][dirty_equals.IsPartialDict] 2. This is the same as [`IsStrictDict(a=1, b=2)`][dirty_equals.IsStrictDict] """ new_cls = self.__class__(self.expected_values) new_cls.__dict__ = self.__dict__.copy() if strict is not None: new_cls.strict = strict if partial is not None: new_cls.partial = partial if ignore is not NotGiven: new_cls.ignore = ignore if new_cls.partial and new_cls.ignore: raise TypeError('partial and ignore cannot be used together') return new_cls def equals(self, other: dict[Any, Any]) -> bool: if not isinstance(other, dict): return False expected = self.expected_values if self.partial: other = {k: v for k, v in other.items() if k in expected} if self.ignore: expected = self._filter_dict(self.expected_values) other = self._filter_dict(other) if other != expected: return False if self.strict and list(other.keys()) != list(expected.keys()): return False return True def _filter_dict(self, d: dict[Any, Any]) -> dict[Any, Any]: return {k: v for k, v in d.items() if not self._ignore_value(v)} def _ignore_value(self, v: Any) -> bool: # `isinstance(v, (DirtyEquals, DirtyEqualsMeta))` seems to always return `True` on pypy, no idea why if type(v) in (DirtyEquals, DirtyEqualsMeta): return False elif callable(self.ignore): return self.ignore(v) else: try: return v in self.ignore # type: ignore[operator] except TypeError: # happens for unhashable types return False def _repr_ne(self) -> str: name = self.__class__.__name__ modifiers = [] if self.partial != (name == 'IsPartialDict'): modifiers += [f'partial={self.partial}'] if (self.ignore == {None}) != (name == 'IsIgnoreDict') or self.ignore not in (None, {None}): r = self.ignore.__name__ if callable(self.ignore) else repr(self.ignore) modifiers += [f'ignore={r}'] if self.strict != (name == 'IsStrictDict'): modifiers += [f'strict={self.strict}'] if modifiers: mod = f'[{", ".join(modifiers)}]' else: mod = '' args = [f'{k}={v!r}' for k, v in self.expected_values.items()] return f'{name}{mod}({", ".join(args)})' class IsPartialDict(IsDict): """ Partial dictionary comparison, this is the same as [`IsDict(...).settings(partial=True)`][dirty_equals.IsDict.settings]. ```py title="IsPartialDict" from dirty_equals import IsPartialDict assert {'a': 1, 'b': 2, 'c': 3} == IsPartialDict(a=1, b=2) assert {'a': 1, 'b': 2, 'c': 3} != IsPartialDict(a=1, b=3) assert {'a': 1, 'b': 2, 'd': 3} != IsPartialDict(a=1, b=2, c=3) # combining partial and strict assert {'a': 1, 'b': None, 'c': 3} == IsPartialDict(a=1, c=3).settings(strict=True) assert {'b': None, 'c': 3, 'a': 1} != IsPartialDict(a=1, c=3).settings(strict=True) ``` """ def _post_init(self) -> None: self.partial = True class IsIgnoreDict(IsDict): """ Dictionary comparison with `None` values ignored, this is the same as [`IsDict(...).settings(ignore={None})`][dirty_equals.IsDict.settings]. `.settings(...)` can be used to customise the behaviour of `IsIgnoreDict`, in particular changing which values are ignored. ```py title="IsIgnoreDict" from dirty_equals import IsIgnoreDict assert {'a': 1, 'b': 2, 'c': None} == IsIgnoreDict(a=1, b=2) assert {'a': 1, 'b': 2, 'c': 'ignore'} == ( IsIgnoreDict(a=1, b=2).settings(ignore={None, 'ignore'}) ) def is_even(v: int) -> bool: return v % 2 == 0 assert {'a': 1, 'b': 2, 'c': 3, 'd': 4} == ( IsIgnoreDict(a=1, c=3).settings(ignore=is_even) ) # combining partial and strict assert {'a': 1, 'b': None, 'c': 3} == IsIgnoreDict(a=1, c=3).settings(strict=True) assert {'b': None, 'c': 3, 'a': 1} != IsIgnoreDict(a=1, c=3).settings(strict=True) ``` """ def _post_init(self) -> None: self.ignore = {None} class IsStrictDict(IsDict): """ Dictionary comparison with order enforced, this is the same as [`IsDict(...).settings(strict=True)`][dirty_equals.IsDict.settings]. ```py title="IsDict.settings(...)" from dirty_equals import IsStrictDict assert {'a': 1, 'b': 2} == IsStrictDict(a=1, b=2) assert {'a': 1, 'b': 2, 'c': 3} != IsStrictDict(a=1, b=2) assert {'b': 2, 'a': 1} != IsStrictDict(a=1, b=2) # combining partial and strict assert {'a': 1, 'b': None, 'c': 3} == IsStrictDict(a=1, c=3).settings(partial=True) assert {'b': None, 'c': 3, 'a': 1} != IsStrictDict(a=1, c=3).settings(partial=True) ``` """ def _post_init(self) -> None: self.strict = True dirty-equals-0.8.0/dirty_equals/_inspection.py 0000664 0000000 0000000 00000014577 14656734655 0021576 0 ustar 00root root 0000000 0000000 from typing import Any, Dict, Tuple, TypeVar, Union, overload from ._base import DirtyEquals from ._strings import IsStr from ._utils import get_dict_arg ExpectedType = TypeVar('ExpectedType', bound=Union[type, Tuple[Union[type, Tuple[Any, ...]], ...]]) class IsInstance(DirtyEquals[ExpectedType]): """ A type which checks that the value is an instance of the expected type. """ def __init__(self, expected_type: ExpectedType, *, only_direct_instance: bool = False): """ Args: expected_type: The type to check against. only_direct_instance: whether instances of subclasses of `expected_type` should be considered equal. !!! note `IsInstance` can be parameterized or initialised with a type - `IsInstance[Foo]` is exactly equivalent to `IsInstance(Foo)`. This allows usage to be analogous to type hints. Example: ```py title="IsInstance" from dirty_equals import IsInstance class Foo: pass class Bar(Foo): pass assert Foo() == IsInstance[Foo] assert Foo() == IsInstance(Foo) assert Foo != IsInstance[Bar] assert Bar() == IsInstance[Foo] assert Foo() == IsInstance(Foo, only_direct_instance=True) assert Bar() != IsInstance(Foo, only_direct_instance=True) ``` """ self.expected_type = expected_type self.only_direct_instance = only_direct_instance super().__init__(expected_type) def __class_getitem__(cls, expected_type: ExpectedType) -> 'IsInstance[ExpectedType]': return cls(expected_type) def equals(self, other: Any) -> bool: if self.only_direct_instance: return type(other) == self.expected_type else: return isinstance(other, self.expected_type) T = TypeVar('T') class HasName(DirtyEquals[T]): """ A type which checks that the value has the given `__name__` attribute. """ def __init__(self, expected_name: Union[IsStr, str], *, allow_instances: bool = True): """ Args: expected_name: The name to check against. allow_instances: whether instances of classes with the given name should be considered equal, (e.g. whether `other.__class__.__name__ == expected_name` should be checked). Example: ```py title="HasName" from dirty_equals import HasName, IsStr class Foo: pass assert Foo == HasName('Foo') assert Foo == HasName['Foo'] assert Foo() == HasName('Foo') assert Foo() != HasName('Foo', allow_instances=False) assert Foo == HasName(IsStr(regex='F..')) assert Foo != HasName('Bar') assert int == HasName('int') assert int == HasName('int') ``` """ self.expected_name = expected_name self.allow_instances = allow_instances kwargs = {} if allow_instances: kwargs['allow_instances'] = allow_instances super().__init__(expected_name, allow_instances=allow_instances) def __class_getitem__(cls, expected_name: str) -> 'HasName[T]': return cls(expected_name) def equals(self, other: Any) -> bool: direct_name = getattr(other, '__name__', None) if direct_name is not None and direct_name == self.expected_name: return True if self.allow_instances: cls = getattr(other, '__class__', None) if cls is not None: # pragma: no branch cls_name = getattr(cls, '__name__', None) if cls_name is not None and cls_name == self.expected_name: return True return False class HasRepr(DirtyEquals[T]): """ A type which checks that the value has the given `repr()` value. """ def __init__(self, expected_repr: Union[IsStr, str]): """ Args: expected_repr: The expected repr value. Example: ```py title="HasRepr" from dirty_equals import HasRepr, IsStr class Foo: def __repr__(self): return 'This is a Foo' assert Foo() == HasRepr('This is a Foo') assert Foo() == HasRepr['This is a Foo'] assert Foo == HasRepr(IsStr(regex='
Doing dirty (but extremely useful) things with equals.
--- {{ version }} **dirty-equals** is a python library that (mis)uses the `__eq__` method to make python code (generally unit tests) more declarative and therefore easier to read and write. *dirty-equals* can be used in whatever context you like, but it comes into its own when writing unit tests for applications where you're commonly checking the response to API calls and the contents of a database. ## Usage Here's a trivial example of what *dirty-equals* can do: ```{.py title="Trivial Usage" test="skip"} from dirty_equals import IsPositive assert 1 == IsPositive # (1)! assert -2 == IsPositive # this will fail! (2) ``` 1. This `assert` will pass since `1` is indeed positive, so the result of `1 == IsPositive` is `True`. 2. This will fail (raise a `AssertionError`) since `-2` is not positive, so the result of `-2 == IsPositive` is `False`. **Not that interesting yet!**, but consider the following unit test code using **dirty-equals**: ```py title="More Powerful Usage" lint="skip" from dirty_equals import IsJson, IsNow, IsPositiveInt, IsStr def test_user_endpoint(client: 'HttpClient', db_conn: 'Database'): client.post('/users/create/', data=...) user_data = db_conn.fetchrow('select * from users') assert user_data == { 'id': IsPositiveInt, # (1)! 'username': 'samuelcolvin', # (2)! 'avatar_file': IsStr(regex=r'/[a-z0-9\-]{10}/example\.png'), # (3)! 'settings_json': IsJson({'theme': 'dark', 'language': 'en'}), # (4)! 'created_ts': IsNow(delta=3), # (5)! } ``` 1. We don't actually care what the `id` is, just that it's present, it's an `int` and it's positive. 2. We can use a normal key and value here since we know exactly what value `username` should have before we test it. 3. `avatar_file` is a string, but we don't know all of the string before the `assert`, just the format (regex) it should match. 4. `settings_json` is a `JSON` string, but it's simpler and more robust to confirm it represents a particular python object rather than compare strings. 5. `created_at` is a `datetime`, although we don't know (or care) about its exact value; since the user was just created we know it must be close to now. `delta` is optional, it defaults to 2 seconds. Without **dirty-equals**, you'd have to compare individual fields and/or modify some fields before comparison - the test would not be declarative or as clear. **dirty-equals** can do so much more than that, for example: * [`IsPartialDict`][dirty_equals.IsPartialDict] lets you compare a subset of a dictionary * [`IsStrictDict`][dirty_equals.IsStrictDict] lets you confirm order in a dictionary * [`IsList`][dirty_equals.IsList] and [`IsTuple`][dirty_equals.IsTuple] lets you compare partial lists and tuples, with or without order constraints * nesting any of these types inside any others * [`IsInstance`][dirty_equals.IsInstance] lets you simply confirm the type of an object * You can even use [boolean operators](./usage.md#boolean-logic) `|` and `&` to combine multiple conditions * and much more... ## Installation Simply: ```bash pip install dirty-equals ``` **dirty-equals** requires **Python 3.8+**. dirty-equals-0.8.0/docs/internals.md 0000664 0000000 0000000 00000002171 14656734655 0017441 0 ustar 00root root 0000000 0000000 # Internals ## How the magic of `DirtyEquals.__eq__` works? When you call `x == y`, Python first calls `x.__eq__(y)`. This would not help us much, because we would have to keep an eye on order of the arguments when comparing to `DirtyEquals` objects. But that's where were another feature of Python comes in. When `x.__eq__(y)` returns the `NotImplemented` object, then Python will try to call `y.__eq__(x)`. Objects in the standard library return that value when they don't know how to compare themselves to objects of `type(y)` (Without checking the C source I can't be certain if this assumption holds for all classes, but it works for all the basic ones). In [`pathlib.PurePath`](https://github.com/python/cpython/blob/aebbd7579a421208f48dd6884b67dbd3278b71ad/Lib/pathlib.py#L751) you can see an example how that is implemented in Python. > By default, object implements `__eq__()` by using `is`, > returning `NotImplemented` in the case of a false comparison: > `True if x is y else NotImplemented`. See the Python documentation for more information ([`object.__eq__`](https://docs.python.org/3/reference/datamodel.html#object.__eq__)). dirty-equals-0.8.0/docs/plugins.py 0000664 0000000 0000000 00000004736 14656734655 0017164 0 ustar 00root root 0000000 0000000 import logging import os import re from mkdocs.config import Config from mkdocs.structure.files import Files from mkdocs.structure.pages import Page try: import pytest except ImportError: pytest = None logger = logging.getLogger('mkdocs.test_examples') def on_pre_build(config: Config): pass def on_files(files: Files, config: Config) -> Files: return remove_files(files) def remove_files(files: Files) -> Files: to_remove = [] for file in files: if file.src_path in {'plugins.py'}: to_remove.append(file) elif file.src_path.startswith('__pycache__/'): to_remove.append(file) logger.debug('removing files: %s', [f.src_path for f in to_remove]) for f in to_remove: files.remove(f) return files def on_page_markdown(markdown: str, page: Page, config: Config, files: Files) -> str: markdown = remove_code_fence_attributes(markdown) return add_version(markdown, page) def add_version(markdown: str, page: Page) -> str: if page.file.src_uri == 'index.md': version_ref = os.getenv('GITHUB_REF') if version_ref and version_ref.startswith('refs/tags/'): version = re.sub('^refs/tags/', '', version_ref.lower()) url = f'https://github.com/samuelcolvin/dirty-equals/releases/tag/{version}' version_str = f'Documentation for version: [{version}]({url})' elif sha := os.getenv('GITHUB_SHA'): sha = sha[:7] url = f'https://github.com/samuelcolvin/dirty-equals/commit/{sha}' version_str = f'Documentation for development version: [{sha}]({url})' else: version_str = 'Documentation for development version' markdown = re.sub(r'{{ *version *}}', version_str, markdown) return markdown def remove_code_fence_attributes(markdown: str) -> str: """ There's no way to add attributes to code fences that works with both pycharm and mkdocs, hence we use `py key="value"` to provide attributes to pytest-examples, then remove those attributes here. https://youtrack.jetbrains.com/issue/IDEA-297873 & https://python-markdown.github.io/extensions/fenced_code_blocks/ """ def remove_attrs(match: re.Match[str]) -> str: suffix = re.sub( r' (?:test|lint|upgrade|group|requires|output|rewrite_assert)=".+?"', '', match.group(2), flags=re.M ) return f'{match.group(1)}{suffix}' return re.sub(r'^( *``` *py)(.*)', remove_attrs, markdown, flags=re.M) dirty-equals-0.8.0/docs/types/ 0000775 0000000 0000000 00000000000 14656734655 0016263 5 ustar 00root root 0000000 0000000 dirty-equals-0.8.0/docs/types/boolean.md 0000664 0000000 0000000 00000000113 14656734655 0020217 0 ustar 00root root 0000000 0000000 # Boolean Types ::: dirty_equals.IsTrueLike ::: dirty_equals.IsFalseLike dirty-equals-0.8.0/docs/types/custom.md 0000664 0000000 0000000 00000002413 14656734655 0020117 0 ustar 00root root 0000000 0000000 # Custom Types ::: dirty_equals._base.DirtyEquals options: merge_init_into_class: false ## Custom Type Example To demonstrate the use of custom types, we'll create a custom type that matches any even number. We won't inherit from [`IsNumeric`][dirty_equals.IsNumeric] in this case to keep the example simple. ```py title="IsEven" from decimal import Decimal from typing import Any, Union from dirty_equals import DirtyEquals, IsOneOf class IsEven(DirtyEquals[Union[int, float, Decimal]]): def equals(self, other: Any) -> bool: return other % 2 == 0 assert 2 == IsEven assert 3 != IsEven assert 'foobar' != IsEven assert 3 == IsEven | IsOneOf(3) ``` There are a few advantages of inheriting from [`DirtyEquals`][dirty_equals.DirtyEquals] compared to just implementing your own class with an `__eq__` method: 1. `TypeError` and `ValueError` in `equals` are caught and result in a not-equals result. 2. A useful `__repr__` is generated, and modified if the `==` operation returns `True`, see [pytest compatibility](../usage.md#__repr__-and-pytest-compatibility) 3. [boolean logic](../usage.md#boolean-logic) works out of the box 4. [Uninitialised usage](../usage.md#initialised-vs-class-comparison) (`IsEven` rather than `IsEven()`) works out of the box dirty-equals-0.8.0/docs/types/datetime.md 0000664 0000000 0000000 00000003535 14656734655 0020407 0 ustar 00root root 0000000 0000000 # Date and Time Types ::: dirty_equals.IsDatetime ### Timezones Timezones are hard, anyone who claims otherwise is either a genius, a liar, or an idiot. `IsDatetime` and its subtypes (e.g. [`IsNow`][dirty_equals.IsNow]) can be used in two modes, based on the `enforce_tz` parameter: * `enforce_tz=True` (the default): * if the datetime wrapped by `IsDatetime` is timezone naive, the compared value must also be timezone naive. * if the datetime wrapped by `IsDatetime` has a timezone, the compared value must have a timezone with the same offset. * `enforce_tz=False`: * if the datetime wrapped by `IsDatetime` is timezone naive, the compared value can either be naive or have a timezone all that matters is the datetime values match. * if the datetime wrapped by `IsDatetime` has a timezone, the compared value needs to represent the same point in time - either way it must have a timezone. Example ```py title="IsDatetime & timezones" requires="3.9" from datetime import datetime from zoneinfo import ZoneInfo from dirty_equals import IsDatetime tz_london = ZoneInfo('Europe/London') new_year_london = datetime(2000, 1, 1, tzinfo=tz_london) tz_nyc = ZoneInfo('America/New_York') new_year_eve_nyc = datetime(1999, 12, 31, 19, 0, 0, tzinfo=tz_nyc) assert new_year_eve_nyc == IsDatetime(approx=new_year_london, enforce_tz=False) assert new_year_eve_nyc != IsDatetime(approx=new_year_london, enforce_tz=True) new_year_naive = datetime(2000, 1, 1) assert new_year_naive != IsDatetime(approx=new_year_london, enforce_tz=False) assert new_year_naive != IsDatetime(approx=new_year_eve_nyc, enforce_tz=False) assert new_year_london == IsDatetime(approx=new_year_naive, enforce_tz=False) assert new_year_eve_nyc != IsDatetime(approx=new_year_naive, enforce_tz=False) ``` ::: dirty_equals.IsNow ::: dirty_equals.IsDate ::: dirty_equals.IsToday dirty-equals-0.8.0/docs/types/dict.md 0000664 0000000 0000000 00000000212 14656734655 0017523 0 ustar 00root root 0000000 0000000 # Dictionary Types ::: dirty_equals.IsDict ::: dirty_equals.IsPartialDict ::: dirty_equals.IsIgnoreDict ::: dirty_equals.IsStrictDict dirty-equals-0.8.0/docs/types/inspection.md 0000664 0000000 0000000 00000000203 14656734655 0020753 0 ustar 00root root 0000000 0000000 # Type Inspection ::: dirty_equals.IsInstance ::: dirty_equals.HasName ::: dirty_equals.HasRepr ::: dirty_equals.HasAttributes dirty-equals-0.8.0/docs/types/numeric.md 0000664 0000000 0000000 00000002434 14656734655 0020252 0 ustar 00root root 0000000 0000000 # Numeric Types ::: dirty_equals.IsInt options: merge_init_into_class: false separate_signature: false ::: dirty_equals.IsFloat options: merge_init_into_class: false separate_signature: false ::: dirty_equals.IsPositive options: merge_init_into_class: false ::: dirty_equals.IsNegative options: merge_init_into_class: false ::: dirty_equals.IsNonNegative options: merge_init_into_class: false ::: dirty_equals.IsNonPositive options: merge_init_into_class: false ::: dirty_equals.IsPositiveInt options: merge_init_into_class: false ::: dirty_equals.IsNegativeInt options: merge_init_into_class: false ::: dirty_equals.IsPositiveFloat options: merge_init_into_class: false ::: dirty_equals.IsNegativeFloat options: merge_init_into_class: false ::: dirty_equals.IsFloatInf options: merge_init_into_class: false ::: dirty_equals.IsFloatInfPos options: merge_init_into_class: false ::: dirty_equals.IsFloatInfNeg options: merge_init_into_class: false ::: dirty_equals.IsFloatNan options: merge_init_into_class: false ::: dirty_equals.IsApprox ::: dirty_equals.IsNumber options: merge_init_into_class: false ::: dirty_equals.IsNumeric dirty-equals-0.8.0/docs/types/other.md 0000664 0000000 0000000 00000000634 14656734655 0017731 0 ustar 00root root 0000000 0000000 # Other Types ::: dirty_equals.FunctionCheck ::: dirty_equals.IsInstance ::: dirty_equals.IsJson ::: dirty_equals.IsUUID ::: dirty_equals.AnyThing ::: dirty_equals.IsOneOf ::: dirty_equals.IsUrl ::: dirty_equals.IsHash ::: dirty_equals.IsIP ::: dirty_equals.IsDataclassType ::: dirty_equals.IsDataclass ::: dirty_equals.IsPartialDataclass ::: dirty_equals.IsStrictDataclass ::: dirty_equals.IsEnum dirty-equals-0.8.0/docs/types/sequence.md 0000664 0000000 0000000 00000000230 14656734655 0020410 0 ustar 00root root 0000000 0000000 # Sequence Types ::: dirty_equals.IsListOrTuple ::: dirty_equals.IsList ::: dirty_equals.IsTuple ::: dirty_equals.HasLen ::: dirty_equals.Contains dirty-equals-0.8.0/docs/types/string.md 0000664 0000000 0000000 00000000134 14656734655 0020111 0 ustar 00root root 0000000 0000000 # String Types ::: dirty_equals.IsAnyStr ::: dirty_equals.IsStr ::: dirty_equals.IsBytes dirty-equals-0.8.0/docs/usage.md 0000664 0000000 0000000 00000007354 14656734655 0016556 0 ustar 00root root 0000000 0000000 ## Boolean Logic *dirty-equals* types can be combined based on either `&` (and, all checks must be `True` for the combined check to be `True`) or `|` (or, any check can be `True` for the combined check to be `True`). Types can also be inverted using the `~` operator, this is equivalent to using `!=` instead of `==`. Example: ```py title="Boolean Combination of Types" from dirty_equals import Contains, HasLen assert ['a', 'b', 'c'] == HasLen(3) & Contains('a') # (1)! assert ['a', 'b', 'c'] == HasLen(3) | Contains('z') # (2)! assert ['a', 'b', 'c'] != Contains('z') assert ['a', 'b', 'c'] == ~Contains('z') ``` 1. The object on the left has to both have length 3 **and** contain `"a"` 2. The object on the left has to either have length 3 **or** contain `"z"` ## Initialised vs. Class comparison !!! warning This does not work with PyPy. *dirty-equals* allows comparison with types regardless of whether they've been initialised. This saves users adding `()` in lots of places. Example: ```py title="Initialised vs. Uninitialised" from dirty_equals import IsInt # these two cases are the same assert 1 == IsInt assert 1 == IsInt() ``` !!! Note Types that require at least on argument when being initialised (like [`IsApprox`][dirty_equals.IsApprox]) cannot be used like this, comparisons will just return `False`. ## `__repr__` and pytest compatibility dirty-equals types have reasonable `__repr__` methods, which describe types and generally are a close match of how they would be created: ```py title="__repr__" from dirty_equals import IsApprox, IsInt assert repr(IsInt) == 'IsInt' assert repr(IsInt()) == 'IsInt()' assert repr(IsApprox(42)) == 'IsApprox(approx=42)' ``` However, the repr method of types changes when an equals (`==`) operation on them returns a `True`, in this case the `__repr__` method will return `repr(other)`. ```py title="repr() after comparison" from dirty_equals import IsInt v = IsInt() assert 42 == v assert repr(v) == '42' ``` This black magic is designed to make the output of pytest when asserts on large objects fail as simple as possible to read. Consider the following unit test: ```py title="pytest error example" from datetime import datetime from dirty_equals import IsNow, IsPositiveInt def test_partial_dict(): api_response_data = { 'id': 1, # (1)! 'first_name': 'John', 'last_name': 'Doe', 'created_at': datetime.now().isoformat(), 'phone': '+44 123456789', } assert api_response_data == { 'id': IsPositiveInt(), 'first_name': 'John', 'last_name': 'Doe', 'created_at': IsNow(iso_string=True), # phone number is missing, so the test will fail } ``` 1. For simplicity we've hardcoded `id` here, but in a test it could be any positive int, hence why we need `IsPositiveInt()` Here's an except from the output of `pytest -vv` show the error details: ```txt title="pytest output" E Common items: E {'created_at': '2022-02-25T15:41:38.493512', E 'first_name': 'John', E 'id': 1, E 'last_name': 'Doe'} E Left contains 1 more item: E {'phone': '+44 123456789'} E Full diff: E { E 'created_at': '2022-02-25T15:41:38.493512', E 'first_name': 'John', E 'id': 1, E 'last_name': 'Doe', E + 'phone': '+44 123456789', E } ``` It's easy to see that the `phone` key is missing, `id` and `created_at` are represented by the exact values they were compared to, so don't show as different in the "Full diff" section. !!! Warning This black magic only works when using initialised types, if `IsPositiveInt` was used instead `IsPositiveInt()` in the above example, the output would not be as clean. dirty-equals-0.8.0/mkdocs.yml 0000664 0000000 0000000 00000005032 14656734655 0016172 0 ustar 00root root 0000000 0000000 site_name: dirty-equals site_description: Doing dirty (but extremely useful) things with equals. site_url: https://dirty-equals.helpmanual.io theme: name: material palette: - scheme: default primary: blue grey accent: indigo toggle: icon: material/lightbulb name: Switch to dark mode - scheme: slate primary: blue grey accent: indigo toggle: icon: material/lightbulb-outline name: Switch to light mode features: - search.suggest - search.highlight - content.tabs.link - content.code.annotate icon: repo: fontawesome/brands/github-alt logo: img/logo-white.svg favicon: img/favicon.png language: en repo_name: samuelcolvin/dirty-equals repo_url: https://github.com/samuelcolvin/dirty-equals edit_uri: '' nav: - Introduction: index.md - Usage: usage.md - Types: - types/numeric.md - types/datetime.md - types/dict.md - types/sequence.md - types/string.md - types/inspection.md - types/boolean.md - types/other.md - types/custom.md - Internals: internals.md markdown_extensions: - toc: permalink: true - admonition - pymdownx.details - pymdownx.superfences - pymdownx.highlight: anchor_linenums: true - pymdownx.inlinehilite - pymdownx.snippets - attr_list - md_in_html - pymdownx.emoji: emoji_index: !!python/name:material.extensions.emoji.twemoji emoji_generator: !!python/name:material.extensions.emoji.to_svg extra: version: provider: mike analytics: provider: google property: G-FLP20728CW social: - icon: fontawesome/brands/github-alt link: https://github.com/samuelcolvin/dirty-equals - icon: fontawesome/brands/twitter link: https://twitter.com/samuel_colvin watch: - dirty_equals plugins: - mike: alias_type: symlink canonical_version: latest - search - mkdocstrings: handlers: python: options: show_root_heading: true show_root_full_path: false show_source: false heading_level: 2 merge_init_into_class: true show_signature_annotations: true separate_signature: true signature_crossrefs: true import: - url: https://docs.python.org/3/objects.inv - url: https://docs.pydantic.dev/latest/objects.inv - mkdocs-simple-hooks: hooks: on_pre_build: 'docs.plugins:on_pre_build' on_files: 'docs.plugins:on_files' on_page_markdown: 'docs.plugins:on_page_markdown' dirty-equals-0.8.0/pyproject.toml 0000664 0000000 0000000 00000004617 14656734655 0017113 0 ustar 00root root 0000000 0000000 [build-system] requires = ['hatchling'] build-backend = 'hatchling.build' [tool.hatch.version] path = 'dirty_equals/version.py' [project] name = 'dirty-equals' description = 'Doing dirty (but extremely useful) things with equals.' authors = [{name = 'Samuel Colvin', email = 's@muelcolvin.com'}] license = 'MIT' readme = 'README.md' classifiers = [ 'Development Status :: 4 - Beta', 'Framework :: Pytest', 'Intended Audience :: Developers', 'Intended Audience :: Education', 'Intended Audience :: Information Technology', 'Intended Audience :: Science/Research', 'Intended Audience :: System Administrators', 'Operating System :: Unix', 'Operating System :: POSIX :: Linux', 'Environment :: Console', 'Environment :: MacOS X', 'License :: OSI Approved :: MIT License', 'Programming Language :: Python :: 3 :: Only', 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: 3.11', 'Programming Language :: Python :: 3.12', 'Programming Language :: Python :: 3.13', 'Topic :: Software Development :: Libraries :: Python Modules', 'Topic :: Internet', 'Typing :: Typed', ] requires-python = '>=3.8' dependencies = [ 'pytz>=2021.3;python_version<"3.9"', ] optional-dependencies = {pydantic = ['pydantic>=2.4.2'] } dynamic = ['version'] [project.urls] Homepage = 'https://github.com/samuelcolvin/dirty-equals' Documentation = 'https://dirty-equals.helpmanual.io' Funding = 'https://github.com/sponsors/samuelcolvin' Source = 'https://github.com/samuelcolvin/dirty-equals' Changelog = 'https://github.com/samuelcolvin/dirty-equals/releases' [tool.ruff] line-length = 120 lint.extend-select = ['Q', 'RUF100', 'C90', 'UP', 'I'] lint.ignore = ['E721'] lint.flake8-quotes = {inline-quotes = 'single', multiline-quotes = 'double'} lint.mccabe = { max-complexity = 14 } lint.pydocstyle = { convention = 'google' } format.quote-style = 'single' target-version = 'py38' [tool.pytest.ini_options] testpaths = "tests" filterwarnings = "error" [tool.coverage.run] source = ["dirty_equals"] branch = true [tool.coverage.report] precision = 2 exclude_lines = [ "pragma: no cover", "raise NotImplementedError", "raise NotImplemented", "if TYPE_CHECKING:", "@overload", ] [tool.mypy] strict = true warn_return_any = false show_error_codes = true dirty-equals-0.8.0/requirements/ 0000775 0000000 0000000 00000000000 14656734655 0016712 5 ustar 00root root 0000000 0000000 dirty-equals-0.8.0/requirements/all.txt 0000664 0000000 0000000 00000000101 14656734655 0020213 0 ustar 00root root 0000000 0000000 -r ./docs.txt -r ./linting.txt -r ./tests.txt -r ./pyproject.txt dirty-equals-0.8.0/requirements/docs.in 0000664 0000000 0000000 00000000105 14656734655 0020166 0 ustar 00root root 0000000 0000000 mike mkdocs mkdocs-material mkdocs-simple-hooks mkdocstrings[python] dirty-equals-0.8.0/requirements/docs.txt 0000664 0000000 0000000 00000005263 14656734655 0020411 0 ustar 00root root 0000000 0000000 # # This file is autogenerated by pip-compile with Python 3.12 # by the following command: # # pip-compile --constraint=requirements/linting.txt --constraint=requirements/tests.txt --output-file=requirements/docs.txt requirements/docs.in # babel==2.16.0 # via mkdocs-material certifi==2024.7.4 # via requests charset-normalizer==3.3.2 # via requests click==8.1.7 # via # -c requirements/tests.txt # mkdocs # mkdocstrings colorama==0.4.6 # via # griffe # mkdocs-material ghp-import==2.1.0 # via mkdocs griffe==0.48.0 # via mkdocstrings-python idna==3.7 # via requests importlib-metadata==8.2.0 # via mike importlib-resources==6.4.0 # via mike jinja2==3.1.4 # via # mike # mkdocs # mkdocs-material # mkdocstrings markdown==3.6 # via # mkdocs # mkdocs-autorefs # mkdocs-material # mkdocstrings # pymdown-extensions markupsafe==2.1.5 # via # jinja2 # mkdocs # mkdocs-autorefs # mkdocstrings mergedeep==1.3.4 # via # mkdocs # mkdocs-get-deps mike==2.1.3 # via -r requirements/docs.in mkdocs==1.6.0 # via # -r requirements/docs.in # mike # mkdocs-autorefs # mkdocs-material # mkdocs-simple-hooks # mkdocstrings mkdocs-autorefs==1.0.1 # via mkdocstrings mkdocs-get-deps==0.2.0 # via mkdocs mkdocs-material==9.5.31 # via -r requirements/docs.in mkdocs-material-extensions==1.3.1 # via mkdocs-material mkdocs-simple-hooks==0.1.5 # via -r requirements/docs.in mkdocstrings[python]==0.25.2 # via # -r requirements/docs.in # mkdocstrings-python mkdocstrings-python==1.10.7 # via mkdocstrings packaging==24.1 # via # -c requirements/tests.txt # mkdocs paginate==0.5.6 # via mkdocs-material pathspec==0.12.1 # via # -c requirements/tests.txt # mkdocs platformdirs==4.2.2 # via # -c requirements/tests.txt # mkdocs-get-deps # mkdocstrings pygments==2.18.0 # via # -c requirements/tests.txt # mkdocs-material pymdown-extensions==10.9 # via # mkdocs-material # mkdocstrings pyparsing==3.1.2 # via mike python-dateutil==2.9.0.post0 # via ghp-import pyyaml==6.0.2 # via # mike # mkdocs # mkdocs-get-deps # pymdown-extensions # pyyaml-env-tag pyyaml-env-tag==0.1 # via # mike # mkdocs regex==2024.7.24 # via mkdocs-material requests==2.32.3 # via mkdocs-material six==1.16.0 # via python-dateutil urllib3==2.2.2 # via requests verspec==0.1.0 # via mike watchdog==4.0.2 # via mkdocs zipp==3.20.0 # via importlib-metadata dirty-equals-0.8.0/requirements/linting.in 0000664 0000000 0000000 00000000036 14656734655 0020705 0 ustar 00root root 0000000 0000000 mypy pydantic ruff types-pytz dirty-equals-0.8.0/requirements/linting.txt 0000664 0000000 0000000 00000001133 14656734655 0021115 0 ustar 00root root 0000000 0000000 # # This file is autogenerated by pip-compile with Python 3.12 # by the following command: # # pip-compile --output-file=requirements/linting.txt requirements/linting.in # annotated-types==0.7.0 # via pydantic mypy==1.11.1 # via -r requirements/linting.in mypy-extensions==1.0.0 # via mypy pydantic==2.8.2 # via -r requirements/linting.in pydantic-core==2.20.1 # via pydantic ruff==0.5.7 # via -r requirements/linting.in types-pytz==2024.1.0.20240417 # via -r requirements/linting.in typing-extensions==4.12.2 # via # mypy # pydantic # pydantic-core dirty-equals-0.8.0/requirements/pyproject.txt 0000664 0000000 0000000 00000001244 14656734655 0021473 0 ustar 00root root 0000000 0000000 # # This file is autogenerated by pip-compile with Python 3.12 # by the following command: # # pip-compile --constraint=requirements/docs.txt --constraint=requirements/linting.txt --constraint=requirements/tests.txt --extra=pydantic --output-file=requirements/pyproject.txt pyproject.toml # annotated-types==0.7.0 # via # -c requirements/linting.txt # pydantic pydantic==2.8.2 # via # -c requirements/linting.txt # dirty-equals (pyproject.toml) pydantic-core==2.20.1 # via # -c requirements/linting.txt # pydantic typing-extensions==4.12.2 # via # -c requirements/linting.txt # pydantic # pydantic-core dirty-equals-0.8.0/requirements/tests.in 0000664 0000000 0000000 00000000117 14656734655 0020403 0 ustar 00root root 0000000 0000000 coverage[toml] packaging pytest pytest-mock pytest-pretty pytest-examples pytz dirty-equals-0.8.0/requirements/tests.txt 0000664 0000000 0000000 00000002267 14656734655 0020624 0 ustar 00root root 0000000 0000000 # # This file is autogenerated by pip-compile with Python 3.12 # by the following command: # # pip-compile --constraint=requirements/linting.txt --output-file=requirements/tests.txt requirements/tests.in # black==24.8.0 # via pytest-examples click==8.1.7 # via black coverage[toml]==7.6.1 # via -r requirements/tests.in iniconfig==2.0.0 # via pytest markdown-it-py==3.0.0 # via rich mdurl==0.1.2 # via markdown-it-py mypy-extensions==1.0.0 # via # -c requirements/linting.txt # black packaging==24.1 # via # -r requirements/tests.in # black # pytest pathspec==0.12.1 # via black platformdirs==4.2.2 # via black pluggy==1.5.0 # via pytest pygments==2.18.0 # via rich pytest==8.3.2 # via # -r requirements/tests.in # pytest-examples # pytest-mock # pytest-pretty pytest-examples==0.0.13 # via -r requirements/tests.in pytest-mock==3.14.0 # via -r requirements/tests.in pytest-pretty==1.2.0 # via -r requirements/tests.in pytz==2024.1 # via -r requirements/tests.in rich==13.7.1 # via pytest-pretty ruff==0.5.7 # via # -c requirements/linting.txt # pytest-examples dirty-equals-0.8.0/tests/ 0000775 0000000 0000000 00000000000 14656734655 0015331 5 ustar 00root root 0000000 0000000 dirty-equals-0.8.0/tests/__init__.py 0000664 0000000 0000000 00000000000 14656734655 0017430 0 ustar 00root root 0000000 0000000 dirty-equals-0.8.0/tests/mypy_checks.py 0000664 0000000 0000000 00000000664 14656734655 0020227 0 ustar 00root root 0000000 0000000 """ This module is run with mypy to check types can be used correctly externally. """ import sys sys.path.append('.') from dirty_equals import HasName, HasRepr, IsStr assert 123 == HasName('int') assert 123 == HasRepr('123') assert 123 == HasName(IsStr(regex='i..')) assert 123 == HasRepr(IsStr(regex=r'\d{3}')) # type ignore is required (if it wasn't, there would be an error) assert 123 != HasName(123) # type: ignore[arg-type] dirty-equals-0.8.0/tests/test_base.py 0000664 0000000 0000000 00000011123 14656734655 0017652 0 ustar 00root root 0000000 0000000 import platform import pprint import packaging.version import pytest from dirty_equals import Contains, IsApprox, IsInt, IsList, IsNegative, IsOneOf, IsPositive, IsStr from dirty_equals.version import VERSION def test_or(): assert 'foo' == IsStr | IsInt assert 1 == IsStr | IsInt assert -1 == IsStr | IsNegative | IsPositive v = IsStr | IsInt with pytest.raises(AssertionError): assert 1.5 == v assert str(v) == 'IsStr | IsInt' def test_and(): assert 4 == IsPositive & IsInt(lt=5) v = IsStr & IsInt with pytest.raises(AssertionError): assert 1 == v assert str(v) == 'IsStr & IsInt' def test_not(): assert 'foo' != IsInt assert 'foo' == ~IsInt def test_value_eq(): v = IsStr() with pytest.raises(AttributeError, match='value is not available until __eq__ has been called'): v.value assert 'foo' == v assert repr(v) == str(v) == "'foo'" == pprint.pformat(v) assert v.value == 'foo' def test_value_ne(): v = IsStr() with pytest.raises(AssertionError): assert 1 == v assert repr(v) == str(v) == 'IsStr()' == pprint.pformat(v) with pytest.raises(AttributeError, match='value is not available until __eq__ has been called'): v.value def test_dict_compare(): v = {'foo': 1, 'bar': 2, 'spam': 3} assert v == {'foo': IsInt, 'bar': IsPositive, 'spam': ~IsStr} assert v == {'foo': IsInt() & IsApprox(1), 'bar': IsPositive() | IsNegative(), 'spam': ~IsStr()} @pytest.mark.skipif(platform.python_implementation() == 'PyPy', reason='PyPy does not metaclass dunder methods') def test_not_repr(): v = ~IsInt assert str(v) == '~IsInt' with pytest.raises(AssertionError): assert 1 == v assert str(v) == '~IsInt' def test_not_repr_instance(): v = ~IsInt() assert str(v) == '~IsInt()' with pytest.raises(AssertionError): assert 1 == v assert str(v) == '~IsInt()' def test_repr(): v = ~IsInt assert str(v) == '~IsInt' assert '1' == v assert str(v) == "'1'" @pytest.mark.parametrize( 'v,v_repr', [ (IsInt, 'IsInt'), (~IsInt, '~IsInt'), (IsInt & IsPositive, 'IsInt & IsPositive'), (IsInt | IsPositive, 'IsInt | IsPositive'), (IsInt(), 'IsInt()'), (~IsInt(), '~IsInt()'), (IsInt() & IsPositive(), 'IsInt() & IsPositive()'), (IsInt() | IsPositive(), 'IsInt() | IsPositive()'), (IsInt() & IsPositive, 'IsInt() & IsPositive'), (IsInt() | IsPositive, 'IsInt() | IsPositive'), (IsPositive & IsInt(lt=5), 'IsPositive & IsInt(lt=5)'), (IsOneOf(1, 2, 3), 'IsOneOf(1, 2, 3)'), ], ) def test_repr_class(v, v_repr): assert repr(v) == str(v) == v_repr == pprint.pformat(v) def test_is_approx_without_init(): assert 1 != IsApprox def test_ne_repr(): v = IsInt assert repr(v) == str(v) == 'IsInt' == pprint.pformat(v) assert 'x' != v assert repr(v) == str(v) == 'IsInt' == pprint.pformat(v) def test_pprint(): v = [IsList(length=...), 1, [IsList(length=...), 2], 3, IsInt()] lorem = ['lorem', 'ipsum', 'dolor', 'sit', 'amet'] * 2 with pytest.raises(AssertionError): assert [lorem, 1, [lorem, 2], 3, '4'] == v assert repr(v) == (f'[{lorem}, 1, [{lorem}, 2], 3, IsInt()]') assert pprint.pformat(v) == ( "[['lorem',\n" " 'ipsum',\n" " 'dolor',\n" " 'sit',\n" " 'amet',\n" " 'lorem',\n" " 'ipsum',\n" " 'dolor',\n" " 'sit',\n" " 'amet'],\n" ' 1,\n' " [['lorem',\n" " 'ipsum',\n" " 'dolor',\n" " 'sit',\n" " 'amet',\n" " 'lorem',\n" " 'ipsum',\n" " 'dolor',\n" " 'sit',\n" " 'amet'],\n" ' 2],\n' ' 3,\n' ' IsInt()]' ) def test_pprint_not_equal(): v = IsList(*range(30)) # need a big value to trigger pprint with pytest.raises(AssertionError): assert [] == v assert ( pprint.pformat(v) == ( 'IsList(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, ' '15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29)' ) == repr(v) == str(v) ) @pytest.mark.parametrize( 'value,dirty', [ (1, IsOneOf(1, 2, 3)), (4, ~IsOneOf(1, 2, 3)), ([1, 2, 3], Contains(1) | IsOneOf([])), ([], Contains(1) | IsOneOf([])), ([2], ~(Contains(1) | IsOneOf([]))), ], ) def test_is_one_of(value, dirty): assert value == dirty def test_version(): packaging.version.parse(VERSION) dirty-equals-0.8.0/tests/test_boolean.py 0000664 0000000 0000000 00000004541 14656734655 0020365 0 ustar 00root root 0000000 0000000 import platform import pytest from dirty_equals import IsFalseLike, IsTrueLike @pytest.mark.parametrize( 'other, expected', [ (False, IsFalseLike), (True, ~IsFalseLike), ([], IsFalseLike), ([1], ~IsFalseLike), ((), IsFalseLike), ('', IsFalseLike), ('', IsFalseLike(allow_strings=True)), ((1, 2), ~IsFalseLike), ({}, IsFalseLike), ({1: 'a'}, ~IsFalseLike), (set(), IsFalseLike), ({'a', 'b', 'c'}, ~IsFalseLike), (None, IsFalseLike), (0, IsFalseLike), (1, ~IsFalseLike), (0.0, IsFalseLike), (1.0, ~IsFalseLike), ('0', IsFalseLike(allow_strings=True)), ('1', ~IsFalseLike(allow_strings=True)), ('0.0', IsFalseLike(allow_strings=True)), ('0.000', IsFalseLike(allow_strings=True)), ('1.0', ~IsFalseLike(allow_strings=True)), ('False', IsFalseLike(allow_strings=True)), ('True', ~IsFalseLike(allow_strings=True)), (0, IsFalseLike(allow_strings=True)), ], ) def test_is_false_like(other, expected): assert other == expected def test_is_false_like_repr(): assert repr(IsFalseLike) == 'IsFalseLike' assert repr(IsFalseLike()) == 'IsFalseLike()' assert repr(IsFalseLike(allow_strings=True)) == 'IsFalseLike(allow_strings=True)' @pytest.mark.skipif(platform.python_implementation() == 'PyPy', reason='PyPy does not metaclass dunder methods') def test_dirty_not_equals(): with pytest.raises(AssertionError): assert 0 != IsFalseLike def test_dirty_not_equals_instance(): with pytest.raises(AssertionError): assert 0 != IsFalseLike() def test_invalid_initialization(): with pytest.raises(TypeError, match='takes 1 positional argument but 2 were given'): IsFalseLike(True) @pytest.mark.parametrize( 'other, expected', [ (False, ~IsTrueLike), (True, IsTrueLike), ([], ~IsTrueLike), ([1], IsTrueLike), ((), ~IsTrueLike), ((1, 2), IsTrueLike), ({}, ~IsTrueLike), ({1: 'a'}, IsTrueLike), (set(), ~IsTrueLike), ({'a', 'b', 'c'}, IsTrueLike), (None, ~IsTrueLike), (0, ~IsTrueLike), (1, IsTrueLike), (0.0, ~IsTrueLike), (1.0, IsTrueLike), ], ) def test_is_true_like(other, expected): assert other == expected dirty-equals-0.8.0/tests/test_datetime.py 0000664 0000000 0000000 00000021677 14656734655 0020553 0 ustar 00root root 0000000 0000000 from datetime import date, datetime, timedelta, timezone from unittest.mock import Mock import pytest import pytz from dirty_equals import IsDate, IsDatetime, IsNow, IsToday try: from zoneinfo import ZoneInfo except ImportError: ZoneInfo = None @pytest.mark.parametrize( 'value,dirty,expect_match', [ pytest.param(datetime(2000, 1, 1), IsDatetime(approx=datetime(2000, 1, 1)), True, id='same'), # Note: this requires the system timezone to be UTC pytest.param(946684800, IsDatetime(approx=datetime(2000, 1, 1), unix_number=True), True, id='unix-int'), # Note: this requires the system timezone to be UTC pytest.param(946684800.123, IsDatetime(approx=datetime(2000, 1, 1), unix_number=True), True, id='unix-float'), pytest.param(946684800, IsDatetime(approx=datetime(2000, 1, 1)), False, id='unix-different'), pytest.param( '2000-01-01T00:00', IsDatetime(approx=datetime(2000, 1, 1), iso_string=True), True, id='iso-string-true' ), pytest.param('2000-01-01T00:00', IsDatetime(approx=datetime(2000, 1, 1)), False, id='iso-string-different'), pytest.param('broken', IsDatetime(approx=datetime(2000, 1, 1)), False, id='iso-string-wrong'), pytest.param( '28/01/87', IsDatetime(approx=datetime(1987, 1, 28), format_string='%d/%m/%y'), True, id='string-format' ), pytest.param('28/01/87', IsDatetime(approx=datetime(2000, 1, 1)), False, id='string-format-different'), pytest.param('foobar', IsDatetime(approx=datetime(2000, 1, 1)), False, id='string-format-wrong'), pytest.param(datetime(2000, 1, 1).isoformat(), IsNow(iso_string=True), False, id='isnow-str-different'), pytest.param([1, 2, 3], IsDatetime(approx=datetime(2000, 1, 1)), False, id='wrong-type'), pytest.param( datetime(2020, 1, 1, 12, 13, 14), IsDatetime(approx=datetime(2020, 1, 1, 12, 13, 14)), True, id='tz-same' ), pytest.param( datetime(2020, 1, 1, 12, 13, 14, tzinfo=timezone.utc), IsDatetime(approx=datetime(2020, 1, 1, 12, 13, 14), enforce_tz=False), True, id='tz-utc', ), pytest.param( datetime(2020, 1, 1, 12, 13, 14, tzinfo=timezone.utc), IsDatetime(approx=datetime(2020, 1, 1, 12, 13, 14)), False, id='tz-utc-different', ), pytest.param( datetime(2020, 1, 1, 12, 13, 14), IsDatetime(approx=datetime(2020, 1, 1, 12, 13, 14, tzinfo=timezone.utc), enforce_tz=False), False, id='tz-approx-tz', ), pytest.param( datetime(2020, 1, 1, 12, 13, 14, tzinfo=timezone(offset=timedelta(hours=1))), IsDatetime(approx=datetime(2020, 1, 1, 12, 13, 14), enforce_tz=False), True, id='tz-1-hour', ), pytest.param( pytz.timezone('Europe/London').localize(datetime(2022, 2, 15, 15, 15)), IsDatetime( approx=pytz.timezone('America/New_York').localize(datetime(2022, 2, 15, 10, 15)), enforce_tz=False ), True, id='tz-both-tz', ), pytest.param( pytz.timezone('Europe/London').localize(datetime(2022, 2, 15, 15, 15)), IsDatetime(approx=pytz.timezone('America/New_York').localize(datetime(2022, 2, 15, 10, 15))), False, id='tz-both-tz-different', ), pytest.param(datetime(2000, 1, 1), IsDatetime(ge=datetime(2000, 1, 1)), True, id='ge'), pytest.param(datetime(1999, 1, 1), IsDatetime(ge=datetime(2000, 1, 1)), False, id='ge-not'), pytest.param(datetime(2000, 1, 2), IsDatetime(gt=datetime(2000, 1, 1)), True, id='gt'), pytest.param(datetime(2000, 1, 1), IsDatetime(gt=datetime(2000, 1, 1)), False, id='gt-not'), ], ) def test_is_datetime(value, dirty, expect_match): if expect_match: assert value == dirty else: assert value != dirty @pytest.mark.skipif(ZoneInfo is None, reason='requires zoneinfo') def test_is_datetime_zoneinfo(): london = datetime(2022, 2, 15, 15, 15, tzinfo=ZoneInfo('Europe/London')) ny = datetime(2022, 2, 15, 10, 15, tzinfo=ZoneInfo('America/New_York')) assert london != IsDatetime(approx=ny) assert london == IsDatetime(approx=ny, enforce_tz=False) def test_is_now_dt(): is_now = IsNow() dt = datetime.now() assert dt == is_now assert str(is_now) == repr(dt) def test_is_now_str(): assert datetime.now().isoformat() == IsNow(iso_string=True) def test_repr(): v = IsDatetime(approx=datetime(2032, 1, 2, 3, 4, 5), iso_string=True) assert str(v) == 'IsDatetime(approx=datetime.datetime(2032, 1, 2, 3, 4, 5), iso_string=True)' @pytest.mark.skipif(ZoneInfo is None, reason='requires zoneinfo') def test_is_now_tz(): utc_now = datetime.now(timezone.utc) now_ny = utc_now.astimezone(ZoneInfo('America/New_York')) assert now_ny == IsNow(tz='America/New_York') # depends on the time of year and DST assert now_ny == IsNow(tz=timezone(timedelta(hours=-5))) | IsNow(tz=timezone(timedelta(hours=-4))) now = datetime.now() assert now == IsNow assert now.timestamp() == IsNow(unix_number=True) assert now.timestamp() != IsNow assert now.isoformat() == IsNow(iso_string=True) assert now.isoformat() != IsNow assert utc_now == IsNow(tz=timezone.utc) def test_delta(): assert IsNow(delta=timedelta(hours=2)).delta == timedelta(seconds=7200) assert IsNow(delta=3600).delta == timedelta(seconds=3600) assert IsNow(delta=3600.1).delta == timedelta(seconds=3600, microseconds=100000) def test_is_now_relative(monkeypatch): mock = Mock(return_value=datetime(2020, 1, 1, 12, 13, 14)) monkeypatch.setattr(IsNow, '_get_now', mock) assert IsNow() == datetime(2020, 1, 1, 12, 13, 14) @pytest.mark.skipif(ZoneInfo is None, reason='requires zoneinfo') def test_tz(): new_year_london = datetime(2000, 1, 1, tzinfo=ZoneInfo('Europe/London')) new_year_eve_nyc = datetime(1999, 12, 31, 19, 0, 0, tzinfo=ZoneInfo('America/New_York')) assert new_year_eve_nyc == IsDatetime(approx=new_year_london, enforce_tz=False) assert new_year_eve_nyc != IsDatetime(approx=new_year_london, enforce_tz=True) new_year_naive = datetime(2000, 1, 1) assert new_year_naive != IsDatetime(approx=new_year_london, enforce_tz=False) assert new_year_naive != IsDatetime(approx=new_year_eve_nyc, enforce_tz=False) assert new_year_london == IsDatetime(approx=new_year_naive, enforce_tz=False) assert new_year_eve_nyc != IsDatetime(approx=new_year_naive, enforce_tz=False) @pytest.mark.parametrize( 'value,dirty,expect_match', [ pytest.param(date(2000, 1, 1), IsDate(approx=date(2000, 1, 1)), True, id='same'), pytest.param('2000-01-01', IsDate(approx=date(2000, 1, 1), iso_string=True), True, id='iso-string-true'), pytest.param('2000-01-01', IsDate(approx=date(2000, 1, 1)), False, id='iso-string-different'), pytest.param('2000-01-01T00:00', IsDate(approx=date(2000, 1, 1)), False, id='iso-string-different'), pytest.param('broken', IsDate(approx=date(2000, 1, 1)), False, id='iso-string-wrong'), pytest.param('28/01/87', IsDate(approx=date(1987, 1, 28), format_string='%d/%m/%y'), True, id='string-format'), pytest.param('28/01/87', IsDate(approx=date(2000, 1, 1)), False, id='string-format-different'), pytest.param('foobar', IsDate(approx=date(2000, 1, 1)), False, id='string-format-wrong'), pytest.param([1, 2, 3], IsDate(approx=date(2000, 1, 1)), False, id='wrong-type'), pytest.param( datetime(2000, 1, 1, 10, 11, 12), IsDate(approx=date(2000, 1, 1)), False, id='wrong-type-datetime' ), pytest.param(date(2020, 1, 1), IsDate(approx=date(2020, 1, 1)), True, id='tz-same'), pytest.param(date(2000, 1, 1), IsDate(ge=date(2000, 1, 1)), True, id='ge'), pytest.param(date(1999, 1, 1), IsDate(ge=date(2000, 1, 1)), False, id='ge-not'), pytest.param(date(2000, 1, 2), IsDate(gt=date(2000, 1, 1)), True, id='gt'), pytest.param(date(2000, 1, 1), IsDate(gt=date(2000, 1, 1)), False, id='gt-not'), pytest.param(date(2000, 1, 1), IsDate(gt=date(2000, 1, 1), delta=10), False, id='delta-int'), pytest.param(date(2000, 1, 1), IsDate(gt=date(2000, 1, 1), delta=10.5), False, id='delta-float'), pytest.param( date(2000, 1, 1), IsDate(gt=date(2000, 1, 1), delta=timedelta(seconds=10)), False, id='delta-timedelta' ), ], ) def test_is_date(value, dirty, expect_match): if expect_match: assert value == dirty else: assert value != dirty def test_is_today(): today = date.today() assert today == IsToday assert today + timedelta(days=2) != IsToday assert today.isoformat() == IsToday(iso_string=True) assert today.isoformat() != IsToday() assert today.strftime('%Y/%m/%d') == IsToday(format_string='%Y/%m/%d') assert today.strftime('%Y/%m/%d') != IsToday() dirty-equals-0.8.0/tests/test_dict.py 0000664 0000000 0000000 00000011622 14656734655 0017667 0 ustar 00root root 0000000 0000000 import pytest from dirty_equals import IsDict, IsIgnoreDict, IsPartialDict, IsPositiveInt, IsStr, IsStrictDict @pytest.mark.parametrize( 'input_value,expected', [ ({}, IsDict), ({}, IsDict()), ({'a': 1}, IsDict(a=1)), ({1: 2}, IsDict({1: 2})), ({'a': 1, 'b': 2}, IsDict(a=1, b=2)), ({'b': 2, 'a': 1}, IsDict(a=1, b=2)), ({'a': 1, 'b': None}, IsDict(a=1, b=None)), ({'a': 1, 'b': 3}, ~IsDict(a=1, b=2)), # partial dict ({1: 10, 2: 20}, IsPartialDict({1: 10})), ({1: 10}, IsPartialDict({1: 10})), ({1: 10, 2: 20}, IsPartialDict({1: 10})), ({1: 10, 2: 20}, IsDict({1: 10}).settings(partial=True)), ({1: 10}, ~IsPartialDict({1: 10, 2: 20})), ({1: 10, 2: None}, ~IsPartialDict({1: 10, 2: 20})), # ignore dict ({}, IsIgnoreDict()), ({'a': 1, 'b': 2}, IsIgnoreDict(a=1, b=2)), ({'a': 1, 'b': None}, IsIgnoreDict(a=1)), ({1: 10, 2: None}, IsIgnoreDict({1: 10})), ({'a': 1, 'b': 2}, ~IsIgnoreDict(a=1)), ({1: 10, 2: False}, ~IsIgnoreDict({1: 10})), ({1: 10, 2: False}, IsIgnoreDict({1: 10}).settings(ignore={False})), # strict dict ({}, IsStrictDict()), ({'a': 1, 'b': 2}, IsStrictDict(a=1, b=2)), ({'a': 1, 'b': 2}, ~IsStrictDict(b=2, a=1)), ({1: 10, 2: 20}, IsStrictDict({1: 10, 2: 20})), ({1: 10, 2: 20}, ~IsStrictDict({2: 20, 1: 10})), ({1: 10, 2: 20}, ~IsDict({2: 20, 1: 10}).settings(strict=True)), # combining types ({'a': 1, 'b': 2, 'c': 3}, IsStrictDict(a=1, c=3).settings(partial=True)), ({'a': 1, 'b': 2, 'c': 3}, IsStrictDict(a=1, b=2).settings(partial=True)), ({'a': 1, 'b': 2, 'c': 3}, IsStrictDict(b=2, c=3).settings(partial=True)), ({'a': 1, 'c': 3, 'b': 2}, ~IsStrictDict(b=2, c=3).settings(partial=True)), ], ) def test_is_dict(input_value, expected): assert input_value == expected def test_ne_repr_partial_dict(): v = IsPartialDict({1: 10, 2: 20}) with pytest.raises(AssertionError): assert 1 == v assert str(v) == 'IsPartialDict(1=10, 2=20)' def test_ne_repr_strict_dict(): v = IsStrictDict({1: 10, 2: 20}) with pytest.raises(AssertionError): assert 1 == v assert str(v) == 'IsStrictDict(1=10, 2=20)' def test_args_and_kwargs(): with pytest.raises(TypeError, match='IsDict requires either a single argument or kwargs, not both'): IsDict(1, x=4) def test_multiple_args(): with pytest.raises(TypeError, match='IsDict expected at most 1 argument, got 2'): IsDict(1, 2) def test_arg_not_dict(): with pytest.raises(TypeError, match="expected_values must be a dict, got