Skip to content

Commit

Permalink
Merge pull request #1 from iterative/matchers
Browse files Browse the repository at this point in the history
introduce matchers
  • Loading branch information
skshetry authored Nov 30, 2021
2 parents 8f1c185 + a20b036 commit c8c75ea
Show file tree
Hide file tree
Showing 13 changed files with 480 additions and 34 deletions.
7 changes: 7 additions & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
[run]
branch = True
source = pytest_test_utils

[report]
exclude_lines =
if TYPE_CHECKING:
2 changes: 1 addition & 1 deletion .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ jobs:
uses: actions/setup-python@v2
with:
python-version: 3.7
- run: pip install -U nox pip wheel
- run: pip install -U nox
- run: nox -s build
- uses: pypa/gh-action-pypi-publish@master
with:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,6 @@ jobs:
- uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- run: pip install -U nox pip wheel
- run: pip install -U nox
- run: nox
- run: nox -s build
7 changes: 5 additions & 2 deletions noxfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,11 @@

@nox.session(python=["3.7", "3.8", "3.9", "3.10"])
def tests(session: nox.Session) -> None:
session.install("-e", ".[dev]")
session.run("pytest")
session.install(".[tests]")
# `pytest --cov` will start coverage after pytest
# so we need to use `coverage`.
session.run("coverage", "run", "-m", "pytest")
session.run("coverage", "report", "--show-missing", "--skip-covered")


@nox.session
Expand Down
8 changes: 6 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,15 @@ strict = true
warn_no_return = true
warn_redundant_casts = true
warn_unreachable = true
files = ["pytest_test_utils"]
files = ["pytest_test_utils", "tests.py"]

[[tool.mypy.overrides]]
module = ["tests"]
strict_equality = false

[tool.pylint.message_control]
enable = ["no-else-return"]
disable = ["missing-function-docstring", "missing-module-docstring", "missing-class-docstring"]

[tool.pytest.ini_options]
testpaths = ["./tests.py"]
testpaths = ["tests.py"]
8 changes: 6 additions & 2 deletions pytest_test_utils/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
from ._any import ANY
from . import matchers
from .tmp_dir import TmpDir
from .tmp_dir_factory import TempDirFactory

__all__ = ["ANY", "TmpDir", "TempDirFactory"]
__all__ = [
"matchers",
"TmpDir",
"TempDirFactory",
]
12 changes: 0 additions & 12 deletions pytest_test_utils/_any.py

This file was deleted.

36 changes: 36 additions & 0 deletions pytest_test_utils/_approx.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
from datetime import datetime, timedelta
from typing import Optional

from _pytest.python_api import ApproxBase

# pylint: disable=invalid-name


class approx_datetime(ApproxBase): # pylint: disable=abstract-method
"""Perform approximate comparisons between datetime or timedelta."""

default_tolerance = timedelta(seconds=1)
expected: datetime
abs: timedelta

def __init__(
self,
expected: datetime,
abs: Optional[timedelta] = None, # pylint: disable=redefined-builtin
) -> None:
"""Initialize the approx_datetime with `abs` as tolerance."""
assert isinstance(expected, datetime)
abs = abs or self.default_tolerance
assert abs >= timedelta(
0
), f"absolute tolerance can't be negative: {abs}"
super().__init__(expected, abs=abs)

def __repr__(self) -> str: # pragma: no cover
"""String repr for approx_datetime, shown during failure."""
return f"approx_datetime({self.expected!r} ± {self.abs!r})"

def __eq__(self, actual: object) -> bool:
"""Checking for equality with certain amount of tolerance."""
assert isinstance(actual, datetime), "expected type of datetime"
return abs(self.expected - actual) <= self.abs
Empty file removed pytest_test_utils/conftest.py
Empty file.
213 changes: 213 additions & 0 deletions pytest_test_utils/matchers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
import collections.abc
import re
from datetime import datetime
from typing import (
TYPE_CHECKING,
Any,
AnyStr,
Dict,
Mapping,
Optional,
Pattern,
Tuple,
Type,
Union,
)

if TYPE_CHECKING:
from _pytest.python_api import ApproxBase


# pylint: disable=invalid-name, too-few-public-methods


class regex:
"""Special class to eq by matching regex"""

def __init__(
self,
pattern: Union[AnyStr, Pattern[AnyStr]],
flags: Union[int, re.RegexFlag] = 0,
) -> None:
self._regex: Pattern[AnyStr] = re.compile(
pattern, flags # type: ignore[arg-type]
)

def __repr__(self) -> str:
flags = self._regex.flags & ~32 # 32 is default
flags_repr = f", {flags}" if flags else ""
return f"regex(r'{self._regex.pattern!s}'{flags_repr})"

def __eq__(self, other: Any) -> bool:
assert isinstance(other, (str, bytes))
return bool(self._regex.search(other)) # type: ignore


class any: # pylint: disable=redefined-builtin
"""Equals to anything.
A way to ignore parts of data structures on comparison"""

def __repr__(self) -> str:
return "any"

def __eq__(self, other: Any) -> bool:
return True


class dict: # pylint: disable=redefined-builtin
"""Special class to eq by matching only presented dict keys"""

def __init__(
self, d: Optional[Mapping[Any, Any]] = None, **keys: Any
) -> None:
self.d: Dict[Any, Any] = {}
if d:
self.d.update(d)
self.d.update(keys)

def __repr__(self) -> str:
inner = ", ".join(f"{k}={repr(v)}" for k, v in self.d.items())
return f"dict({inner})"

def __eq__(self, other: object) -> bool:
assert isinstance(other, collections.abc.Mapping)
return all(other.get(name) == v for name, v in self.d.items())


class unordered:
"""Compare list contents, but do not care about ordering.
(E.g. sort lists first, then compare.)
If you care about ordering, then just compare lists directly."""

def __init__(self, *items: Any) -> None:
self.items = items

def __repr__(self) -> str:
inner = ", ".join(map(repr, self.items))
return f"unordered({inner})"

def __eq__(self, other: object) -> bool:
assert isinstance(other, collections.abc.Iterable)
return sorted(self.items) == sorted(other)


class attrs:
def __init__(self, **attribs: Any) -> None:
self.attribs = attribs

def __repr__(self) -> str:
inner = ", ".join(f"{k}={repr(v)}" for k, v in self.attribs.items())
return f"attrs({inner})"

def __eq__(self, other: Any) -> bool:
# Unforturnately this doesn't work with classes with slots
# self.__class__ = other.__class__
return all(
getattr(other, name) == v for name, v in self.attribs.items()
)


class any_of:
def __init__(self, *items: Any) -> None:
self.items = sorted(items)

def __repr__(self) -> str:
inner = ", ".join(map(repr, self.items))
return f"any_of({inner})"

def __eq__(self, other: object) -> bool:
return other in self.items


class instance_of:
def __init__(
self,
expected_type: Union[Type[object], Tuple[Type[object], ...]],
) -> None:
self.expected_type = expected_type

def __repr__(self) -> str:
if isinstance(self.expected_type, tuple):
inner = f"({', '.join(t.__name__ for t in self.expected_type)})"
else:
inner = self.expected_type.__name__
return f"{self.__class__.__name__}({inner})"

def __eq__(self, other: Any) -> bool:
return isinstance(other, self.expected_type)


def approx( # type: ignore[no-untyped-def]
expected,
rel=None,
abs=None, # pylint: disable=redefined-builtin
nan_ok: bool = False,
) -> "ApproxBase":
# pylint: disable=import-outside-toplevel

if isinstance(expected, datetime):
from ._approx import approx_datetime

return approx_datetime(expected, abs=abs)

import pytest

return pytest.approx(expected, rel=rel, abs=abs, nan_ok=nan_ok)


class Matcher(attrs):
"""Special class to eq by existing attrs.
The purpose is to simplify asserts containing objects, i.e.:
assert (
result.errors ==
[M(message=M.re("^Something went wrong:"), extensions={"code": 523})]
)
Here all the structures like lists and dicts are followed as usual both
outside and inside a mather object. These could be freely intermixed.
"""

any = any()

@staticmethod
def attrs(**attribs: Any) -> attrs:
return attrs(**attribs)

@staticmethod
def regex(
pattern: Union[AnyStr, Pattern[AnyStr]],
flags: Union[int, re.RegexFlag] = 0,
) -> regex:
return regex(pattern, flags=flags)

re = regex

@staticmethod
def dict(d: Optional[Mapping[Any, Any]] = None, **keys: Any) -> dict:
return dict(d=d, **keys)

@staticmethod
def unordered(*items: Any) -> unordered:
return unordered(*items)

@staticmethod
def any_of(*items: Any) -> any_of:
return any_of(*items)

@staticmethod
def instance_of(
expected_type: Union[Type[object], Tuple[Type[object], ...]]
) -> instance_of:
return instance_of(expected_type)

@staticmethod
def approx( # type: ignore[no-untyped-def]
expected,
rel=None,
abs=None, # pylint: disable=redefined-builtin
nan_ok: bool = False,
) -> "ApproxBase":
return approx(expected, rel=rel, abs=abs, nan_ok=nan_ok)
14 changes: 12 additions & 2 deletions pytest_test_utils/pytest_plugin.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
from pathlib import Path
from typing import Iterator
from typing import Iterator, Type

import pytest
from pytest import TempPathFactory

from . import TempDirFactory, TmpDir
from . import TempDirFactory, TmpDir, matchers


@pytest.fixture(scope="session")
Expand All @@ -19,3 +19,13 @@ def tmp_dir(
tmp = TmpDir(tmp_path)
monkeypatch.chdir(tmp_path)
yield tmp


@pytest.fixture(name="matcher")
def matcher_fixture() -> Type["matchers.Matcher"]:
return matchers.Matcher


@pytest.fixture(name="M")
def m_fixture() -> Type["matchers.Matcher"]:
return matchers.Matcher
5 changes: 4 additions & 1 deletion setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,12 @@ install_requires=
pytest

[options.extras_require]
dev =
tests =
pytest==6.2.5
pytest-sugar==0.9.4
coverage==6.2
dev =
%(tests)s
pylint==2.11.1
mypy==0.910

Expand Down
Loading

0 comments on commit c8c75ea

Please sign in to comment.