import inspect
from collections.abc import Callable, Iterator
from contextlib import ExitStack, contextmanager
from typing import Any, NamedTuple, TypeVar, final
import pytest
from hypothesis import given
from hypothesis import settings as hypothesis_settings
from hypothesis import strategies as st
from hypothesis.strategies._internal import types
from returns.contrib.hypothesis.containers import strategy_from_container
from returns.primitives.laws import Law, Lawful
@final
class _Settings(NamedTuple):
"""Settings that we provide to an end user."""
settings_kwargs: dict[str, Any]
use_init: bool
[docs]
def check_all_laws(
container_type: type[Lawful],
*,
settings_kwargs: dict[str, Any] | None = None,
use_init: bool = False,
) -> None:
"""
Function to check all defined mathematical laws in a specified container.
Should be used like so:
.. code:: python
from returns.contrib.hypothesis.laws import check_all_laws
from returns.io import IO
check_all_laws(IO)
You can also pass different ``hypothesis`` settings inside:
.. code:: python
check_all_laws(IO, settings_kwargs={'max_examples': 100})
Note:
Cannot be used inside doctests because of the magic we use inside.
See also:
- https://sobolevn.me/2021/02/make-tests-a-part-of-your-app
- https://mmhaskell.com/blog/2017/3/13/obey-the-type-laws
"""
settings = _Settings(
settings_kwargs if settings_kwargs is not None else {},
use_init,
)
for interface, laws in container_type.laws().items():
for law in laws:
_create_law_test_case(
container_type,
interface,
law,
settings=settings,
)
[docs]
@contextmanager
def container_strategies(
container_type: type[Lawful],
*,
settings: _Settings,
) -> Iterator[None]:
"""
Registers all types inside a container to resolve to a correct strategy.
For example, let's say we have ``Result`` type.
It is a subtype of ``ContainerN``, ``MappableN``, ``BindableN``, etc.
When we check this type, we need ``MappableN`` to resolve to ``Result``.
Can be used independently from other functions.
"""
our_interfaces = {
base_type
for base_type in container_type.__mro__
if (
getattr(base_type, '__module__', '').startswith('returns.') and
# We don't register `Lawful` type, it is not a container:
base_type != Lawful and
# We will register the container itself later with
# `maybe_register_container` function:
base_type != container_type
)
}
for interface in our_interfaces:
st.register_type_strategy(
interface,
strategy_from_container(
container_type,
use_init=settings.use_init,
),
)
try:
yield
finally:
for interface in our_interfaces:
types._global_type_lookup.pop(interface)
_clean_caches()
[docs]
@contextmanager
def register_container(
container_type: type['Lawful'],
*,
use_init: bool,
) -> Iterator[None]:
"""Temporary registers a container if it is not registered yet."""
used = types._global_type_lookup.pop(container_type, None)
st.register_type_strategy(
container_type,
strategy_from_container(
container_type,
use_init=use_init,
),
)
try:
yield
finally:
types._global_type_lookup.pop(container_type)
if used:
st.register_type_strategy(container_type, used)
else:
_clean_caches()
[docs]
@contextmanager
def pure_functions() -> Iterator[None]:
"""
Context manager to resolve all ``Callable`` as pure functions.
It is not a default in ``hypothesis``.
"""
def factory(thing) -> st.SearchStrategy:
like = (
(lambda: None)
if len(thing.__args__) == 1
else (lambda *args, **kwargs: None)
)
return_type = thing.__args__[-1]
return st.functions(
like=like,
returns=st.from_type(
return_type
if return_type is not None
else type(None),
),
pure=True,
)
used = types._global_type_lookup[Callable] # type: ignore[index]
st.register_type_strategy(Callable, factory) # type: ignore[arg-type]
try:
yield
finally:
types._global_type_lookup.pop(Callable) # type: ignore[call-overload]
st.register_type_strategy(Callable, used) # type: ignore[arg-type]
[docs]
@contextmanager
def type_vars() -> Iterator[None]:
"""
Our custom ``TypeVar`` handling.
There are several noticeable differences:
1. We add mutable types to the tests: like ``list`` and ``dict``
2. We ensure that values inside strategies are self-equal,
for example, ``nan`` does not work for us
"""
def factory(thing):
return types.resolve_TypeVar(thing).filter(
lambda inner: inner == inner, # noqa: WPS312
)
used = types._global_type_lookup.pop(TypeVar)
st.register_type_strategy(TypeVar, factory)
try:
yield
finally:
types._global_type_lookup.pop(TypeVar)
st.register_type_strategy(TypeVar, used)
[docs]
@contextmanager
def clean_plugin_context() -> Iterator[None]:
"""
We register a lot of types in `_entrypoint.py`, we need to clean them.
Otherwise, some types might be messed up.
"""
saved_stategies = {}
for strategy_key, strategy in types._global_type_lookup.items():
if isinstance(strategy_key, type):
if strategy_key.__module__.startswith('returns.'):
saved_stategies.update({strategy_key: strategy})
for key_to_remove in saved_stategies:
types._global_type_lookup.pop(key_to_remove)
_clean_caches()
try:
yield
finally:
for saved_state in saved_stategies.items():
st.register_type_strategy(*saved_state)
def _clean_caches() -> None:
st.from_type.__clear_cache() # type: ignore[attr-defined]
def _run_law(
container_type: type[Lawful],
law: Law,
*,
settings: _Settings,
) -> Callable[[st.DataObject], None]:
def factory(source: st.DataObject) -> None:
with ExitStack() as stack:
stack.enter_context(clean_plugin_context())
stack.enter_context(type_vars())
stack.enter_context(pure_functions())
stack.enter_context(
container_strategies(container_type, settings=settings),
)
stack.enter_context(
register_container(container_type, use_init=settings.use_init),
)
source.draw(st.builds(law.definition))
return factory
def _create_law_test_case(
container_type: type[Lawful],
interface: type[Lawful],
law: Law,
*,
settings: _Settings,
) -> None:
test_function = given(st.data())(
hypothesis_settings(**settings.settings_kwargs)(
_run_law(container_type, law, settings=settings),
),
)
called_from = inspect.stack()[2]
module = inspect.getmodule(called_from[0])
template = 'test_{container}_{interface}_{name}'
test_function.__name__ = template.format( # noqa: WPS125
container=container_type.__qualname__.lower(),
interface=interface.__qualname__.lower(),
name=law.name,
)
setattr(
module,
test_function.__name__,
pytest.mark.filterwarnings(
# We ignore multiple warnings about unused coroutines and stuff:
'ignore::pytest.PytestUnraisableExceptionWarning',
)(
# We mark all tests with `returns_lawful` marker,
# so users can easily skip them if needed.
pytest.mark.returns_lawful(test_function),
),
)