pax_global_header 0000666 0000000 0000000 00000000064 14475112553 0014521 g ustar 00root root 0000000 0000000 52 comment=87d4552f76f3a123cf0455f46f89091ade15614f dirty-equals-0.7.0/ 0000775 0000000 0000000 00000000000 14475112553 0014150 5 ustar 00root root 0000000 0000000 dirty-equals-0.7.0/.codecov.yml 0000664 0000000 0000000 00000000223 14475112553 0016370 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.7.0/.github/ 0000775 0000000 0000000 00000000000 14475112553 0015510 5 ustar 00root root 0000000 0000000 dirty-equals-0.7.0/.github/FUNDING.yml 0000664 0000000 0000000 00000000025 14475112553 0017322 0 ustar 00root root 0000000 0000000 github: samuelcolvin dirty-equals-0.7.0/.github/workflows/ 0000775 0000000 0000000 00000000000 14475112553 0017545 5 ustar 00root root 0000000 0000000 dirty-equals-0.7.0/.github/workflows/ci.yml 0000664 0000000 0000000 00000011271 14475112553 0020665 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.7', '3.8', '3.9', '3.10', '3.11'] # 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.7' - os: 'ubuntu' python-version: 'pypy-3.8' - os: 'ubuntu' python-version: 'pypy-3.9' runs-on: ${{ matrix.os }}-latest env: PYTHON: ${{ matrix.python-version }} OS: ${{ matrix.os }} steps: - uses: actions/checkout@v3 - name: set up python uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - 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@v3 - uses: actions/setup-python@v4 with: python-version: '3.10' - run: pip install -r requirements/linting.txt - uses: pre-commit/action@v3.0.0 with: extra_args: --all-files docs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: set up python uses: actions/setup-python@v4 with: python-version: '3.10' - 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/mkdocs_material-9.1.5+insiders.4.32.4-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@v3 with: ref: docs-site - uses: actions/checkout@v3 - name: set up python uses: actions/setup-python@v4 with: python-version: '3.9' - 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/mkdocs_material-9.1.5+insiders.4.32.4-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@v3 - name: set up python uses: actions/setup-python@v4 with: python-version: '3.10' - 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.7.0/.github/workflows/upload-previews.yml 0000664 0000000 0000000 00000001625 14475112553 0023422 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.7.0/.gitignore 0000664 0000000 0000000 00000000326 14475112553 0016141 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.7.0/.pre-commit-config.yaml 0000664 0000000 0000000 00000000750 14475112553 0020433 0 ustar 00root root 0000000 0000000 repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.0.1 hooks: - 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: 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.7.0/LICENSE 0000664 0000000 0000000 00000002070 14475112553 0015154 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.7.0/Makefile 0000664 0000000 0000000 00000001557 14475112553 0015620 0 ustar 00root root 0000000 0000000 .DEFAULT_GOAL := all sources = dirty_equals tests .PHONY: install install: pip install -r requirements/all.txt pre-commit install .PHONY: format format: black $(sources) ruff --fix $(sources) .PHONY: lint lint: ruff $(sources) black $(sources) --check --diff .PHONY: test test: 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.7.0/README.md 0000664 0000000 0000000 00000007571 14475112553 0015441 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.7+**. dirty-equals-0.7.0/dirty_equals/ 0000775 0000000 0000000 00000000000 14475112553 0016655 5 ustar 00root root 0000000 0000000 dirty-equals-0.7.0/dirty_equals/__init__.py 0000664 0000000 0000000 00000004021 14475112553 0020763 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, 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', # 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.7.0/dirty_equals/_base.py 0000664 0000000 0000000 00000015720 14475112553 0020305 0 ustar 00root root 0000000 0000000 from abc import ABCMeta from typing import TYPE_CHECKING, Any, Dict, Generic, Iterable, Optional, Tuple, TypeVar try: from typing import Protocol except ImportError: # Python 3.7 doesn't have Protocol Protocol = object # type: ignore[assignment] 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() 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.7.0/dirty_equals/_boolean.py 0000664 0000000 0000000 00000004560 14475112553 0021012 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.7.0/dirty_equals/_datetime.py 0000664 0000000 0000000 00000025666 14475112553 0021201 0 ustar 00root root 0000000 0000000 from datetime import date, datetime, timedelta, timezone, tzinfo from typing import Any, Optional, Union from ._numeric import IsNumeric from ._utils import Omit class IsDatetime(IsNumeric[datetime]): """ Check if the value is a datetime, and matches the given conditions. """ allowed_types = datetime def __init__( self, *, approx: Optional[datetime] = None, delta: Optional[Union[timedelta, int, float]] = None, gt: Optional[datetime] = None, lt: Optional[datetime] = None, ge: Optional[datetime] = None, le: Optional[datetime] = None, unix_number: bool = False, iso_string: bool = False, format_string: Optional[str] = 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 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: Union[timedelta, int, float] = 2, unix_number: bool = False, iso_string: bool = False, format_string: Optional[str] = None, enforce_tz: bool = True, tz: Union[None, str, tzinfo] = 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 `pytz.timezone`, a `datetime.timezone` or a string which will be passed to `pytz.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): import pytz tz = pytz.timezone(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: return datetime.utcnow().replace(tzinfo=timezone.utc).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: Optional[date] = None, delta: Optional[Union[timedelta, int, float]] = None, gt: Optional[date] = None, lt: Optional[date] = None, ge: Optional[date] = None, le: Optional[date] = None, iso_string: bool = False, format_string: Optional[str] = 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: Optional[str] = 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.7.0/dirty_equals/_dict.py 0000664 0000000 0000000 00000020130 14475112553 0020305 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.7.0/dirty_equals/_inspection.py 0000664 0000000 0000000 00000014617 14475112553 0021552 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="Trival 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.7+**. dirty-equals-0.7.0/docs/internals.md 0000664 0000000 0000000 00000002171 14475112553 0017422 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.7.0/docs/plugins.py 0000664 0000000 0000000 00000003332 14475112553 0017134 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: 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 dirty-equals-0.7.0/docs/types/ 0000775 0000000 0000000 00000000000 14475112553 0016244 5 ustar 00root root 0000000 0000000 dirty-equals-0.7.0/docs/types/boolean.md 0000664 0000000 0000000 00000000113 14475112553 0020200 0 ustar 00root root 0000000 0000000 # Boolean Types ::: dirty_equals.IsTrueLike ::: dirty_equals.IsFalseLike dirty-equals-0.7.0/docs/types/custom.md 0000664 0000000 0000000 00000002413 14475112553 0020100 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.7.0/docs/types/datetime.md 0000664 0000000 0000000 00000003513 14475112553 0020364 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" from datetime import datetime import pytz from dirty_equals import IsDatetime tz_london = pytz.timezone('Europe/London') new_year_london = tz_london.localize(datetime(2000, 1, 1)) tz_nyc = pytz.timezone('America/New_York') new_year_eve_nyc = tz_nyc.localize(datetime(1999, 12, 31, 19, 0, 0)) 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.7.0/docs/types/dict.md 0000664 0000000 0000000 00000000212 14475112553 0017504 0 ustar 00root root 0000000 0000000 # Dictionary Types ::: dirty_equals.IsDict ::: dirty_equals.IsPartialDict ::: dirty_equals.IsIgnoreDict ::: dirty_equals.IsStrictDict dirty-equals-0.7.0/docs/types/inspection.md 0000664 0000000 0000000 00000000203 14475112553 0020734 0 ustar 00root root 0000000 0000000 # Type Inspection ::: dirty_equals.IsInstance ::: dirty_equals.HasName ::: dirty_equals.HasRepr ::: dirty_equals.HasAttributes dirty-equals-0.7.0/docs/types/numeric.md 0000664 0000000 0000000 00000002434 14475112553 0020233 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.7.0/docs/types/other.md 0000664 0000000 0000000 00000000603 14475112553 0017706 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-0.7.0/docs/types/sequence.md 0000664 0000000 0000000 00000000230 14475112553 0020371 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.7.0/docs/types/string.md 0000664 0000000 0000000 00000000134 14475112553 0020072 0 ustar 00root root 0000000 0000000 # String Types ::: dirty_equals.IsAnyStr ::: dirty_equals.IsStr ::: dirty_equals.IsBytes dirty-equals-0.7.0/docs/usage.md 0000664 0000000 0000000 00000007354 14475112553 0016537 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.7.0/mkdocs.yml 0000664 0000000 0000000 00000004525 14475112553 0016161 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:materialx.emoji.twemoji emoji_generator: !!python/name:materialx.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 - 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.7.0/pyproject.toml 0000664 0000000 0000000 00000005105 14475112553 0017065 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 = {file = 'LICENSE'} 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.7', 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: 3.11', 'Topic :: Software Development :: Libraries :: Python Modules', 'Topic :: Internet', 'Typing :: Typed', ] requires-python = '>=3.7' dependencies = [ 'typing-extensions>=4.0.1;python_version<"3.8"', 'pytz>=2021.3', ] optional-dependencies = {pydantic = ['pydantic>=1.9.1'] } 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 extend-select = ['Q', 'RUF100', 'C90', 'UP', 'I'] flake8-quotes = {inline-quotes = 'single', multiline-quotes = 'double'} mccabe = { max-complexity = 14 } isort = { known-first-party = ['tests'] } target-version = 'py37' [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.black] color = true line-length = 120 target-version = ["py39"] skip-string-normalization = true [tool.isort] line_length = 120 multi_line_output = 3 include_trailing_comma = true force_grid_wrap = 0 combine_as_imports = true color_output = true [tool.mypy] strict = true warn_return_any = false show_error_codes = true dirty-equals-0.7.0/requirements/ 0000775 0000000 0000000 00000000000 14475112553 0016673 5 ustar 00root root 0000000 0000000 dirty-equals-0.7.0/requirements/all.txt 0000664 0000000 0000000 00000000101 14475112553 0020174 0 ustar 00root root 0000000 0000000 -r ./docs.txt -r ./linting.txt -r ./tests.txt -r ./pyproject.txt dirty-equals-0.7.0/requirements/docs.in 0000664 0000000 0000000 00000000253 14475112553 0020153 0 ustar 00root root 0000000 0000000 black # waiting for https://github.com/jimporter/mike/issues/154 git+https://github.com/jimporter/mike.git mkdocs mkdocs-material mkdocs-simple-hooks mkdocstrings[python] dirty-equals-0.7.0/requirements/docs.txt 0000664 0000000 0000000 00000004413 14475112553 0020366 0 ustar 00root root 0000000 0000000 # # This file is autogenerated by pip-compile with python 3.10 # To update, run: # # pip-compile --output-file=requirements/docs.txt requirements/docs.in # black==23.3.0 # via -r requirements/docs.in certifi==2022.12.7 # via requests charset-normalizer==3.1.0 # via requests click==8.1.3 # via # black # mkdocs colorama==0.4.6 # via # griffe # mkdocs-material ghp-import==2.1.0 # via mkdocs griffe==0.27.1 # via mkdocstrings-python idna==3.4 # via requests importlib-metadata==6.6.0 # via mike importlib-resources==5.12.0 # via mike jinja2==3.1.2 # via # mike # mkdocs # mkdocs-material # mkdocstrings markdown==3.3.7 # via # mkdocs # mkdocs-autorefs # mkdocs-material # mkdocstrings # pymdown-extensions markupsafe==2.1.2 # via # jinja2 # mkdocstrings mergedeep==1.3.4 # via mkdocs mike @ git+https://github.com/jimporter/mike.git # via -r requirements/docs.in mkdocs==1.4.2 # via # -r requirements/docs.in # mike # mkdocs-autorefs # mkdocs-material # mkdocs-simple-hooks # mkdocstrings mkdocs-autorefs==0.4.1 # via mkdocstrings mkdocs-material==9.1.8 # via -r requirements/docs.in mkdocs-material-extensions==1.1.1 # via mkdocs-material mkdocs-simple-hooks==0.1.5 # via -r requirements/docs.in mkdocstrings[python]==0.21.2 # via # -r requirements/docs.in # mkdocstrings-python mkdocstrings-python==0.9.0 # via mkdocstrings mypy-extensions==1.0.0 # via black packaging==23.1 # via # black # mkdocs pathspec==0.11.1 # via black platformdirs==3.4.0 # via black pygments==2.15.1 # via mkdocs-material pymdown-extensions==9.11 # via # mkdocs-material # mkdocstrings python-dateutil==2.8.2 # via ghp-import pyyaml==6.0 # via # mike # mkdocs # pymdown-extensions # pyyaml-env-tag pyyaml-env-tag==0.1 # via mkdocs regex==2023.3.23 # via mkdocs-material requests==2.29.0 # via mkdocs-material six==1.16.0 # via python-dateutil tomli==2.0.1 # via black urllib3==1.26.15 # via requests verspec==0.1.0 # via mike watchdog==3.0.0 # via mkdocs zipp==3.15.0 # via importlib-metadata dirty-equals-0.7.0/requirements/linting.in 0000664 0000000 0000000 00000000057 14475112553 0020671 0 ustar 00root root 0000000 0000000 black mypy pre-commit pydantic ruff types-pytz dirty-equals-0.7.0/requirements/linting.txt 0000664 0000000 0000000 00000002225 14475112553 0021101 0 ustar 00root root 0000000 0000000 # # This file is autogenerated by pip-compile with Python 3.10 # by the following command: # # pip-compile --output-file=requirements/linting.txt requirements/linting.in # black==23.3.0 # via -r requirements/linting.in cfgv==3.3.1 # via pre-commit click==8.1.3 # via black distlib==0.3.6 # via virtualenv filelock==3.12.0 # via virtualenv identify==2.5.23 # via pre-commit mypy==1.2.0 # via -r requirements/linting.in mypy-extensions==1.0.0 # via # black # mypy nodeenv==1.7.0 # via pre-commit packaging==23.1 # via black pathspec==0.11.1 # via black platformdirs==3.4.0 # via # black # virtualenv pre-commit==3.2.2 # via -r requirements/linting.in pydantic==1.10.7 # via -r requirements/linting.in pyyaml==6.0 # via pre-commit ruff==0.0.263 # via -r requirements/linting.in tomli==2.0.1 # via # black # mypy types-pytz==2023.3.0.0 # via -r requirements/linting.in typing-extensions==4.5.0 # via # mypy # pydantic virtualenv==20.22.0 # via pre-commit # The following packages are considered to be unsafe in a requirements file: # setuptools dirty-equals-0.7.0/requirements/pyproject.txt 0000664 0000000 0000000 00000000513 14475112553 0021452 0 ustar 00root root 0000000 0000000 # # This file is autogenerated by pip-compile with python 3.10 # To update, run: # # pip-compile --extra=pydantic --output-file=requirements/pyproject.txt pyproject.toml # pydantic==1.10.7 # via dirty-equals (pyproject.toml) pytz==2022.2.1 # via dirty-equals (pyproject.toml) typing-extensions==4.5.0 # via pydantic dirty-equals-0.7.0/requirements/tests.in 0000664 0000000 0000000 00000000112 14475112553 0020357 0 ustar 00root root 0000000 0000000 coverage[toml] packaging pytest pytest-mock pytest-pretty pytest-examples dirty-equals-0.7.0/requirements/tests.txt 0000664 0000000 0000000 00000002154 14475112553 0020600 0 ustar 00root root 0000000 0000000 # # This file is autogenerated by pip-compile with python 3.10 # To update, run: # # pip-compile --output-file=requirements/tests.txt requirements/tests.in # black==23.3.0 # via pytest-examples click==8.1.3 # via black coverage[toml]==7.2.3 # via -r requirements/tests.in exceptiongroup==1.1.1 # via pytest iniconfig==2.0.0 # via pytest markdown-it-py==2.2.0 # via rich mdurl==0.1.2 # via markdown-it-py mypy-extensions==1.0.0 # via black packaging==23.1 # via # -r requirements/tests.in # black # pytest pathspec==0.11.1 # via black platformdirs==3.5.0 # via black pluggy==1.0.0 # via pytest pygments==2.15.1 # via rich pytest==7.3.1 # via # -r requirements/tests.in # pytest-examples # pytest-mock # pytest-pretty pytest-examples==0.0.8 # via -r requirements/tests.in pytest-mock==3.10.0 # via -r requirements/tests.in pytest-pretty==1.2.0 # via -r requirements/tests.in rich==13.3.4 # via pytest-pretty ruff==0.0.263 # via pytest-examples tomli==2.0.1 # via # black # coverage # pytest dirty-equals-0.7.0/tests/ 0000775 0000000 0000000 00000000000 14475112553 0015312 5 ustar 00root root 0000000 0000000 dirty-equals-0.7.0/tests/__init__.py 0000664 0000000 0000000 00000000000 14475112553 0017411 0 ustar 00root root 0000000 0000000 dirty-equals-0.7.0/tests/mypy_checks.py 0000664 0000000 0000000 00000000700 14475112553 0020177 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 # noqa E402 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.7.0/tests/test_base.py 0000664 0000000 0000000 00000006326 14475112553 0017644 0 ustar 00root root 0000000 0000000 import platform import packaging.version import pytest from dirty_equals import Contains, IsApprox, IsInt, 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 str(v) == "'foo'" assert repr(v) == "'foo'" assert v.value == 'foo' def test_value_ne(): v = IsStr() with pytest.raises(AssertionError): assert 1 == v assert str(v) == 'IsStr()' assert repr(v) == 'IsStr()' 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) == v_repr def test_is_approx_without_init(): assert 1 != IsApprox def test_ne_repr(): v = IsInt assert repr(v) == 'IsInt' assert 'x' != v assert repr(v) == 'IsInt' @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.7.0/tests/test_boolean.py 0000664 0000000 0000000 00000004541 14475112553 0020346 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.7.0/tests/test_datetime.py 0000664 0000000 0000000 00000020723 14475112553 0020523 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 @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 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)' def test_is_now_tz(): now_ny = datetime.utcnow().replace(tzinfo=timezone.utc).astimezone(pytz.timezone('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 utc_now = datetime.utcnow().replace(tzinfo=timezone.utc) 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) def test_tz(): new_year_london = pytz.timezone('Europe/London').localize(datetime(2000, 1, 1)) new_year_eve_nyc = pytz.timezone('America/New_York').localize(datetime(1999, 12, 31, 19, 0, 0)) 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.7.0/tests/test_dict.py 0000664 0000000 0000000 00000011622 14475112553 0017650 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