pax_global_header00006660000000000000000000000064146567346550014537gustar00rootroot0000000000000052 comment=da29a2db8293c4c974f303974a6e5ffdb462c9b4 dirty-equals-0.8.0/000077500000000000000000000000001465673465500141675ustar00rootroot00000000000000dirty-equals-0.8.0/.codecov.yml000066400000000000000000000002231465673465500164070ustar00rootroot00000000000000coverage: precision: 2 range: [90, 100] status: patch: false project: false comment: layout: 'header, diff, flags, files, footer' dirty-equals-0.8.0/.github/000077500000000000000000000000001465673465500155275ustar00rootroot00000000000000dirty-equals-0.8.0/.github/FUNDING.yml000066400000000000000000000000251465673465500173410ustar00rootroot00000000000000github: samuelcolvin dirty-equals-0.8.0/.github/workflows/000077500000000000000000000000001465673465500175645ustar00rootroot00000000000000dirty-equals-0.8.0/.github/workflows/ci.yml000066400000000000000000000112611465673465500207030ustar00rootroot00000000000000name: 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.yml000066400000000000000000000016251465673465500234410ustar00rootroot00000000000000name: 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/.gitignore000066400000000000000000000003261465673465500161600ustar00rootroot00000000000000*.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.yaml000066400000000000000000000011761465673465500204550ustar00rootroot00000000000000repos: - 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/LICENSE000066400000000000000000000020701465673465500151730ustar00rootroot00000000000000The 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/Makefile000066400000000000000000000033211465673465500156260ustar00rootroot00000000000000.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.md000066400000000000000000000075711465673465500154600ustar00rootroot00000000000000

dirty-equals

Doing dirty (but extremely useful) things with equals.

CI Coverage pypi versions license

--- **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/000077500000000000000000000000001465673465500166745ustar00rootroot00000000000000dirty-equals-0.8.0/dirty_equals/__init__.py000066400000000000000000000040661465673465500210130ustar00rootroot00000000000000from ._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.py000066400000000000000000000177421465673465500203320ustar00rootroot00000000000000import 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.py000066400000000000000000000045601465673465500210310ustar00rootroot00000000000000from 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.py000066400000000000000000000271201465673465500212030ustar00rootroot00000000000000from __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.py000066400000000000000000000201101465673465500203220ustar00rootroot00000000000000from __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.py000066400000000000000000000145771465673465500215760ustar00rootroot00000000000000from 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=' 'HasRepr[T]': return cls(expected_repr) def equals(self, other: Any) -> bool: return repr(other) == self.expected_repr class HasAttributes(DirtyEquals[Any]): """ A type which checks that the value has the given attributes. This is a partial check - e.g. the attributes provided to check do not need to be exhaustive. """ @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()`). Example: ```py title="HasAttributes" from dirty_equals import AnyThing, HasAttributes, IsInt, IsStr class Foo: def __init__(self, a, b): self.a = a self.b = b def spam(self): pass assert Foo(1, 2) == HasAttributes(a=1, b=2) assert Foo(1, 2) == HasAttributes(a=1) assert Foo(1, 's') == HasAttributes(a=IsInt, b=IsStr) assert Foo(1, 2) != HasAttributes(a=IsInt, b=IsStr) assert Foo(1, 2) != HasAttributes(a=1, b=2, c=3) assert Foo(1, 2) == HasAttributes(a=1, b=2, spam=AnyThing) ``` """ self.expected_attrs = get_dict_arg('HasAttributes', expected_args, expected_kwargs) super().__init__(**self.expected_attrs) def equals(self, other: Any) -> bool: for attr, expected_value in self.expected_attrs.items(): # done like this to avoid problems with `AnyThing` equaling `None` or `DefaultAttr` try: value = getattr(other, attr) except AttributeError: return False else: if value != expected_value: return False return True dirty-equals-0.8.0/dirty_equals/_numeric.py000066400000000000000000000334451465673465500210600ustar00rootroot00000000000000import math from datetime import date, datetime, timedelta from decimal import Decimal from typing import Any, Optional, Tuple, Type, TypeVar, Union from ._base import DirtyEquals __all__ = ( 'IsApprox', 'IsNumeric', 'IsNumber', 'IsPositive', 'IsNegative', 'IsNonPositive', 'IsNonNegative', 'IsInt', 'IsPositiveInt', 'IsNegativeInt', 'IsFloat', 'IsPositiveFloat', 'IsNegativeFloat', ) from ._utils import Omit AnyNumber = Union[int, float, Decimal] N = TypeVar('N', int, float, Decimal, date, datetime, AnyNumber) class IsNumeric(DirtyEquals[N]): """ Base class for all numeric types, `IsNumeric` implements approximate and inequality comparisons, as well as the type checks. This class can be used directly or via any of its subclasses. """ allowed_types: Union[Type[N], Tuple[type, ...]] = (int, float, Decimal, date, datetime) """It allows any of the types supported in its subclasses.""" def __init__( self, *, exactly: Optional[N] = None, approx: Optional[N] = None, delta: Optional[N] = None, gt: Optional[N] = None, lt: Optional[N] = None, ge: Optional[N] = None, le: Optional[N] = None, ): """ Args: exactly: A value to exactly compare to - useful when you want to make sure a value is an `int` or `float`, while also checking its value. approx: A value to approximately compare to. delta: The allowable different when comparing to the value to `approx`, if omitted `value / 100` is used except for datetimes where 2 seconds is used. gt: Value which the compared value should be greater than. lt: Value which the compared value should be less than. ge: Value which the compared value should be greater than or equal to. le: Value which the compared value should be less than or equal to. If not values are provided, only the type is checked. If `approx` is provided as well a `gt`, `lt`, `ge`, or `le`, a `TypeError` is raised. Example of direct usage: ```py title="IsNumeric" from datetime import datetime from dirty_equals import IsNumeric assert 1.0 == IsNumeric assert 4 == IsNumeric(gt=3) d = datetime(2020, 1, 1, 12, 0, 0) assert d == IsNumeric(approx=datetime(2020, 1, 1, 12, 0, 1)) ``` """ self.exactly: Optional[N] = exactly if self.exactly is not None and (gt, lt, ge, le) != (None, None, None, None): raise TypeError('"exactly" cannot be combined with "gt", "lt", "ge", or "le"') if self.exactly is not None and approx is not None: raise TypeError('"exactly" cannot be combined with "approx"') self.approx: Optional[N] = approx if self.approx is not None and (gt, lt, ge, le) != (None, None, None, None): raise TypeError('"approx" cannot be combined with "gt", "lt", "ge", or "le"') self.delta: Optional[N] = delta self.gt: Optional[N] = gt self.lt: Optional[N] = lt self.ge: Optional[N] = ge self.le: Optional[N] = le self.has_bounds_checks = not all(f is None for f in (exactly, approx, delta, gt, lt, ge, le)) kwargs = { 'exactly': Omit if exactly is None else exactly, 'approx': Omit if approx is None else approx, 'delta': Omit if delta is None else delta, 'gt': Omit if gt is None else gt, 'lt': Omit if lt is None else lt, 'ge': Omit if ge is None else ge, 'le': Omit if le is None else le, } super().__init__(**kwargs) def prepare(self, other: Any) -> N: if other is True or other is False: raise TypeError('booleans are not numbers') elif not isinstance(other, self.allowed_types): raise TypeError(f'not a {self.allowed_types}') else: return other def equals(self, other: Any) -> bool: other = self.prepare(other) if self.has_bounds_checks: return self.bounds_checks(other) else: return True def bounds_checks(self, other: N) -> bool: if self.exactly is not None: return self.exactly == other elif self.approx is not None: if self.delta is None: if isinstance(other, date): delta: Any = timedelta(seconds=2) else: delta = abs(other / 100) else: delta = self.delta return self.approx_equals(other, delta) elif self.gt is not None and not other > self.gt: return False elif self.lt is not None and not other < self.lt: return False elif self.ge is not None and not other >= self.ge: return False elif self.le is not None and not other <= self.le: return False else: return True def approx_equals(self, other: Any, delta: Any) -> bool: return abs(self.approx - other) <= delta class IsNumber(IsNumeric[AnyNumber]): """ Base class for all types that can be used with all number types, e.g. numeric but not `date` or `datetime`. Inherits from [`IsNumeric`][dirty_equals.IsNumeric] and can therefore be initialised with any of its arguments. """ allowed_types = int, float, Decimal """ It allows any of the number types. """ Num = TypeVar('Num', int, float, Decimal) class IsApprox(IsNumber): """ Simplified subclass of [`IsNumber`][dirty_equals.IsNumber] that only allows approximate comparisons. """ def __init__(self, approx: Num, *, delta: Optional[Num] = None): """ Args: approx: A value to approximately compare to. delta: The allowable different when comparing to the value to `approx`, if omitted `value / 100` is used. ```py title="IsApprox" from dirty_equals import IsApprox assert 1.0 == IsApprox(1) assert 123 == IsApprox(120, delta=4) assert 201 == IsApprox(200) assert 201 != IsApprox(200, delta=0.1) ``` """ super().__init__(approx=approx, delta=delta) class IsPositive(IsNumber): """ Check that a value is positive (`> 0`), can be an `int`, a `float` or a `Decimal` (or indeed any value which implements `__gt__` for `0`). ```py title="IsPositive" from decimal import Decimal from dirty_equals import IsPositive assert 1.0 == IsPositive assert 1 == IsPositive assert Decimal('3.14') == IsPositive assert 0 != IsPositive assert -1 != IsPositive ``` """ def __init__(self) -> None: super().__init__(gt=0) self._repr_kwargs = {} class IsNegative(IsNumber): """ Check that a value is negative (`< 0`), can be an `int`, a `float` or a `Decimal` (or indeed any value which implements `__lt__` for `0`). ```py title="IsNegative" from decimal import Decimal from dirty_equals import IsNegative assert -1.0 == IsNegative assert -1 == IsNegative assert Decimal('-3.14') == IsNegative assert 0 != IsNegative assert 1 != IsNegative ``` """ def __init__(self) -> None: super().__init__(lt=0) self._repr_kwargs = {} class IsNonNegative(IsNumber): """ Check that a value is positive or zero (`>= 0`), can be an `int`, a `float` or a `Decimal` (or indeed any value which implements `__ge__` for `0`). ```py title="IsNonNegative" from decimal import Decimal from dirty_equals import IsNonNegative assert 1.0 == IsNonNegative assert 1 == IsNonNegative assert Decimal('3.14') == IsNonNegative assert 0 == IsNonNegative assert -1 != IsNonNegative assert Decimal('0') == IsNonNegative ``` """ def __init__(self) -> None: super().__init__(ge=0) self._repr_kwargs = {} class IsNonPositive(IsNumber): """ Check that a value is negative or zero (`<=0`), can be an `int`, a `float` or a `Decimal` (or indeed any value which implements `__le__` for `0`). ```py title="IsNonPositive" from decimal import Decimal from dirty_equals import IsNonPositive assert -1.0 == IsNonPositive assert -1 == IsNonPositive assert Decimal('-3.14') == IsNonPositive assert 0 == IsNonPositive assert 1 != IsNonPositive assert Decimal('-0') == IsNonPositive assert Decimal('0') == IsNonPositive ``` """ def __init__(self) -> None: super().__init__(le=0) self._repr_kwargs = {} class IsInt(IsNumeric[int]): """ Checks that a value is an integer. Inherits from [`IsNumeric`][dirty_equals.IsNumeric] and can therefore be initialised with any of its arguments. ```py title="IsInt" from dirty_equals import IsInt assert 1 == IsInt assert -2 == IsInt assert 1.0 != IsInt assert 'foobar' != IsInt assert True != IsInt assert 1 == IsInt(exactly=1) assert -2 != IsInt(exactly=1) ``` """ allowed_types = int """ As the name suggests, only integers are allowed, booleans (`True` are `False`) are explicitly excluded although technically they are sub-types of `int`. """ class IsPositiveInt(IsInt): """ Like [`IsPositive`][dirty_equals.IsPositive] but only for `int`s. ```py title="IsPositiveInt" from decimal import Decimal from dirty_equals import IsPositiveInt assert 1 == IsPositiveInt assert 1.0 != IsPositiveInt assert Decimal('3.14') != IsPositiveInt assert 0 != IsPositiveInt assert -1 != IsPositiveInt ``` """ def __init__(self) -> None: super().__init__(gt=0) self._repr_kwargs = {} class IsNegativeInt(IsInt): """ Like [`IsNegative`][dirty_equals.IsNegative] but only for `int`s. ```py title="IsNegativeInt" from decimal import Decimal from dirty_equals import IsNegativeInt assert -1 == IsNegativeInt assert -1.0 != IsNegativeInt assert Decimal('-3.14') != IsNegativeInt assert 0 != IsNegativeInt assert 1 != IsNegativeInt ``` """ def __init__(self) -> None: super().__init__(lt=0) self._repr_kwargs = {} class IsFloat(IsNumeric[float]): """ Checks that a value is a float. Inherits from [`IsNumeric`][dirty_equals.IsNumeric] and can therefore be initialised with any of its arguments. ```py title="IsFloat" from dirty_equals import IsFloat assert 1.0 == IsFloat assert 1 != IsFloat assert 1.0 == IsFloat(exactly=1.0) assert 1.001 != IsFloat(exactly=1.0) ``` """ allowed_types = float """ As the name suggests, only floats are allowed. """ class IsPositiveFloat(IsFloat): """ Like [`IsPositive`][dirty_equals.IsPositive] but only for `float`s. ```py title="IsPositiveFloat" from decimal import Decimal from dirty_equals import IsPositiveFloat assert 1.0 == IsPositiveFloat assert 1 != IsPositiveFloat assert Decimal('3.14') != IsPositiveFloat assert 0.0 != IsPositiveFloat assert -1.0 != IsPositiveFloat ``` """ def __init__(self) -> None: super().__init__(gt=0) self._repr_kwargs = {} class IsNegativeFloat(IsFloat): """ Like [`IsNegative`][dirty_equals.IsNegative] but only for `float`s. ```py title="IsNegativeFloat" from decimal import Decimal from dirty_equals import IsNegativeFloat assert -1.0 == IsNegativeFloat assert -1 != IsNegativeFloat assert Decimal('-3.14') != IsNegativeFloat assert 0.0 != IsNegativeFloat assert 1.0 != IsNegativeFloat ``` """ def __init__(self) -> None: super().__init__(lt=0) self._repr_kwargs = {} class IsFloatInf(IsFloat): """ Checks that a value is float and infinite (positive or negative). Inherits from [`IsFloat`][dirty_equals.IsFloat]. ```py title="IsFloatInf" from dirty_equals import IsFloatInf assert float('inf') == IsFloatInf assert float('-inf') == IsFloatInf assert 1.0 != IsFloatInf ``` """ def equals(self, other: Any) -> bool: other = self.prepare(other) return math.isinf(other) class IsFloatInfPos(IsFloatInf): """ Checks that a value is float and positive infinite. Inherits from [`IsFloatInf`][dirty_equals.IsFloatInf]. ```py title="IsFloatInfPos" from dirty_equals import IsFloatInfPos assert float('inf') == IsFloatInfPos assert -float('-inf') == IsFloatInfPos assert -float('inf') != IsFloatInfPos assert float('-inf') != IsFloatInfPos ``` """ def __init__(self) -> None: super().__init__(gt=0) self._repr_kwargs = {} def equals(self, other: Any) -> bool: return self.bounds_checks(other) and super().equals(other) class IsFloatInfNeg(IsFloatInf): """ Checks that a value is float and negative infinite. Inherits from [`IsFloatInf`][dirty_equals.IsFloatInf]. ```py title="IsFloatInfNeg" from dirty_equals import IsFloatInfNeg assert -float('inf') == IsFloatInfNeg assert float('-inf') == IsFloatInfNeg assert float('inf') != IsFloatInfNeg assert -float('-inf') != IsFloatInfNeg ``` """ def __init__(self) -> None: super().__init__(lt=0) self._repr_kwargs = {} def equals(self, other: Any) -> bool: return self.bounds_checks(other) and super().equals(other) class IsFloatNan(IsFloat): """ Checks that a value is float and nan (not a number). Inherits from [`IsFloat`][dirty_equals.IsFloat]. ```py title="IsFloatNan" from dirty_equals import IsFloatNan assert float('nan') == IsFloatNan assert 1.0 != IsFloatNan ``` """ def equals(self, other: Any) -> bool: other = self.prepare(other) return math.isnan(other) dirty-equals-0.8.0/dirty_equals/_other.py000066400000000000000000000447411465673465500205400ustar00rootroot00000000000000from __future__ import annotations import json import re from dataclasses import asdict, is_dataclass from enum import Enum from functools import lru_cache from ipaddress import IPv4Address, IPv4Network, IPv6Address, IPv6Network, ip_network from typing import TYPE_CHECKING, Any, Callable, Literal, TypeVar, Union, overload from uuid import UUID from ._base import DirtyEquals from ._dict import IsDict from ._utils import Omit, plain_repr if TYPE_CHECKING: from pydantic import TypeAdapter class IsUUID(DirtyEquals[UUID]): """ A class that checks if a value is a valid UUID, optionally checking UUID version. """ def __init__(self, version: Literal[None, 1, 2, 3, 4, 5] = None): """ Args: version: The version of the UUID to check, if omitted, all versions are accepted. ```py title="IsUUID" import uuid from dirty_equals import IsUUID assert 'edf9f29e-45c7-431c-99db-28ea44df9785' == IsUUID assert 'edf9f29e-45c7-431c-99db-28ea44df9785' == IsUUID(4) assert 'edf9f29e45c7431c99db28ea44df9785' == IsUUID(4) assert 'edf9f29e-45c7-431c-99db-28ea44df9785' != IsUUID(5) assert uuid.uuid4() == IsUUID(4) ``` """ self.version = version super().__init__(version or plain_repr('*')) def equals(self, other: Any) -> bool: if isinstance(other, UUID): uuid = other elif isinstance(other, str): uuid = UUID(other) if self.version is not None and uuid.version != self.version: return False else: return False if self.version: return uuid.version == self.version else: return True AnyJson = object JsonType = TypeVar('JsonType', AnyJson, Any) class IsJson(DirtyEquals[JsonType]): """ A class that checks if a value is a JSON object, and check the contents of the JSON. """ @overload def __init__(self, expected_value: JsonType = AnyJson): ... @overload def __init__(self, **expected_kwargs: Any): ... def __init__(self, expected_value: JsonType = AnyJson, **expected_kwargs: Any): """ Args: expected_value: Value to compare the JSON to, if omitted, any JSON is accepted. **expected_kwargs: Keyword arguments forming a dict to compare the JSON to, `expected_value` and `expected_kwargs` may not be combined. As with any `dirty_equals` type, types can be nested to provide more complex checks. !!! note Like [`IsInstance`][dirty_equals.IsInstance], `IsJson` can be parameterized or initialised with a value - `IsJson[xyz]` is exactly equivalent to `IsJson(xyz)`. This allows usage to be analogous to type hints. ```py title="IsJson" from dirty_equals import IsJson, IsPositiveInt, IsStrictDict assert '{"a": 1, "b": 2}' == IsJson assert '{"a": 1, "b": 2}' == IsJson(a=1, b=2) assert '{"a": 1}' != IsJson(a=2) assert 'invalid json' != IsJson assert '{"a": 1}' == IsJson(a=IsPositiveInt) assert '"just a quoted string"' == IsJson('just a quoted string') assert '{"a": 1, "b": 2}' == IsJson[IsStrictDict(a=1, b=2)] assert '{"b": 2, "a": 1}' != IsJson[IsStrictDict(a=1, b=2)] ``` """ if expected_kwargs: if expected_value is not AnyJson: raise TypeError('IsJson requires either an argument or kwargs, not both') self.expected_value: Any = expected_kwargs else: self.expected_value = expected_value super().__init__(plain_repr('*') if expected_value is AnyJson else expected_value) def __class_getitem__(cls, expected_type: JsonType) -> IsJson[JsonType]: return cls(expected_type) def equals(self, other: Any) -> bool: if isinstance(other, (str, bytes)): v = json.loads(other) if self.expected_value is AnyJson: return True else: return v == self.expected_value else: return False class FunctionCheck(DirtyEquals[Any]): """ Use a function to check if a value "equals" whatever you want to check """ def __init__(self, func: Callable[[Any], bool]): """ Args: func: callable that takes a value and returns a bool. ```py title="FunctionCheck" from dirty_equals import FunctionCheck def is_even(x): return x % 2 == 0 assert 2 == FunctionCheck(is_even) assert 3 != FunctionCheck(is_even) ``` """ self.func = func super().__init__(plain_repr(func.__name__)) def equals(self, other: Any) -> bool: return self.func(other) T = TypeVar('T') @lru_cache def _build_type_adapter(ta: type[TypeAdapter[T]], schema: T) -> TypeAdapter[T]: return ta(schema) _allowed_url_attribute_checks: set[str] = { 'scheme', 'host', 'host_type', 'user', 'password', 'port', 'path', 'query', 'fragment', } class IsUrl(DirtyEquals[Any]): """ A class that checks if a value is a valid URL, optionally checking different URL types and attributes with [Pydantic](https://pydantic-docs.helpmanual.io/usage/types/#urls). """ def __init__( self, any_url: bool = False, any_http_url: bool = False, http_url: bool = False, file_url: bool = False, postgres_dsn: bool = False, ampqp_dsn: bool = False, redis_dsn: bool = False, **expected_attributes: Any, ): """ Args: any_url: any scheme allowed, host required any_http_url: scheme http or https, host required http_url: scheme http or https, host required, max length 2083 file_url: scheme file, host not required postgres_dsn: user info required ampqp_dsn: schema amqp or amqps, user info not required, host not required redis_dsn: scheme redis or rediss, user info not required, host not required **expected_attributes: Expected values for url attributes ```py title="IsUrl" from dirty_equals import IsUrl assert 'https://example.com' == IsUrl assert 'https://example.com' == IsUrl(host='example.com') assert 'https://example.com' == IsUrl(scheme='https') assert 'https://example.com' != IsUrl(scheme='http') assert 'postgres://user:pass@localhost:5432/app' == IsUrl(postgres_dsn=True) assert 'postgres://user:pass@localhost:5432/app' != IsUrl(http_url=True) ``` """ try: from pydantic import ( AmqpDsn, AnyHttpUrl, AnyUrl, FileUrl, HttpUrl, PostgresDsn, RedisDsn, TypeAdapter, ValidationError, ) self.ValidationError = ValidationError except ImportError as e: # pragma: no cover raise ImportError('Pydantic V2 is not installed, run `pip install dirty-equals[pydantic]`') from e url_type_mappings = { AnyUrl: any_url, AnyHttpUrl: any_http_url, HttpUrl: http_url, FileUrl: file_url, PostgresDsn: postgres_dsn, AmqpDsn: ampqp_dsn, RedisDsn: redis_dsn, } url_types_sum = sum(url_type_mappings.values()) if url_types_sum == 0: url_type: Any = AnyUrl elif url_types_sum == 1: url_type = max(url_type_mappings, key=url_type_mappings.get) # type: ignore[arg-type] else: raise ValueError('You can only check against one Pydantic url type at a time') self.type_adapter = _build_type_adapter(TypeAdapter, url_type) for item in expected_attributes: if item not in _allowed_url_attribute_checks: raise TypeError( 'IsURL only checks these attributes: scheme, host, host_type, user, password, ' 'port, path, query, fragment' ) self.attribute_checks = expected_attributes super().__init__() def equals(self, other: Any) -> bool: try: other_url = self.type_adapter.validate_python(other) except self.ValidationError: raise ValueError('Invalid URL') # we now check that str() of the parsed URL equals its original value # so that invalid encodings fail # we remove trailing slashes since they're added by pydantic's URL parsing, but don't mean `other` is invalid other_url_str = str(other_url) if not other.endswith('/') and other_url_str.endswith('/'): other_url_str = other_url_str[:-1] equal = other_url_str == other if not self.attribute_checks: return equal for attribute, expected in self.attribute_checks.items(): if getattr(other_url, attribute) != expected: return False return equal HashTypes = Literal['md5', 'sha-1', 'sha-256'] class IsHash(DirtyEquals[str]): """ A class that checks if a value is a valid common hash type, using a simple length and allowed characters regex. """ def __init__(self, hash_type: HashTypes): """ Args: hash_type: The hash type to check. Must be specified. ```py title="IsHash" from dirty_equals import IsHash assert 'f1e069787ece74531d112559945c6871' == IsHash('md5') assert b'f1e069787ece74531d112559945c6871' == IsHash('md5') assert 'f1e069787ece74531d112559945c6871' != IsHash('sha-256') assert 'F1E069787ECE74531D112559945C6871' == IsHash('md5') assert '40bd001563085fc35165329ea1ff5c5ecbdbbeef' == IsHash('sha-1') assert 'a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3' == IsHash( 'sha-256' ) ``` """ allowed_hashes = HashTypes.__args__ # type: ignore[attr-defined] if hash_type not in allowed_hashes: raise ValueError(f"Hash type must be one of the following values: {', '.join(allowed_hashes)}") self.hash_type = hash_type super().__init__(hash_type) def equals(self, other: Any) -> bool: if isinstance(other, str): s = other elif isinstance(other, (bytes, bytearray)): s = other.decode() else: return False hash_type_regex_patterns = { 'md5': r'[a-fA-F\d]{32}', 'sha-1': r'[a-fA-F\d]{40}', 'sha-256': r'[a-fA-F\d]{64}', } return bool(re.fullmatch(hash_type_regex_patterns[self.hash_type], s)) IP = TypeVar('IP', IPv4Address, IPv4Network, IPv6Address, IPv6Network, Union[str, int, bytes]) class IsIP(DirtyEquals[IP]): """ A class that checks if a value is a valid IP address, optionally checking IP version, netmask. """ def __init__(self, *, version: Literal[None, 4, 6] = None, netmask: str | None = None): """ Args: version: The version of the IP to check, if omitted, versions 4 and 6 are both accepted. netmask: The netmask of the IP to check, if omitted, any netmask is accepted. Requires version. ```py title="IsIP" from ipaddress import IPv4Address, IPv4Network, IPv6Address from dirty_equals import IsIP assert '179.27.154.96' == IsIP assert '179.27.154.96' == IsIP(version=4) assert '2001:0db8:0a0b:12f0:0000:0000:0000:0001' == IsIP(version=6) assert IPv4Address('127.0.0.1') == IsIP assert IPv4Network('43.48.0.0/12') == IsIP assert IPv6Address('::eeff:ae3f:d473') == IsIP assert '54.43.53.219/10' == IsIP(version=4, netmask='255.192.0.0') assert '54.43.53.219/10' == IsIP(version=4, netmask=4290772992) assert '::ffff:aebf:d473/12' == IsIP(version=6, netmask='fff0::') assert 3232235521 == IsIP ``` """ self.version = version if netmask and not self.version: raise TypeError('To check the netmask you must specify the IP version') self.netmask = netmask super().__init__(version=version or Omit, netmask=netmask or Omit) def equals(self, other: Any) -> bool: if isinstance(other, (IPv4Network, IPv6Network)): ip = other elif isinstance(other, (str, bytes, int, IPv4Address, IPv6Address)): ip = ip_network(other, strict=False) else: return False if self.version: if self.netmask: version_check = self.version == ip.version address_format = {4: IPv4Address, 6: IPv6Address}[self.version] netmask_check = int(address_format(self.netmask)) == int(ip.netmask) return version_check and netmask_check elif self.version != ip.version: return False return True class IsDataclassType(DirtyEquals[Any]): """ Checks that an object is a dataclass type. Inherits from [`DirtyEquals`][dirty_equals.DirtyEquals]. ```py title="IsDataclassType" from dataclasses import dataclass from dirty_equals import IsDataclassType @dataclass class Foo: a: int b: int foo = Foo(1, 2) assert Foo == IsDataclassType assert foo != IsDataclassType ``` """ def equals(self, other: Any) -> bool: return is_dataclass(other) and isinstance(other, type) class IsDataclass(DirtyEquals[Any]): """ Checks that an object is an instance of a dataclass. Inherits from [`DirtyEquals`][dirty_equals.DirtyEquals] and it can be initialised with specific keyword arguments to check exactness of dataclass fields, by comparing the instance `__dict__` with [`IsDict`][dirty_equals.IsDict]. Moreover it is possible to check for strictness and partialness of the dataclass, by setting the `strict` and `partial` attributes using the `.settings(strict=..., partial=...)` method. Remark that passing no kwargs to `IsDataclass` initialization means fields are not checked, not that the dataclass is empty, namely `IsDataclass()` is the same as `IsDataclass`. ```py title="IsDataclass" from dataclasses import dataclass from dirty_equals import IsInt, IsDataclass @dataclass class Foo: a: int b: int c: str foo = Foo(1, 2, 'c') assert foo == IsDataclass assert foo == IsDataclass(a=IsInt, b=2, c='c') assert foo == IsDataclass(b=2, a=1).settings(partial=True) assert foo != IsDataclass(a=IsInt, b=2).settings(strict=True) assert foo == IsDataclass(a=IsInt, b=2).settings(strict=True, partial=True) assert foo != IsDataclass(b=2, a=1).settings(strict=True, partial=True) ``` """ def __init__(self, **fields: Any): """ Args: fields: key-value pairs of field-value to check for. """ self.strict = False self.partial = False self._post_init() super().__init__(**fields) def _post_init(self) -> None: pass def equals(self, other: Any) -> bool: if is_dataclass(other) and not isinstance(other, type): if self._repr_kwargs: return self._fields_check(other) else: return True else: return False def settings( self, *, strict: bool | None = None, partial: bool | None = None, ) -> IsDataclass: """Allows to customise the behaviour of `IsDataclass`, technically a new `IsDataclass` to allow chaining.""" new_cls = self.__class__(**self._repr_kwargs) new_cls.__dict__ = self.__dict__.copy() if strict is not None: new_cls.strict = strict if partial is not None: new_cls.partial = partial return new_cls def _fields_check(self, other: Any) -> bool: """ Checks exactness of fields using [`IsDict`][dirty_equals.IsDict] with given settings. Remark that if this method is called, then `other` is an instance of a dataclass, therefore we can call `dataclasses.asdict` to convert to a dict. """ return asdict(other) == IsDict(self._repr_kwargs).settings(strict=self.strict, partial=self.partial) class IsPartialDataclass(IsDataclass): """ Inherits from [`IsDataclass`][dirty_equals.IsDataclass] with `partial=True` by default. ```py title="IsPartialDataclass" from dataclasses import dataclass from dirty_equals import IsInt, IsPartialDataclass @dataclass class Foo: a: int b: int c: str = 'c' foo = Foo(1, 2, 'c') assert foo == IsPartialDataclass assert foo == IsPartialDataclass(a=1) assert foo == IsPartialDataclass(b=2, a=IsInt) assert foo != IsPartialDataclass(b=2, a=IsInt).settings(strict=True) assert Foo != IsPartialDataclass ``` """ def _post_init(self) -> None: self.partial = True class IsStrictDataclass(IsDataclass): """ Inherits from [`IsDataclass`][dirty_equals.IsDataclass] with `strict=True` by default. ```py title="IsStrictDataclass" from dataclasses import dataclass from dirty_equals import IsInt, IsStrictDataclass @dataclass class Foo: a: int b: int c: str = 'c' foo = Foo(1, 2, 'c') assert foo == IsStrictDataclass assert foo == IsStrictDataclass( a=IsInt, b=2, ).settings(partial=True) assert foo != IsStrictDataclass( a=IsInt, b=2, ).settings(partial=False) assert foo != IsStrictDataclass(b=2, a=IsInt, c='c') ``` """ def _post_init(self) -> None: self.strict = True class IsEnum(DirtyEquals[Enum]): """ Checks if an instance is an Enum. Inherits from [`DirtyEquals`][dirty_equals.DirtyEquals]. ```py title="IsEnum" from enum import Enum, auto from dirty_equals import IsEnum class ExampleEnum(Enum): a = auto() b = auto() a = ExampleEnum.a assert a == IsEnum assert a == IsEnum(ExampleEnum) ``` """ def __init__(self, enum_cls: type[Enum] = Enum): """ Args: enum_cls: Enum class to check against. """ self._enum_cls = enum_cls self._enum_values = {i.value for i in enum_cls} def equals(self, other: Any) -> bool: if isinstance(other, Enum): return isinstance(other, self._enum_cls) else: return other in self._enum_values dirty-equals-0.8.0/dirty_equals/_sequence.py000066400000000000000000000235321465673465500212220ustar00rootroot00000000000000from typing import TYPE_CHECKING, Any, Container, Dict, List, Optional, Sized, Tuple, Type, TypeVar, Union, overload from ._base import DirtyEquals from ._utils import Omit, plain_repr if TYPE_CHECKING: from typing import TypeAlias __all__ = 'HasLen', 'Contains', 'IsListOrTuple', 'IsList', 'IsTuple' T = TypeVar('T', List[Any], Tuple[Any, ...]) LengthType: 'TypeAlias' = 'Union[None, int, Tuple[int, Union[int, Any]]]' class HasLen(DirtyEquals[Sized]): """ Check that some has a given length, or length in a given range. """ @overload def __init__(self, length: int): ... @overload def __init__(self, min_length: int, max_length: Union[int, Any]): ... def __init__(self, min_length: int, max_length: Union[None, int, Any] = None): # type: ignore[misc] """ Args: min_length: Expected length if `max_length` is not given, else minimum length. max_length: Expected maximum length, use an ellipsis `...` to indicate that there's no maximum. ```py title="HasLen" from dirty_equals import HasLen assert [1, 2, 3] == HasLen(3) # (1)! assert '123' == HasLen(3, ...) # (2)! assert (1, 2, 3) == HasLen(3, 5) # (3)! assert (1, 2, 3) == HasLen(0, ...) # (4)! ``` 1. Length must be 3. 2. Length must be 3 or higher. 3. Length must be between 3 and 5 inclusive. 4. Length is required but can take any value. """ if max_length is None: self.length: LengthType = min_length super().__init__(self.length) else: self.length = (min_length, max_length) super().__init__(*_length_repr(self.length)) def equals(self, other: Any) -> bool: return _length_correct(self.length, other) class Contains(DirtyEquals[Container[Any]]): """ Check that an object contains one or more values. """ def __init__(self, contained_value: Any, *more_contained_values: Any): """ Args: contained_value: value that must be contained in the compared object. *more_contained_values: more values that must be contained in the compared object. ```py title="Contains" from dirty_equals import Contains assert [1, 2, 3] == Contains(1) assert [1, 2, 3] == Contains(1, 2) assert (1, 2, 3) == Contains(1) assert 'abc' == Contains('b') assert {'a': 1, 'b': 2} == Contains('a') assert [1, 2, 3] != Contains(10) ``` """ self.contained_values: Tuple[Any, ...] = (contained_value,) + more_contained_values super().__init__(*self.contained_values) def equals(self, other: Any) -> bool: return all(v in other for v in self.contained_values) class IsListOrTuple(DirtyEquals[T]): """ Check that some object is a list or tuple and optionally its values match some constraints. """ allowed_type: Union[Type[T], Tuple[Type[List[Any]], Type[Tuple[Any, ...]]]] = (list, tuple) @overload def __init__(self, *items: Any, check_order: bool = True, length: 'LengthType' = None): ... @overload def __init__(self, positions: Dict[int, Any], length: 'LengthType' = None): ... def __init__( self, *items: Any, positions: Optional[Dict[int, Any]] = None, check_order: bool = True, length: 'LengthType' = None, ): """ `IsListOrTuple` and its subclasses can be initialised in two ways: Args: *items: Positional members of an object to check. These must start from the zeroth position, but (depending on the value of `length`) may not include all values of the list/tuple being checked. check_order: Whether to enforce the order of the items. length (Union[int, Tuple[int, Union[int, Any]]]): length constraints, int or tuple matching the arguments of [`HasLen`][dirty_equals.HasLen]. or, Args: positions (Dict[int, Any]): Instead of `*items`, a dictionary of positions and values to check and be provided. length (Union[int, Tuple[int, Union[int, Any]]]): length constraints, int or tuple matching the arguments of [`HasLen`][dirty_equals.HasLen]. ```py title="IsListOrTuple" from dirty_equals import AnyThing, IsListOrTuple assert [1, 2, 3] == IsListOrTuple(1, 2, 3) assert (1, 3, 2) == IsListOrTuple(1, 2, 3, check_order=False) assert [{'a': 1}, {'a': 2}] == ( IsListOrTuple({'a': 2}, {'a': 1}, check_order=False) # (1)! ) assert [1, 2, 3, 3] != IsListOrTuple(1, 2, 3, check_order=False) # (2)! assert [1, 2, 3, 4, 5] == IsListOrTuple(1, 2, 3, length=...) # (3)! assert [1, 2, 3, 4, 5] != IsListOrTuple(1, 2, 3, length=(8, 10)) # (4)! assert ['a', 'b', 'c', 'd'] == (IsListOrTuple(positions={2: 'c', 3: 'd'})) # (5)! assert ['a', 'b', 'c', 'd'] == ( IsListOrTuple(positions={2: 'c', 3: 'd'}, length=4) # (6)! ) assert [1, 2, 3, 4] == IsListOrTuple(3, check_order=False, length=(0, ...)) # (7)! assert [1, 2, 3] == IsListOrTuple(AnyThing, AnyThing, 3) # (8)! ``` 1. Unlike using sets for comparison, we can do order-insensitive comparisons on objects that are not hashable. 2. And we won't get caught out by duplicate values 3. Here we're just checking the first 3 items, the compared list or tuple can be of any length 4. Compared list is not long enough 5. Compare using `positions`, here no length if enforced 6. Compare using `positions` but with a length constraint 7. Here we're just confirming that the value `3` is in the list 8. If you don't care about the first few values of a list or tuple, you can use [`AnyThing`][dirty_equals.AnyThing] in your arguments. """ if positions is not None: self.positions: Optional[Dict[int, Any]] = positions if items: raise TypeError(f'{self.__class__.__name__} requires either args or positions, not both') if not check_order: raise TypeError('check_order=False is not compatible with positions') else: self.positions = None self.items = items self.check_order = check_order self.length: Any = length if self.length is not None and not isinstance(self.length, int): if self.length == Ellipsis: self.length = 0, ... else: self.length = tuple(self.length) super().__init__( *items, positions=Omit if positions is None else positions, length=_length_repr(self.length), check_order=self.check_order and Omit, ) def equals(self, other: Any) -> bool: if not isinstance(other, self.allowed_type): return False if not _length_correct(self.length, other): return False if self.check_order: if self.positions is None: if self.length is None: return list(self.items) == list(other) else: return list(self.items) == list(other[: len(self.items)]) else: return all(v == other[k] for k, v in self.positions.items()) else: # order insensitive comparison # if we haven't checked length yet, check it now if self.length is None and len(other) != len(self.items): return False other_copy = list(other) for item in self.items: try: other_copy.remove(item) except ValueError: return False return True class IsList(IsListOrTuple[List[Any]]): """ All the same functionality as [`IsListOrTuple`][dirty_equals.IsListOrTuple], but the compared value must be a list. ```py title="IsList" from dirty_equals import IsList assert [1, 2, 3] == IsList(1, 2, 3) assert [1, 2, 3] == IsList(positions={2: 3}) assert [1, 2, 3] == IsList(1, 2, 3, check_order=False) assert [1, 2, 3, 4] == IsList(1, 2, 3, length=4) assert [1, 2, 3, 4] == IsList(1, 2, 3, length=(4, 5)) assert [1, 2, 3, 4] == IsList(1, 2, 3, length=...) assert (1, 2, 3) != IsList(1, 2, 3) ``` """ allowed_type = list class IsTuple(IsListOrTuple[Tuple[Any, ...]]): """ All the same functionality as [`IsListOrTuple`][dirty_equals.IsListOrTuple], but the compared value must be a tuple. ```py title="IsTuple" from dirty_equals import IsTuple assert (1, 2, 3) == IsTuple(1, 2, 3) assert (1, 2, 3) == IsTuple(positions={2: 3}) assert (1, 2, 3) == IsTuple(1, 2, 3, check_order=False) assert (1, 2, 3, 4) == IsTuple(1, 2, 3, length=4) assert (1, 2, 3, 4) == IsTuple(1, 2, 3, length=(4, 5)) assert (1, 2, 3, 4) == IsTuple(1, 2, 3, length=...) assert [1, 2, 3] != IsTuple(1, 2, 3) ``` """ allowed_type = tuple def _length_repr(length: 'LengthType') -> Any: if length is None: return Omit elif isinstance(length, int): return length else: if len(length) != 2: raise TypeError(f'length must be a tuple of length 2, not {len(length)}') max_value = length[1] if isinstance(length[1], int) else plain_repr('...') return length[0], max_value def _length_correct(length: 'LengthType', other: 'Sized') -> bool: if isinstance(length, int): if len(other) != length: return False elif isinstance(length, tuple): other_len = len(other) min_length, max_length = length if other_len < min_length: return False if isinstance(max_length, int) and other_len > max_length: return False return True dirty-equals-0.8.0/dirty_equals/_strings.py000066400000000000000000000117561465673465500211100ustar00rootroot00000000000000import re from typing import Any, Literal, Optional, Pattern, Tuple, Type, TypeVar, Union from ._base import DirtyEquals from ._utils import Omit, plain_repr T = TypeVar('T', str, bytes) __all__ = 'IsStr', 'IsBytes', 'IsAnyStr' class IsAnyStr(DirtyEquals[T]): """ Comparison of `str` or `bytes` objects. This class allow comparison with both `str` and `bytes` but is subclassed by [`IsStr`][dirty_equals.IsStr] and [`IsBytes`][dirty_equals.IsBytes] which restrict comparison to `str` or `bytes` respectively. """ expected_types: Tuple[Type[Any], ...] = (str, bytes) def __init__( self, *, min_length: Optional[int] = None, max_length: Optional[int] = None, case: Literal['upper', 'lower', None] = None, regex: Union[None, T, Pattern[T]] = None, regex_flags: int = 0, ): """ Args: min_length: minimum length of the string/bytes max_length: maximum length of the string/bytes case: check case of the string/bytes regex: regular expression to match the string/bytes with, `re.fullmatch` is used. This can be a compiled regex, or a string or bytes. regex_flags: optional flags for the regular expression Examples: ```py title="IsAnyStr" from dirty_equals import IsAnyStr assert 'foobar' == IsAnyStr() assert b'foobar' == IsAnyStr() assert 123 != IsAnyStr() assert 'foobar' == IsAnyStr(regex='foo...') assert 'foobar' == IsAnyStr(regex=b'foo...') # (1)! assert 'foobar' == IsAnyStr(min_length=6) assert 'foobar' != IsAnyStr(min_length=8) assert 'foobar' == IsAnyStr(case='lower') assert 'Foobar' != IsAnyStr(case='lower') ``` 1. `regex` can be either a string or bytes, `IsAnyStr` will take care of conversion so checks work. """ self.min_length = min_length self.max_length = max_length self.case = case self._flex = len(self.expected_types) > 1 if regex is None: self.regex: Union[None, T, Pattern[T]] = None self.regex_flags: int = 0 else: self.regex, self.regex_flags = self._prepare_regex(regex, regex_flags) super().__init__( min_length=Omit if min_length is None else min_length, max_length=Omit if max_length is None else max_length, case=case or Omit, regex=regex or Omit, regex_flags=Omit if regex_flags == 0 else plain_repr(repr(re.RegexFlag(regex_flags))), ) def equals(self, other: Any) -> bool: if type(other) not in self.expected_types: return False if self.regex is not None: if self._flex and isinstance(other, str): other = other.encode() if not re.fullmatch(self.regex, other, flags=self.regex_flags): return False len_ = len(other) if self.min_length is not None and len_ < self.min_length: return False if self.max_length is not None and len_ > self.max_length: return False if self.case == 'upper' and not other.isupper(): return False if self.case == 'lower' and not other.islower(): return False return True def _prepare_regex(self, regex: Union[T, Pattern[T]], regex_flags: int) -> Tuple[Union[T, Pattern[T]], int]: if isinstance(regex, re.Pattern): if self._flex: # less performant, but more flexible if regex_flags == 0 and regex.flags != re.UNICODE: regex_flags = regex.flags & ~re.UNICODE regex = regex.pattern elif regex_flags != 0: regex = regex.pattern if self._flex and isinstance(regex, str): regex = regex.encode() # type: ignore[assignment] return regex, regex_flags class IsStr(IsAnyStr[str]): """ Checks if the value is a string, and optionally meets some constraints. `IsStr` is a subclass of [`IsAnyStr`][dirty_equals.IsAnyStr] and therefore allows all the same arguments. Examples: ```py title="IsStr" from dirty_equals import IsStr assert 'foobar' == IsStr() assert b'foobar' != IsStr() assert 'foobar' == IsStr(regex='foo...') assert 'FOOBAR' == IsStr(min_length=5, max_length=10, case='upper') ``` """ expected_types = (str,) class IsBytes(IsAnyStr[bytes]): """ Checks if the value is a bytes object, and optionally meets some constraints. `IsBytes` is a subclass of [`IsAnyStr`][dirty_equals.IsAnyStr] and therefore allows all the same arguments. Examples: ```py title="IsBytes" from dirty_equals import IsBytes assert b'foobar' == IsBytes() assert 'foobar' != IsBytes() assert b'foobar' == IsBytes(regex=b'foo...') assert b'FOOBAR' == IsBytes(min_length=5, max_length=10, case='upper') ``` """ expected_types = (bytes,) dirty-equals-0.8.0/dirty_equals/_utils.py000066400000000000000000000021561465673465500205510ustar00rootroot00000000000000__all__ = 'plain_repr', 'PlainRepr', 'Omit', 'get_dict_arg' from typing import Any, Dict, Tuple class PlainRepr: """ Hack to allow repr of string without quotes. """ def __init__(self, v: str): self.v = v def __repr__(self) -> str: return self.v def plain_repr(v: str) -> PlainRepr: return PlainRepr(v) # used to omit arguments from repr Omit = object() def get_dict_arg( name: str, expected_args: Tuple[Dict[Any, Any], ...], expected_kwargs: Dict[str, Any] ) -> Dict[Any, Any]: """ Used to enforce init logic similar to `dict(...)`. """ if expected_kwargs: value = expected_kwargs if expected_args: raise TypeError(f'{name} requires either a single argument or kwargs, not both') elif not expected_args: value = {} elif len(expected_args) == 1: value = expected_args[0] if not isinstance(value, dict): raise TypeError(f'expected_values must be a dict, got {type(value)}') else: raise TypeError(f'{name} expected at most 1 argument, got {len(expected_args)}') return value dirty-equals-0.8.0/dirty_equals/py.typed000066400000000000000000000000001465673465500203610ustar00rootroot00000000000000dirty-equals-0.8.0/dirty_equals/version.py000066400000000000000000000000221465673465500207250ustar00rootroot00000000000000VERSION = '0.8.0' dirty-equals-0.8.0/docs/000077500000000000000000000000001465673465500151175ustar00rootroot00000000000000dirty-equals-0.8.0/docs/CNAME000066400000000000000000000000331465673465500156610ustar00rootroot00000000000000dirty-equals.helpmanual.io dirty-equals-0.8.0/docs/img/000077500000000000000000000000001465673465500156735ustar00rootroot00000000000000dirty-equals-0.8.0/docs/img/dirty-equals-logo-base.svg000066400000000000000000000071471465673465500227160ustar00rootroot00000000000000 dirty-equals dirty-equals-0.8.0/docs/img/dirty-equals-logo-favicon.svg000066400000000000000000000060311465673465500234200ustar00rootroot00000000000000 dirty-equals-0.8.0/docs/img/favicon.png000066400000000000000000000013731465673465500200320ustar00rootroot00000000000000‰PNG  IHDR szzô pHYsÃÃÇo¨dtEXtSoftwarewww.inkscape.org›î<ˆIDATX…å–»kQÆgæÎî&»³â*D"AˆITDƒ ¨Xù+Aƒ•vZØZiá¿F+"–"¤2Šbmcá ѨEDA³›5Ù™c‘çξftŒˆ_·w¾9¿ïÌœ½wà—d±ä3™‡…$ëF’ ›]#¯]#ÏH¸±¶ê…î¼ÍˆëÈ׈ºF4gs&êý“—ÊÛœÄ: èÐ ]kM ÊžžH4€k8,"wUé‹`/—<Í/fI @Þá ª<ÒQü¾§[+0ÕÉgE¨%ªr#|I» ï°Ž _¨ÀôopÓìÈÙÖ¨ªÜ‹ H»FÊ®‘ǮͩvƦ33\ä`~Þ„"·Kµàà…/5lKðëD›¨N[VW5Љ†lklÈÐxò†¤:¯WÍötð|\»Xò=ÎI#|•û@‡¦®¨žeµI'p(Rc´eAö6–’[%?¸€®‘}ÀÎå¥@­þpá÷ÜìñcÃWU7t”ÆÐã–W ¡¿sŸ$æ+@ÉCõkÁÛ°me³)öXÜÅÅ×Ó@ôò\ç+r†# ãݾ¬y -~÷˜,l±Œ¼6¶¹a–ø³!tL‡4£žî2¶±.)Ú®2Vòƒó1á°¸¿z[\/`¸h)z¬mé|¤¶Ð0Ó¾´7@¡­IÌCA"¯ËªBèé`Û$®‘GÀÑ8Å“’ÀCKDÇÿ|1ŽKd+޼@Ù¶®pe²äënë Ì¢'Ï눟R[‹À æóÕ ¸#b冈ÿýU³¨Œ¾ŽÌùLBó/¢T.Åv[“Ý}¡R®ò¨&Y÷ß×O9¶²f”«þÀIEND®B`‚dirty-equals-0.8.0/docs/img/logo-text.svg000066400000000000000000000171011465673465500203360ustar00rootroot00000000000000 dirty-equals-0.8.0/docs/img/logo-white.svg000066400000000000000000000024671465673465500205030ustar00rootroot00000000000000 dirty-equals-0.8.0/docs/index.md000066400000000000000000000102271465673465500165520ustar00rootroot00000000000000

dirty-equals

Doing dirty (but extremely useful) things with equals.

CI Coverage pypi versions license

--- {{ 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.md000066400000000000000000000021711465673465500174410ustar00rootroot00000000000000# 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.py000066400000000000000000000047361465673465500171640ustar00rootroot00000000000000import 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/000077500000000000000000000000001465673465500162635ustar00rootroot00000000000000dirty-equals-0.8.0/docs/types/boolean.md000066400000000000000000000001131465673465500202170ustar00rootroot00000000000000# Boolean Types ::: dirty_equals.IsTrueLike ::: dirty_equals.IsFalseLike dirty-equals-0.8.0/docs/types/custom.md000066400000000000000000000024131465673465500201170ustar00rootroot00000000000000# 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.md000066400000000000000000000035351465673465500204070ustar00rootroot00000000000000# 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.md000066400000000000000000000002121465673465500175230ustar00rootroot00000000000000# Dictionary Types ::: dirty_equals.IsDict ::: dirty_equals.IsPartialDict ::: dirty_equals.IsIgnoreDict ::: dirty_equals.IsStrictDict dirty-equals-0.8.0/docs/types/inspection.md000066400000000000000000000002031465673465500207530ustar00rootroot00000000000000# Type Inspection ::: dirty_equals.IsInstance ::: dirty_equals.HasName ::: dirty_equals.HasRepr ::: dirty_equals.HasAttributes dirty-equals-0.8.0/docs/types/numeric.md000066400000000000000000000024341465673465500202520ustar00rootroot00000000000000# 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.md000066400000000000000000000006341465673465500177310ustar00rootroot00000000000000# 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.md000066400000000000000000000002301465673465500204100ustar00rootroot00000000000000# 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.md000066400000000000000000000001341465673465500201110ustar00rootroot00000000000000# String Types ::: dirty_equals.IsAnyStr ::: dirty_equals.IsStr ::: dirty_equals.IsBytes dirty-equals-0.8.0/docs/usage.md000066400000000000000000000073541465673465500165560ustar00rootroot00000000000000## 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.yml000066400000000000000000000050321465673465500161720ustar00rootroot00000000000000site_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.toml000066400000000000000000000046171465673465500171130ustar00rootroot00000000000000[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/000077500000000000000000000000001465673465500167125ustar00rootroot00000000000000dirty-equals-0.8.0/requirements/all.txt000066400000000000000000000001011465673465500202130ustar00rootroot00000000000000-r ./docs.txt -r ./linting.txt -r ./tests.txt -r ./pyproject.txt dirty-equals-0.8.0/requirements/docs.in000066400000000000000000000001051465673465500201660ustar00rootroot00000000000000mike mkdocs mkdocs-material mkdocs-simple-hooks mkdocstrings[python] dirty-equals-0.8.0/requirements/docs.txt000066400000000000000000000052631465673465500204110ustar00rootroot00000000000000# # 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.in000066400000000000000000000000361465673465500207050ustar00rootroot00000000000000mypy pydantic ruff types-pytz dirty-equals-0.8.0/requirements/linting.txt000066400000000000000000000011331465673465500211150ustar00rootroot00000000000000# # 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.txt000066400000000000000000000012441465673465500214730ustar00rootroot00000000000000# # 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.in000066400000000000000000000001171465673465500204030ustar00rootroot00000000000000coverage[toml] packaging pytest pytest-mock pytest-pretty pytest-examples pytz dirty-equals-0.8.0/requirements/tests.txt000066400000000000000000000022671465673465500206240ustar00rootroot00000000000000# # 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/000077500000000000000000000000001465673465500153315ustar00rootroot00000000000000dirty-equals-0.8.0/tests/__init__.py000066400000000000000000000000001465673465500174300ustar00rootroot00000000000000dirty-equals-0.8.0/tests/mypy_checks.py000066400000000000000000000006641465673465500202270ustar00rootroot00000000000000""" 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.py000066400000000000000000000111231465673465500176520ustar00rootroot00000000000000import 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.py000066400000000000000000000045411465673465500203650ustar00rootroot00000000000000import 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.py000066400000000000000000000216771465673465500205530ustar00rootroot00000000000000from 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.py000066400000000000000000000116221465673465500176670ustar00rootroot00000000000000import 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 "): IsDict(1) def test_combine_partial_ignore(): d = IsPartialDict(a=2, b=2, c=3) with pytest.raises(TypeError, match='partial and ignore cannot be used together'): d.settings(ignore={1}) def ignore_42(value): return value == 42 def test_callable_ignore(): assert {'a': 1} == IsDict(a=1).settings(ignore=ignore_42) assert {'a': 1, 'b': 42} == IsDict(a=1).settings(ignore=ignore_42) assert {'a': 1, 'b': 43} != IsDict(a=1).settings(ignore=ignore_42) @pytest.mark.parametrize( 'd,expected_repr', [ (IsDict, 'IsDict'), (IsDict(), 'IsDict()'), (IsPartialDict, 'IsPartialDict'), (IsPartialDict(), 'IsPartialDict()'), (IsDict().settings(partial=True), 'IsDict[partial=True]()'), (IsIgnoreDict(), 'IsIgnoreDict()'), (IsIgnoreDict().settings(ignore={7}), 'IsIgnoreDict[ignore={7}]()'), (IsIgnoreDict().settings(ignore={None}), 'IsIgnoreDict()'), (IsIgnoreDict().settings(ignore=None), 'IsIgnoreDict[ignore=None]()'), (IsDict().settings(ignore=ignore_42), 'IsDict[ignore=ignore_42]()'), (IsDict().settings(ignore={7}), 'IsDict[ignore={7}]()'), (IsDict().settings(ignore={None}), 'IsDict[ignore={None}]()'), (IsPartialDict().settings(partial=False), 'IsPartialDict[partial=False]()'), (IsStrictDict, 'IsStrictDict'), (IsStrictDict(), 'IsStrictDict()'), (IsDict().settings(strict=True), 'IsDict[strict=True]()'), (IsStrictDict().settings(strict=False), 'IsStrictDict[strict=False]()'), ], ) def test_not_equals_repr(d, expected_repr): assert repr(d) == expected_repr def test_ignore(): def custom_ignore(v: int) -> bool: return v % 2 == 0 assert {'a': 1, 'b': 2, 'c': 3, 'd': 4} == IsDict(a=1, c=3).settings(ignore=custom_ignore) def test_ignore_with_is_str(): api_data = {'id': 123, 'token': 't-abc123', 'dob': None, 'street_address': None} token_is_str = IsStr(regex=r't\-.+') assert api_data == IsIgnoreDict(id=IsPositiveInt, token=token_is_str) assert token_is_str.value == 't-abc123' def test_unhashable_value(): a = {'a': 1} api_data = {'b': a, 'c': None} assert api_data == IsIgnoreDict(b=a) dirty-equals-0.8.0/tests/test_docs.py000066400000000000000000000027431465673465500177000ustar00rootroot00000000000000import platform import sys from pathlib import Path import pytest from pytest_examples import CodeExample, EvalExample, find_examples root_dir = Path(__file__).parent.parent examples = find_examples( root_dir / 'dirty_equals', root_dir / 'docs', ) @pytest.mark.skipif(platform.python_implementation() == 'PyPy', reason='PyPy does not allow metaclass dunder methods') @pytest.mark.skipif(sys.version_info >= (3, 12), reason="pytest-examples doesn't yet support 3.12") @pytest.mark.parametrize('example', examples, ids=str) def test_docstrings(example: CodeExample, eval_example: EvalExample): prefix_settings = example.prefix_settings() # E711 and E712 refer to `== True` and `== None` and need to be ignored # I001 refers is a problem with black and ruff disagreeing about blank lines :shrug: eval_example.set_config(ruff_ignore=['E711', 'E712', 'I001']) requires = prefix_settings.get('requires') if requires: requires_version = tuple(int(v) for v in requires.split('.')) if sys.version_info < requires_version: pytest.skip(f'requires python {requires}') if prefix_settings.get('test') != 'skip': if eval_example.update_examples: eval_example.run_print_update(example) else: eval_example.run_print_check(example) if prefix_settings.get('lint') != 'skip': if eval_example.update_examples: eval_example.format(example) else: eval_example.lint(example) dirty-equals-0.8.0/tests/test_inspection.py000066400000000000000000000054371465673465500211260ustar00rootroot00000000000000import platform import sys import pytest from dirty_equals import AnyThing, HasAttributes, HasName, HasRepr, IsInstance, IsInt, IsStr class Foo: def __init__(self, a=1, b=2): self.a = a self.b = b def spam(self): pass def dirty_repr(value): if hasattr(value, 'equals'): return repr(value) return '' def test_is_instance_of(): assert Foo() == IsInstance(Foo) assert Foo() == IsInstance[Foo] assert 1 != IsInstance[Foo] class Bar(Foo): def __repr__(self): return f'Bar(a={self.a}, b={self.b})' def test_is_instance_of_inherit(): assert Bar() == IsInstance(Foo) assert Foo() == IsInstance(Foo, only_direct_instance=True) assert Bar() != IsInstance(Foo, only_direct_instance=True) assert Foo != IsInstance(Foo) assert Bar != IsInstance(Foo) assert type != IsInstance(Foo) def test_is_instance_of_repr(): assert repr(IsInstance) == 'IsInstance' assert repr(IsInstance(Foo)) == "IsInstance()" def even(x): return x % 2 == 0 @pytest.mark.parametrize( 'value,dirty', [ (Foo, HasName('Foo')), (Foo, HasName['Foo']), (Foo(), HasName('Foo')), (Foo(), ~HasName('Foo', allow_instances=False)), (Bar, ~HasName('Foo')), (int, HasName('int')), (42, HasName('int')), (even, HasName('even')), (Foo().spam, HasName('spam')), (Foo.spam, HasName('spam')), (Foo, HasName(IsStr(regex='F..'))), (Bar, ~HasName(IsStr(regex='F..'))), ], ids=dirty_repr, ) def test_has_name(value, dirty): assert value == dirty pypy38 = pytest.mark.skipif( platform.python_implementation() == 'PyPy' and sys.version_info[:2] == (3, 8), reason='pypy3.8 fails with this specific case 🤷', ) @pytest.mark.parametrize( 'value,dirty', [ (Foo(1, 2), HasAttributes(a=1, b=2)), (Foo(1, 's'), HasAttributes(a=IsInt(), b=IsStr())), (Foo(1, 2), ~HasAttributes(a=IsInt(), b=IsStr())), (Foo(1, 2), ~HasAttributes(a=1, b=2, c=3)), pytest.param(Foo(1, 2), HasAttributes(a=1, b=2, spam=AnyThing), marks=pypy38), (Foo(1, 2), ~HasAttributes(a=1, b=2, missing=AnyThing)), ], ids=dirty_repr, ) def test_has_attributes(value, dirty): assert value == dirty @pytest.mark.parametrize( 'value,dirty', [ (Bar(1, 2), HasRepr('Bar(a=1, b=2)')), (Bar(1, 2), HasRepr['Bar(a=1, b=2)']), (4, ~HasRepr('Bar(a=1, b=2)')), (Foo(), HasRepr(IsStr(regex=r''))), (Foo, HasRepr("")), (42, HasRepr('42')), (43, ~HasRepr('42')), ], ids=dirty_repr, ) def test_has_repr(value, dirty): assert value == dirty dirty-equals-0.8.0/tests/test_list_tuple.py000066400000000000000000000104431465673465500211300ustar00rootroot00000000000000import pytest from dirty_equals import AnyThing, Contains, HasLen, IsInt, IsList, IsListOrTuple, IsNegative, IsTuple @pytest.mark.parametrize( 'other,dirty', [ ([], IsList), ((), IsTuple), ([], IsList()), ([1], IsList(length=1)), ((), IsTuple()), ([1, 2, 3], IsList(1, 2, 3)), ((1, 2, 3), IsTuple(1, 2, 3)), ((1, 2, 3), IsListOrTuple(1, 2, 3)), ([1, 2, 3], IsListOrTuple(1, 2, 3)), ([1, 2, 3, 4, 5], IsList(1, 2, 3, length=5)), ([1, 2, 3, 4, 5], IsList(1, 2, 3, length=(4, 6))), ([1, 2, 3, 4, 5], IsList(1, 2, 3, length=[4, 6])), ([1, 2, 3, 4, 5], IsList(1, 2, 3, length=(4, ...))), ([3, 2, 1], IsList(1, 2, 3, check_order=False)), ([{1: 2}, 7], IsList(7, {1: 2}, check_order=False)), ([1, 2, 3, 4], IsList(positions={0: 1, 2: 3, -1: 4})), ([1, 2, 3], IsList(AnyThing, 2, 3)), ([1, 2, 3], IsList(1, 2, IsInt)), ([3, 2, 1], IsList(1, 2, IsInt, check_order=False)), ([1, 2, 2], IsList(2, 2, 1, check_order=False)), ([], HasLen(0)), ([1, 2, 3], HasLen(3)), ('123', HasLen(3)), (b'123', HasLen(3)), ({'a': 1, 'b': 2, 'c': 3}, HasLen(3)), ([1, 2], HasLen(1, 2)), ([1, 2], HasLen(2, 3)), ([1, 2, 3], HasLen(2, ...)), ([1, 2, 3], HasLen(0, ...)), ([1, 2, 3], Contains(1)), ([1, 2, 3], Contains(1, 2)), ((1, 2, 3), Contains(1)), ({1, 2, 3}, Contains(1)), ('abc', Contains('b')), ({'a': 1, 'b': 2}, Contains('a')), ([{'a': 1}, {'b': 2}], Contains({'a': 1})), ], ) def test_dirty_equals(other, dirty): assert other == dirty @pytest.mark.parametrize( 'other,dirty', [ ([], IsTuple), ((), IsList), ([1], IsList), ([1, 2, 3], IsTuple(1, 2, 3)), ((1, 2, 3), IsList(1, 2, 3)), ([1, 2, 3, 4], IsList(1, 2, 3)), ([1, 2, 3, 4, 5], IsList(1, 2, 3, length=6)), ([1, 2, 3, 4, 5], IsList(1, 2, 3, length=(6, 8))), ([1, 2, 3, 4, 5], IsList(1, 2, 3, length=(0, 2))), ([1, 2, 3, 4, 5], IsList(1, 2, 3, length=[1, 2])), ([1, 2, 3, 4, 5], IsList(1, 2, 3, length=(6, ...))), ([3, 2, 1, 4], IsList(1, 2, 3, check_order=False)), ([1, 2, 3, 4], IsList(positions={0: 1, 2: 3, -1: 5})), ([1, 2, 3], IsList(1, 2, IsNegative)), ([1, 2, 2], IsList(1, 2, 3, check_order=False)), ([1, 2, 3], IsList(1, 2, 2, check_order=False)), ([1], HasLen(0)), ([], HasLen(1)), ('abc', HasLen(2)), ([1, 2, 3], HasLen(1, 2)), ([1], HasLen(2, 3)), ([1], HasLen(2, ...)), (123, HasLen(0, ...)), ([1, 2, 3], Contains(10)), ([1, 2, 3], Contains(1, 'a')), ([1, 2, 3], Contains(1, 'a')), ([{'a': 1}, {'b': 2}], Contains({'a': 2})), ({1, 2, 3}, Contains({1: 2})), ], ) def test_dirty_not_equals(other, dirty): assert other != dirty def test_args_and_positions(): with pytest.raises(TypeError, match='IsList requires either args or positions, not both'): IsList(1, 2, positions={0: 1}) def test_positions_with_check_order(): with pytest.raises(TypeError, match='check_order=False is not compatible with positions'): IsList(check_order=False, positions={0: 1}) def test_wrong_length_length(): with pytest.raises(TypeError, match='length must be a tuple of length 2, not 3'): IsList(1, 2, length=(1, 2, 3)) @pytest.mark.parametrize( 'dirty,repr_str', [ (IsList, 'IsList'), (IsTuple(1, 2, 3), 'IsTuple(1, 2, 3)'), (IsList(positions={1: 10, 2: 20}), 'IsList(positions={1: 10, 2: 20})'), (IsTuple(1, 2, 3, length=4), 'IsTuple(1, 2, 3, length=4)'), (IsTuple(1, 2, 3, length=(6, ...)), 'IsTuple(1, 2, 3, length=(6, ...))'), (IsTuple(1, 2, 3, length=(6, 'x')), 'IsTuple(1, 2, 3, length=(6, ...))'), (IsTuple(1, 2, 3, length=(6, 10)), 'IsTuple(1, 2, 3, length=(6, 10))'), (IsTuple(1, 2, 3, check_order=False), 'IsTuple(1, 2, 3, check_order=False)'), (HasLen(42), 'HasLen(42)'), (HasLen(0, ...), 'HasLen(0, ...)'), ], ) def test_repr(dirty, repr_str): assert repr(dirty) == repr_str def test_no_contains_value(): with pytest.raises(TypeError): Contains() dirty-equals-0.8.0/tests/test_numeric.py000066400000000000000000000072161465673465500204120ustar00rootroot00000000000000import pytest from dirty_equals import ( IsApprox, IsFloat, IsFloatInf, IsFloatInfNeg, IsFloatInfPos, IsFloatNan, IsInt, IsNegative, IsNegativeFloat, IsNegativeInt, IsNonNegative, IsNonPositive, IsPositive, IsPositiveFloat, IsPositiveInt, ) @pytest.mark.parametrize( 'other,dirty', [ (1, IsInt), (1, IsInt()), (1, IsInt(exactly=1)), (1, IsPositiveInt), (-1, IsNegativeInt), (-1.0, IsFloat), (-1.0, IsFloat(exactly=-1.0)), (1.0, IsPositiveFloat), (-1.0, IsNegativeFloat), (1, IsPositive), (1.0, IsPositive), (-1, IsNegative), (-1.0, IsNegative), (5, IsInt(gt=4)), (5, IsInt(ge=5)), (5, IsInt(lt=6)), (5, IsInt(le=5)), (1, IsApprox(1)), (1, IsApprox(2, delta=1)), (100, IsApprox(99)), (-100, IsApprox(-99)), (0, IsNonNegative), (1, IsNonNegative), (0.0, IsNonNegative), (1.0, IsNonNegative), (0, IsNonPositive), (-1, IsNonPositive), (0.0, IsNonPositive), (-1.0, IsNonPositive), (-1, IsNonPositive & IsInt), (1, IsNonNegative & IsInt), (float('inf'), IsFloatInf), (-float('inf'), IsFloatInf), (float('-inf'), IsFloatInf), (float('inf'), IsFloatInfPos), (-float('-inf'), IsFloatInfPos), (-float('inf'), IsFloatInfNeg), (float('-inf'), IsFloatInfNeg), (float('nan'), IsFloatNan), (-float('nan'), IsFloatNan), (float('-nan'), IsFloatNan), ], ) def test_dirty_equals(other, dirty): assert other == dirty @pytest.mark.parametrize( 'other,dirty', [ (1.0, IsInt), (1.2, IsInt), (1, IsInt(exactly=2)), (True, IsInt), (False, IsInt), (1.0, IsInt()), (-1, IsPositiveInt), (0, IsPositiveInt), (1, IsNegativeInt), (0, IsNegativeInt), (1, IsFloat), (1, IsFloat(exactly=1.0)), (1.1234, IsFloat(exactly=1.0)), (-1.0, IsPositiveFloat), (0.0, IsPositiveFloat), (1.0, IsNegativeFloat), (0.0, IsNegativeFloat), (-1, IsPositive), (-1.0, IsPositive), (4, IsInt(gt=4)), (4, IsInt(ge=5)), (6, IsInt(lt=6)), (6, IsInt(le=5)), (-1, IsNonNegative), (-1.0, IsNonNegative), (1, IsNonPositive), (1.0, IsNonPositive), (-1.0, IsNonPositive & IsInt), (1.0, IsNonNegative & IsInt), (1, IsFloatNan), (1.0, IsFloatNan), (1, IsFloatInf), (1.0, IsFloatInf), (-float('inf'), IsFloatInfPos), (float('-inf'), IsFloatInfPos), (-float('-inf'), IsFloatInfNeg), (-float('-inf'), IsFloatInfNeg), ], ids=repr, ) def test_dirty_not_equals(other, dirty): assert other != dirty def test_invalid_approx_gt(): with pytest.raises(TypeError, match='"approx" cannot be combined with "gt", "lt", "ge", or "le"'): IsInt(approx=1, gt=1) def test_invalid_exactly_approx(): with pytest.raises(TypeError, match='"exactly" cannot be combined with "approx"'): IsInt(exactly=1, approx=1) def test_invalid_exactly_gt(): with pytest.raises(TypeError, match='"exactly" cannot be combined with "gt", "lt", "ge", or "le"'): IsInt(exactly=1, gt=1) def test_not_int(): d = IsInt() with pytest.raises(AssertionError): assert '1' == d assert repr(d) == 'IsInt()' def test_not_negative(): d = IsNegativeInt with pytest.raises(AssertionError): assert 1 == d assert repr(d) == 'IsNegativeInt' dirty-equals-0.8.0/tests/test_other.py000066400000000000000000000247261465673465500200760ustar00rootroot00000000000000import uuid from dataclasses import dataclass from enum import Enum, auto from hashlib import md5, sha1, sha256 from ipaddress import IPv4Address, IPv4Network, IPv6Address, IPv6Network import pytest from dirty_equals import ( FunctionCheck, IsDataclass, IsDataclassType, IsEnum, IsHash, IsInt, IsIP, IsJson, IsPartialDataclass, IsStr, IsStrictDataclass, IsUrl, IsUUID, ) class FooEnum(Enum): a = auto() b = auto() c = 'c' @dataclass class Foo: a: int b: int c: str foo = Foo(1, 2, 'c') @pytest.mark.parametrize( 'other,dirty', [ (uuid.uuid4(), IsUUID()), (uuid.uuid4(), IsUUID), (uuid.uuid4(), IsUUID(4)), ('edf9f29e-45c7-431c-99db-28ea44df9785', IsUUID), ('edf9f29e-45c7-431c-99db-28ea44df9785', IsUUID(4)), ('edf9f29e45c7431c99db28ea44df9785', IsUUID(4)), (uuid.uuid3(uuid.UUID('edf9f29e-45c7-431c-99db-28ea44df9785'), 'abc'), IsUUID), (uuid.uuid3(uuid.UUID('edf9f29e-45c7-431c-99db-28ea44df9785'), 'abc'), IsUUID(3)), (uuid.uuid1(), IsUUID(1)), (str(uuid.uuid1()), IsUUID(1)), ('ea9e828d-fd18-3898-99f3-5a46dbcee036', IsUUID(3)), ], ) def test_is_uuid_true(other, dirty): assert other == dirty @pytest.mark.parametrize( 'other,dirty', [ ('foobar', IsUUID()), ([1, 2, 3], IsUUID()), ('edf9f29e-45c7-431c-99db-28ea44df9785', IsUUID(5)), (uuid.uuid3(uuid.UUID('edf9f29e-45c7-431c-99db-28ea44df9785'), 'abc'), IsUUID(4)), (uuid.uuid1(), IsUUID(4)), ('edf9f29e-45c7-431c-99db-28ea44df9785', IsUUID(1)), ('ea9e828d-fd18-3898-99f3-5a46dbcee036', IsUUID(4)), ], ) def test_is_uuid_false(other, dirty): assert other != dirty def test_is_uuid_false_repr(): is_uuid = IsUUID() with pytest.raises(AssertionError): assert '123' == is_uuid assert str(is_uuid) == 'IsUUID(*)' def test_is_uuid4_false_repr(): is_uuid = IsUUID(4) with pytest.raises(AssertionError): assert '123' == is_uuid assert str(is_uuid) == 'IsUUID(4)' @pytest.mark.parametrize('json_value', ['null', '"xyz"', '[1, 2, 3]', '{"a": 1}']) def test_is_json_any_true(json_value): assert json_value == IsJson() assert json_value == IsJson def test_is_json_any_false(): is_json = IsJson() with pytest.raises(AssertionError): assert 'foobar' == is_json assert str(is_json) == 'IsJson(*)' @pytest.mark.parametrize( 'json_value,expected_value', [ ('null', None), ('"xyz"', 'xyz'), ('[1, 2, 3]', [1, 2, 3]), ('{"a": 1}', {'a': 1}), ], ) def test_is_json_specific_true(json_value, expected_value): assert json_value == IsJson(expected_value) assert json_value == IsJson[expected_value] def test_is_json_invalid(): assert 'invalid json' != IsJson assert 123 != IsJson assert [1, 2] != IsJson def test_is_json_kwargs(): assert '{"a": 1, "b": 2}' == IsJson(a=1, b=2) assert '{"a": 1, "b": 3}' != IsJson(a=1, b=2) def test_is_json_specific_false(): is_json = IsJson([1, 2, 3]) with pytest.raises(AssertionError): assert '{"a": 1}' == is_json assert str(is_json) == 'IsJson([1, 2, 3])' def test_equals_function(): func_argument = None def foo(v): nonlocal func_argument func_argument = v return v % 2 == 0 assert 4 == FunctionCheck(foo) assert func_argument == 4 assert 5 != FunctionCheck(foo) def test_equals_function_fail(): def foobar(v): return False c = FunctionCheck(foobar) with pytest.raises(AssertionError): assert 4 == c assert str(c) == 'FunctionCheck(foobar)' def test_json_both(): with pytest.raises(TypeError, match='IsJson requires either an argument or kwargs, not both'): IsJson(1, a=2) @pytest.mark.parametrize( 'other,dirty', [ (IPv4Address('127.0.0.1'), IsIP()), (IPv4Network('43.48.0.0/12'), IsIP()), (IPv6Address('::eeff:ae3f:d473'), IsIP()), (IPv6Network('::eeff:ae3f:d473/128'), IsIP()), ('2001:0db8:0a0b:12f0:0000:0000:0000:0001', IsIP()), ('179.27.154.96', IsIP), ('43.62.123.119', IsIP(version=4)), ('::ffff:2b3e:7b77', IsIP(version=6)), ('0:0:0:0:0:ffff:2b3e:7b77', IsIP(version=6)), ('54.43.53.219/10', IsIP(version=4, netmask='255.192.0.0')), ('::ffff:aebf:d473/12', IsIP(version=6, netmask='fff0::')), ('2001:0db8:0a0b:12f0:0000:0000:0000:0001', IsIP(version=6)), (3232235521, IsIP()), (b'\xc0\xa8\x00\x01', IsIP()), (338288524927261089654018896845572831328, IsIP(version=6)), (b'\x20\x01\x06\x58\x02\x2a\xca\xfe\x02\x00\x00\x00\x00\x00\x00\x01', IsIP(version=6)), ], ) def test_is_ip_true(other, dirty): assert other == dirty @pytest.mark.parametrize( 'other,dirty', [ ('foobar', IsIP()), ([1, 2, 3], IsIP()), ('210.115.28.193', IsIP(version=6)), ('::ffff:d273:1cc1', IsIP(version=4)), ('210.115.28.193/12', IsIP(version=6, netmask='255.255.255.0')), ('::ffff:d273:1cc1', IsIP(version=6, netmask='fff0::')), (3232235521, IsIP(version=6)), (338288524927261089654018896845572831328, IsIP(version=4)), ], ) def test_is_ip_false(other, dirty): assert other != dirty def test_not_ip_repr(): is_ip = IsIP() with pytest.raises(AssertionError): assert '123' == is_ip assert str(is_ip) == 'IsIP()' def test_ip_bad_netmask(): with pytest.raises(TypeError, match='To check the netmask you must specify the IP version'): IsIP(netmask='255.255.255.0') @pytest.mark.parametrize( 'other,dirty', [ ('f1e069787ECE74531d112559945c6871', IsHash('md5')), ('40bd001563085fc35165329ea1FF5c5ecbdbbeef', IsHash('sha-1')), ('a665a45920422f9d417e4867eFDC4fb8a04a1f3fff1fa07e998e86f7f7a27ae3', IsHash('sha-256')), (b'f1e069787ECE74531d112559945c6871', IsHash('md5')), (bytearray(b'f1e069787ECE74531d112559945c6871'), IsHash('md5')), ], ) def test_is_hash_true(other, dirty): assert other == dirty @pytest.mark.parametrize( 'other,dirty', [ ('foobar', IsHash('md5')), (b'\x81 UnicodeDecodeError', IsHash('md5')), ([1, 2, 3], IsHash('sha-1')), ('f1e069787ECE74531d112559945c6871d', IsHash('md5')), ('400bd001563085fc35165329ea1FF5c5ecbdbbeef', IsHash('sha-1')), ('a665a45920422g9d417e4867eFDC4fb8a04a1f3fff1fa07e998e86f7f7a27ae3', IsHash('sha-256')), ], ) def test_is_hash_false(other, dirty): assert other != dirty @pytest.mark.parametrize( 'hash_type', ['md5', 'sha-1', 'sha-256'], ) def test_is_hash_md5_false_repr(hash_type): is_hash = IsHash(hash_type) with pytest.raises(AssertionError): assert '123' == is_hash assert str(is_hash) == f"IsHash('{hash_type}')" @pytest.mark.parametrize( 'hash_func, hash_type', [(md5, 'md5'), (sha1, 'sha-1'), (sha256, 'sha-256')], ) def test_hashlib_hashes(hash_func, hash_type): assert hash_func(b'dirty equals').hexdigest() == IsHash(hash_type) def test_wrong_hash_type(): with pytest.raises(ValueError, match='Hash type must be one of the following values: md5, sha-1, sha-256'): assert '123' == IsHash('ntlm') @pytest.mark.parametrize( 'other,dirty', [ ('https://example.com', IsUrl), ('https://example.com', IsUrl(scheme='https')), ('postgres://user:pass@localhost:5432/app', IsUrl(postgres_dsn=True)), ], ) def test_is_url_true(other, dirty): assert other == dirty @pytest.mark.parametrize( 'other,dirty', [ ('https://example.com', IsUrl(postgres_dsn=True)), ('https://example.com', IsUrl(scheme='http')), ('definitely not a url', IsUrl), (42, IsUrl), ('https://anotherexample.com', IsUrl(postgres_dsn=True)), ], ) def test_is_url_false(other, dirty): assert other != dirty def test_is_url_invalid_kwargs(): with pytest.raises( TypeError, match='IsURL only checks these attributes: scheme, host, host_type, user, password, port, path, query, ' 'fragment', ): IsUrl(https=True) def test_is_url_too_many_url_types(): with pytest.raises( ValueError, match='You can only check against one Pydantic url type at a time', ): assert 'https://example.com' == IsUrl(any_url=True, http_url=True, postgres_dsn=True) @pytest.mark.parametrize( 'other,dirty', [ (Foo, IsDataclassType), (Foo, IsDataclassType()), ], ) def test_is_dataclass_type_true(other, dirty): assert other == dirty @pytest.mark.parametrize( 'other,dirty', [ (foo, IsDataclassType), (foo, IsDataclassType()), (Foo, IsDataclass), ], ) def test_is_dataclass_type_false(other, dirty): assert other != dirty @pytest.mark.parametrize( 'other,dirty', [ (foo, IsDataclass), (foo, IsDataclass()), (foo, IsDataclass(a=IsInt, b=2, c=IsStr)), (foo, IsDataclass(a=1, c='c', b=2).settings(strict=False, partial=False)), (foo, IsDataclass(a=1, b=2, c='c').settings(strict=True, partial=False)), (foo, IsStrictDataclass(a=1, b=2, c='c')), (foo, IsDataclass(c='c', a=1).settings(strict=False, partial=True)), (foo, IsPartialDataclass(c='c', a=1)), (foo, IsDataclass(b=2, c='c').settings(strict=True, partial=True)), (foo, IsStrictDataclass(b=2, c='c').settings(partial=True)), (foo, IsPartialDataclass(b=2, c='c').settings(strict=True)), ], ) def test_is_dataclass_true(other, dirty): assert other == dirty @pytest.mark.parametrize( 'other,dirty', [ (foo, IsDataclassType), (Foo, IsDataclass), (foo, IsDataclass(a=1)), (foo, IsDataclass(a=IsStr, b=IsInt, c=IsStr)), (foo, IsDataclass(b=2, a=1, c='c').settings(strict=True)), (foo, IsStrictDataclass(b=2, a=1, c='c')), (foo, IsDataclass(b=2, a=1).settings(partial=False)), ], ) def test_is_dataclass_false(other, dirty): assert other != dirty @pytest.mark.parametrize( 'other,dirty', [ (FooEnum.a, IsEnum), (FooEnum.b, IsEnum(FooEnum)), (2, IsEnum(FooEnum)), ('c', IsEnum(FooEnum)), ], ) def test_is_enum_true(other, dirty): assert other == dirty @pytest.mark.parametrize( 'other,dirty', [ (FooEnum, IsEnum), (FooEnum, IsEnum(FooEnum)), (4, IsEnum(FooEnum)), ], ) def test_is_enum_false(other, dirty): assert other != dirty dirty-equals-0.8.0/tests/test_strings.py000066400000000000000000000102341465673465500204330ustar00rootroot00000000000000import re import pytest from dirty_equals import IsAnyStr, IsBytes, IsStr @pytest.mark.parametrize( 'value,dirty,match', [ # IsStr tests ('foo', IsStr, True), ('foo', IsStr(), True), (b'foo', IsStr, False), ('foo', IsStr(regex='fo{2}'), True), ('foo', IsStr(regex=b'fo{2}'), False), ('foo', IsStr(regex=re.compile('fo{2}')), True), ('Foo', IsStr(regex=re.compile('fo{2}', flags=re.I)), True), ('Foo', IsStr(regex=re.compile('fo{2}'), regex_flags=re.I), True), ('foo', IsStr(regex='fo'), False), ('foo', IsStr(regex='foo', max_length=2), False), ('foo\nbar', IsStr(regex='fo.*', regex_flags=re.S), True), ('foo\nbar', IsStr(regex='fo.*'), False), ('foo', IsStr(min_length=3), True), ('fo', IsStr(min_length=3), False), ('foo', IsStr(max_length=3), True), ('foobar', IsStr(max_length=3), False), ('foo', IsStr(case='lower'), True), ('FOO', IsStr(case='lower'), False), ('FOO', IsStr(case='upper'), True), ('foo', IsStr(case='upper'), False), # IsBytes tests (b'foo', IsBytes, True), (b'foo', IsBytes(), True), ('foo', IsBytes, False), (b'foo', IsBytes(regex=b'fo{2}'), True), (b'Foo', IsBytes(regex=re.compile(b'fo{2}', flags=re.I)), True), (b'Foo', IsBytes(regex=re.compile(b'fo{2}'), regex_flags=re.I), True), (b'foo', IsBytes(regex=b'fo'), False), (b'foo', IsBytes(regex=b'foo', max_length=2), False), (b'foo\nbar', IsBytes(regex=b'fo.*', regex_flags=re.S), True), (b'foo\nbar', IsBytes(regex=b'fo.*'), False), (b'foo', IsBytes(min_length=3), True), (b'fo', IsBytes(min_length=3), False), # IsAnyStr tests (b'foo', IsAnyStr, True), (b'foo', IsAnyStr(), True), ('foo', IsAnyStr, True), (b'foo', IsAnyStr(regex=b'fo{2}'), True), ('foo', IsAnyStr(regex=b'fo{2}'), True), (b'foo', IsAnyStr(regex='fo{2}'), True), ('foo', IsAnyStr(regex='fo{2}'), True), (b'Foo', IsAnyStr(regex=re.compile(b'fo{2}', flags=re.I)), True), ('Foo', IsAnyStr(regex=re.compile(b'fo{2}', flags=re.I)), True), (b'Foo', IsAnyStr(regex=re.compile(b'fo{2}'), regex_flags=re.I), True), ('Foo', IsAnyStr(regex=re.compile(b'fo{2}'), regex_flags=re.I), True), (b'Foo', IsAnyStr(regex=re.compile('fo{2}', flags=re.I)), True), ('Foo', IsAnyStr(regex=re.compile('fo{2}', flags=re.I)), True), (b'Foo', IsAnyStr(regex=re.compile('fo{2}'), regex_flags=re.I), True), ('Foo', IsAnyStr(regex=re.compile('fo{2}'), regex_flags=re.I), True), (b'foo\nbar', IsAnyStr(regex=b'fo.*', regex_flags=re.S), True), (b'foo\nbar', IsAnyStr(regex=b'fo.*'), False), ('foo', IsAnyStr(regex=b'foo', max_length=2), False), (b'foo', IsAnyStr(regex=b'foo', max_length=2), False), (b'foo', IsAnyStr(min_length=3), True), ('foo', IsAnyStr(min_length=3), True), (b'fo', IsAnyStr(min_length=3), False), ], ) def test_dirty_equals_true(value, dirty, match): if match: assert value == dirty else: assert value != dirty def test_regex_true(): assert 'whatever' == IsStr(regex='whatever') reg = IsStr(regex='wh.*er') assert 'whatever' == reg assert str(reg) == "'whatever'" def test_regex_bytes_true(): assert b'whatever' == IsBytes(regex=b'whatever') assert b'whatever' == IsBytes(regex=b'wh.*er') def test_regex_false(): reg = IsStr(regex='wh.*er') with pytest.raises(AssertionError): assert 'WHATEVER' == reg assert str(reg) == "IsStr(regex='wh.*er')" def test_regex_false_type_error(): assert 123 != IsStr(regex='wh.*er') reg = IsBytes(regex=b'wh.*er') with pytest.raises(AssertionError): assert 'whatever' == reg assert str(reg) == "IsBytes(regex=b'wh.*er')" def test_is_any_str(): assert 'foobar' == IsAnyStr assert b'foobar' == IsAnyStr assert 123 != IsAnyStr assert 'foo' == IsAnyStr(regex='foo') assert 'foo' == IsAnyStr(regex=b'foo') assert b'foo' == IsAnyStr(regex='foo') assert b'foo' == IsAnyStr(regex=b'foo')