diff --git a/CHANGELOG.md b/CHANGELOG.md index ec60379b6..7f18ff25a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,10 @@ See [0Ver](https://0ver.org/). ## 0.24.1 +### Features + +- Make `hypothesis` plugin test laws from user-defined interfaces too + ### Bugfixes - Add pickling support for `UnwrapFailedError` exception diff --git a/docs/pages/contrib/hypothesis_plugins.rst b/docs/pages/contrib/hypothesis_plugins.rst index 16e8dfd68..e0be685e1 100644 --- a/docs/pages/contrib/hypothesis_plugins.rst +++ b/docs/pages/contrib/hypothesis_plugins.rst @@ -81,9 +81,9 @@ It works in a combination with "Laws as Values" feature we provide in the core. check_all_laws(YourCustomContainer) -This one line of code will generate ~100 tests for all defined law +This one line of code will generate ~100 tests for all defined laws in both ``YourCustomContainer`` and all its super types, -including our internal ones. +including our internal ones and user-defined ones. We also provide a way to configure the checking process with ``settings_kwargs``: diff --git a/returns/contrib/hypothesis/laws.py b/returns/contrib/hypothesis/laws.py index 273543e4d..03349ea60 100644 --- a/returns/contrib/hypothesis/laws.py +++ b/returns/contrib/hypothesis/laws.py @@ -1,7 +1,7 @@ import inspect from collections.abc import Callable, Iterator from contextlib import ExitStack, contextmanager -from typing import Any, NamedTuple, TypeVar, final +from typing import Any, NamedTuple, TypeGuard, TypeVar, final import pytest from hypothesis import given @@ -10,7 +10,7 @@ from hypothesis.strategies._internal import types # noqa: PLC2701 from returns.contrib.hypothesis.containers import strategy_from_container -from returns.primitives.laws import Law, Lawful +from returns.primitives.laws import LAWS_ATTRIBUTE, Law, Lawful @final @@ -83,14 +83,7 @@ def container_strategies( 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 base_type not in {Lawful, container_type} - ) - } + our_interfaces = lawful_interfaces(container_type) for interface in our_interfaces: st.register_type_strategy( interface, @@ -221,6 +214,28 @@ def clean_plugin_context() -> Iterator[None]: st.register_type_strategy(*saved_state) +def lawful_interfaces(container_type: type[Lawful]) -> set[type[Lawful]]: + """Return ancestors of `container_type` that are lawful interfaces.""" + return { + base_type + for base_type in container_type.__mro__ + if _is_lawful_interface(base_type) + and base_type not in {Lawful, container_type} + } + + +def _is_lawful_interface( + interface_type: type[object], +) -> TypeGuard[type[Lawful]]: + return issubclass(interface_type, Lawful) and _has_non_inherited_attribute( + interface_type, LAWS_ATTRIBUTE + ) + + +def _has_non_inherited_attribute(type_: type[object], attribute: str) -> bool: + return attribute in type_.__dict__ + + def _clean_caches() -> None: st.from_type.__clear_cache() # type: ignore[attr-defined] # noqa: SLF001 diff --git a/returns/primitives/laws.py b/returns/primitives/laws.py index c08b4117b..765691e7d 100644 --- a/returns/primitives/laws.py +++ b/returns/primitives/laws.py @@ -1,5 +1,5 @@ from collections.abc import Callable, Sequence -from typing import ClassVar, Generic, TypeVar, final +from typing import ClassVar, Final, Generic, TypeVar, final from returns.primitives.types import Immutable @@ -12,6 +12,8 @@ #: Special alias to define laws as functions even inside a class law_definition = staticmethod +LAWS_ATTRIBUTE: Final = '_laws' + class Law(Immutable): """ @@ -132,7 +134,7 @@ def laws(cls) -> dict[type['Lawful'], Sequence[Law]]: # noqa: WPS210 laws = {} for klass in seen.values(): - current_laws = klass.__dict__.get('_laws', ()) + current_laws = klass.__dict__.get(LAWS_ATTRIBUTE, ()) if not current_laws: continue laws[klass] = current_laws diff --git a/tests/test_contrib/test_hypothesis/__init__.py b/tests/test_contrib/test_hypothesis/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/test_contrib/test_hypothesis/test_interface_resolution.py b/tests/test_contrib/test_hypothesis/test_interface_resolution.py new file mode 100644 index 000000000..73861ba96 --- /dev/null +++ b/tests/test_contrib/test_hypothesis/test_interface_resolution.py @@ -0,0 +1,42 @@ +from returns.contrib.hypothesis.laws import lawful_interfaces +from returns.result import Result +from test_hypothesis.test_laws import ( + test_custom_interface_with_laws, + test_custom_type_applicative, +) + + +def test_container_defined_in_returns() -> None: + """Check that it returns all interfaces for a container in `returns`.""" + result = lawful_interfaces(Result) + + assert sorted(str(interface) for interface in result) == [ + "", + "", + "", + "", + "", + "", + "", + "", + ] + + +def test_container_defined_outside_returns() -> None: + """Check container defined outside `returns`.""" + result = lawful_interfaces(test_custom_type_applicative._Wrapper) # noqa: SLF001 + + assert sorted(str(interface) for interface in result) == [ + "", + "", + ] + + +def test_interface_defined_outside_returns() -> None: + """Check container with interface defined outside `returns`.""" + result = lawful_interfaces(test_custom_interface_with_laws._Wrapper) # noqa: SLF001 + + assert sorted(str(interface) for interface in result) == [ + "" + ] diff --git a/tests/test_contrib/test_hypothesis/test_laws/test_custom_interface_with_laws.py b/tests/test_contrib/test_hypothesis/test_laws/test_custom_interface_with_laws.py new file mode 100644 index 000000000..3f4d38d13 --- /dev/null +++ b/tests/test_contrib/test_hypothesis/test_laws/test_custom_interface_with_laws.py @@ -0,0 +1,110 @@ +from abc import abstractmethod +from collections.abc import Callable, Sequence +from typing import ClassVar, Generic, TypeAlias, TypeVar, final + +from typing_extensions import Never + +from returns.contrib.hypothesis.laws import check_all_laws +from returns.functions import compose, identity +from returns.primitives.asserts import assert_equal +from returns.primitives.container import BaseContainer +from returns.primitives.hkt import KindN, SupportsKind1 +from returns.primitives.laws import ( + Law, + Law1, + Law3, + Lawful, + LawSpecDef, + law_definition, +) + +_FirstType = TypeVar('_FirstType') +_SecondType = TypeVar('_SecondType') +_ThirdType = TypeVar('_ThirdType') +_UpdatedType = TypeVar('_UpdatedType') + +_MappableType = TypeVar('_MappableType', bound='_MappableN') +_ValueType = TypeVar('_ValueType') +_NewValueType = TypeVar('_NewValueType') + +# Used in laws: +_NewType1 = TypeVar('_NewType1') +_NewType2 = TypeVar('_NewType2') + + +@final +class _LawSpec(LawSpecDef): + """Copy of the functor laws for `MappableN`.""" + + __slots__ = () + + @law_definition + def identity_law( + mappable: '_MappableN[_FirstType, _SecondType, _ThirdType]', + ) -> None: + """Mapping identity over a value must return the value unchanged.""" + assert_equal(mappable.map(identity), mappable) + + @law_definition + def associative_law( + mappable: '_MappableN[_FirstType, _SecondType, _ThirdType]', + first: Callable[[_FirstType], _NewType1], + second: Callable[[_NewType1], _NewType2], + ) -> None: + """Mapping twice or mapping a composition is the same thing.""" + assert_equal( + mappable.map(first).map(second), + mappable.map(compose(first, second)), + ) + + +class _MappableN( + Lawful['_MappableN[_FirstType, _SecondType, _ThirdType]'], + Generic[_FirstType, _SecondType, _ThirdType], +): + """Simple "user-defined" copy of `MappableN`.""" + + __slots__ = () + + _laws: ClassVar[Sequence[Law]] = ( + Law1(_LawSpec.identity_law), + Law3(_LawSpec.associative_law), + ) + + @abstractmethod # noqa: WPS125 + def map( + self: _MappableType, + function: Callable[[_FirstType], _UpdatedType], + ) -> KindN[_MappableType, _UpdatedType, _SecondType, _ThirdType]: + """Allows to run a pure function over a container.""" + + +_Mappable1: TypeAlias = _MappableN[_FirstType, Never, Never] + + +class _ParentWrapper(_Mappable1[_ValueType]): + """Class that is an ancestor of `_Wrapper` but has no laws.""" + + +class _Wrapper( + BaseContainer, + SupportsKind1['_Wrapper', _ValueType], + _ParentWrapper[_ValueType], +): + """Simple instance of `_MappableN`.""" + + _inner_value: _ValueType + + def __init__(self, inner_value: _ValueType) -> None: + super().__init__(inner_value) + + def map( + self, + function: Callable[[_ValueType], _NewValueType], + ) -> '_Wrapper[_NewValueType]': + return _Wrapper(function(self._inner_value)) + + +# We need to use `use_init=True` because `MappableN` does not automatically +# get a strategy from `strategy_from_container`. +check_all_laws(_Wrapper, use_init=True)