From 4fbe5206742dd7824a8c3e9d3df0a01752200685 Mon Sep 17 00:00:00 2001 From: S Pradeep Kumar Date: Sun, 9 Mar 2025 17:41:11 +0530 Subject: [PATCH 1/6] laws: add xfail-ing test for custom interface with laws. When running property tests for an container, we register it as the strategy for each ancestor interface with laws. However, we do that only for modules in `returns.`, which means that lawful interfaces written by users will not be resolved. This commit just demonstrates the current state. --- .../test_custom_interface_with_laws.py | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 tests/test_contrib/test_hypothesis/test_laws/test_custom_interface_with_laws.py 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..08c39cf77 --- /dev/null +++ b/tests/test_contrib/test_hypothesis/test_laws/test_custom_interface_with_laws.py @@ -0,0 +1,108 @@ +from abc import abstractmethod +from collections.abc import Callable, Sequence +from typing import ClassVar, Generic, TypeAlias, TypeVar, final + +import pytest +from hypothesis.errors import ResolutionFailed +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 _Wrapper( + BaseContainer, + SupportsKind1['_Wrapper', _ValueType], + _Mappable1[_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)) + + +pytestmark = pytest.mark.xfail(raises=ResolutionFailed) + +check_all_laws(_Wrapper) From f4cc763d3809e478382ec5834e388860a0cdaf83 Mon Sep 17 00:00:00 2001 From: S Pradeep Kumar Date: Sun, 9 Mar 2025 18:54:56 +0530 Subject: [PATCH 2/6] laws: extract and test function for getting lawful interfaces. Right now, it just gets all MRO classes, and it misses interfaces defined outside `returns`. Will tackle that in the next commit. --- returns/contrib/hypothesis/laws.py | 21 ++++--- .../test_contrib/test_hypothesis/__init__.py | 0 .../test_interface_resolution.py | 61 +++++++++++++++++++ 3 files changed, 74 insertions(+), 8 deletions(-) create mode 100644 tests/test_contrib/test_hypothesis/__init__.py create mode 100644 tests/test_contrib/test_hypothesis/test_interface_resolution.py diff --git a/returns/contrib/hypothesis/laws.py b/returns/contrib/hypothesis/laws.py index 273543e4d..06cc6c374 100644 --- a/returns/contrib/hypothesis/laws.py +++ b/returns/contrib/hypothesis/laws.py @@ -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,18 @@ def clean_plugin_context() -> Iterator[None]: st.register_type_strategy(*saved_state) +def lawful_interfaces(container_type: type[Lawful]) -> set[type[object]]: + """Return ancestors of `container_type` that are lawful interfaces.""" + return { + base_type + for base_type in container_type.__mro__ + if ( + getattr(base_type, '__module__', '').startswith('returns.') + and base_type not in {Lawful, container_type} + ) + } + + def _clean_caches() -> None: st.from_type.__clear_cache() # type: ignore[attr-defined] # noqa: SLF001 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..92067d498 --- /dev/null +++ b/tests/test_contrib/test_hypothesis/test_interface_resolution.py @@ -0,0 +1,61 @@ +from returns.contrib.hypothesis.laws import lawful_interfaces +from returns.result import Result + +from .test_laws import ( + test_custom_interface_with_laws, + test_custom_type_applicative, +) + + +def test_lawful_interfaces__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_lawful_interfaces__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_lawful_interfaces__interface_defined_outside_returns() -> None: + """Check container with interface defined outside `returns`.""" + result = lawful_interfaces(test_custom_interface_with_laws._Wrapper) # noqa: SLF001 + + # NOTE: The interface `_MappableN` is missing. + assert sorted(str(interface) for interface in result) == [ + "", + "", + "", + "", + ] From 9f47ff3d2b3031a8317f38dd369bb2301d712e4b Mon Sep 17 00:00:00 2001 From: S Pradeep Kumar Date: Sun, 9 Mar 2025 19:13:00 +0530 Subject: [PATCH 3/6] laws: restrict interface-patching to lawful interfaces. Earlier, we were restricting it to any class within `returns`. Main user-visible change: We now include `Lawful` ancestors outside `returns`. --- returns/contrib/hypothesis/laws.py | 14 +++++++++----- .../test_interface_resolution.py | 18 ++---------------- 2 files changed, 11 insertions(+), 21 deletions(-) diff --git a/returns/contrib/hypothesis/laws.py b/returns/contrib/hypothesis/laws.py index 06cc6c374..366a04f54 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 @@ -219,13 +219,17 @@ def lawful_interfaces(container_type: type[Lawful]) -> set[type[object]]: return { base_type for base_type in container_type.__mro__ - if ( - getattr(base_type, '__module__', '').startswith('returns.') - and base_type not in {Lawful, container_type} - ) + 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) + + def _clean_caches() -> None: st.from_type.__clear_cache() # type: ignore[attr-defined] # noqa: SLF001 diff --git a/tests/test_contrib/test_hypothesis/test_interface_resolution.py b/tests/test_contrib/test_hypothesis/test_interface_resolution.py index 92067d498..f13d943f9 100644 --- a/tests/test_contrib/test_hypothesis/test_interface_resolution.py +++ b/tests/test_contrib/test_hypothesis/test_interface_resolution.py @@ -15,22 +15,15 @@ def test_lawful_interfaces__container_defined_in_returns() -> None: "", "", "", - "", "", "", "", "", - "", "", "", "", "", "", - "", - "", - "", - "", - "", ] @@ -41,10 +34,6 @@ def test_lawful_interfaces__container_defined_outside_returns() -> None: assert sorted(str(interface) for interface in result) == [ "", "", - "", - "", - "", - "", ] @@ -52,10 +41,7 @@ def test_lawful_interfaces__interface_defined_outside_returns() -> None: """Check container with interface defined outside `returns`.""" result = lawful_interfaces(test_custom_interface_with_laws._Wrapper) # noqa: SLF001 - # NOTE: The interface `_MappableN` is missing. assert sorted(str(interface) for interface in result) == [ - "", - "", - "", - "", + "" ] From bb3fa1a80e129e84653c6c23e0d8ce3d52898256 Mon Sep 17 00:00:00 2001 From: S Pradeep Kumar Date: Sun, 9 Mar 2025 19:32:15 +0530 Subject: [PATCH 4/6] laws: restrict interface-patching to interfaces with laws. We check that the interface class actually has laws. This should eliminate any false positives that come from patching interfaces that were defined outside `returns`. Added a `_ParentWrapper` to test that it doesn't show up. (It did before the change in this commit.) Add to CHANGELOG and the hypothesis plugins page. --- CHANGELOG.md | 4 ++++ docs/pages/contrib/hypothesis_plugins.rst | 4 ++-- returns/contrib/hypothesis/laws.py | 12 +++++++++--- returns/primitives/laws.py | 4 +++- .../test_hypothesis/test_interface_resolution.py | 4 ---- .../test_laws/test_custom_interface_with_laws.py | 14 ++++++++------ 6 files changed, 26 insertions(+), 16 deletions(-) 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 366a04f54..03349ea60 100644 --- a/returns/contrib/hypothesis/laws.py +++ b/returns/contrib/hypothesis/laws.py @@ -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 @@ -214,7 +214,7 @@ def clean_plugin_context() -> Iterator[None]: st.register_type_strategy(*saved_state) -def lawful_interfaces(container_type: type[Lawful]) -> set[type[object]]: +def lawful_interfaces(container_type: type[Lawful]) -> set[type[Lawful]]: """Return ancestors of `container_type` that are lawful interfaces.""" return { base_type @@ -227,7 +227,13 @@ def lawful_interfaces(container_type: type[Lawful]) -> set[type[object]]: def _is_lawful_interface( interface_type: type[object], ) -> TypeGuard[type[Lawful]]: - return issubclass(interface_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: diff --git a/returns/primitives/laws.py b/returns/primitives/laws.py index c08b4117b..e79c64169 100644 --- a/returns/primitives/laws.py +++ b/returns/primitives/laws.py @@ -12,6 +12,8 @@ #: Special alias to define laws as functions even inside a class law_definition = staticmethod +LAWS_ATTRIBUTE: str = '_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/test_interface_resolution.py b/tests/test_contrib/test_hypothesis/test_interface_resolution.py index f13d943f9..50a46fd14 100644 --- a/tests/test_contrib/test_hypothesis/test_interface_resolution.py +++ b/tests/test_contrib/test_hypothesis/test_interface_resolution.py @@ -14,15 +14,11 @@ def test_lawful_interfaces__container_defined_in_returns() -> None: 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 index 08c39cf77..3f4d38d13 100644 --- 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 @@ -2,8 +2,6 @@ from collections.abc import Callable, Sequence from typing import ClassVar, Generic, TypeAlias, TypeVar, final -import pytest -from hypothesis.errors import ResolutionFailed from typing_extensions import Never from returns.contrib.hypothesis.laws import check_all_laws @@ -84,10 +82,14 @@ def map( _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], - _Mappable1[_ValueType], + _ParentWrapper[_ValueType], ): """Simple instance of `_MappableN`.""" @@ -103,6 +105,6 @@ def map( return _Wrapper(function(self._inner_value)) -pytestmark = pytest.mark.xfail(raises=ResolutionFailed) - -check_all_laws(_Wrapper) +# 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) From b4abbca4eadece3b2e1f252655091fb1e6877eb3 Mon Sep 17 00:00:00 2001 From: S Pradeep Kumar Date: Mon, 10 Mar 2025 00:43:28 +0530 Subject: [PATCH 5/6] laws: use `Final` for constant. --- returns/primitives/laws.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/returns/primitives/laws.py b/returns/primitives/laws.py index e79c64169..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,7 +12,7 @@ #: Special alias to define laws as functions even inside a class law_definition = staticmethod -LAWS_ATTRIBUTE: str = '_laws' +LAWS_ATTRIBUTE: Final = '_laws' class Law(Immutable): From 7adb88137924a47fdeaf2d692a5de9cc618883af Mon Sep 17 00:00:00 2001 From: S Pradeep Kumar Date: Mon, 10 Mar 2025 00:57:12 +0530 Subject: [PATCH 6/6] laws: shrink test names. --- .../test_hypothesis/test_interface_resolution.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/tests/test_contrib/test_hypothesis/test_interface_resolution.py b/tests/test_contrib/test_hypothesis/test_interface_resolution.py index 50a46fd14..73861ba96 100644 --- a/tests/test_contrib/test_hypothesis/test_interface_resolution.py +++ b/tests/test_contrib/test_hypothesis/test_interface_resolution.py @@ -1,13 +1,12 @@ from returns.contrib.hypothesis.laws import lawful_interfaces from returns.result import Result - -from .test_laws import ( +from test_hypothesis.test_laws import ( test_custom_interface_with_laws, test_custom_type_applicative, ) -def test_lawful_interfaces__container_defined_in_returns() -> None: +def test_container_defined_in_returns() -> None: """Check that it returns all interfaces for a container in `returns`.""" result = lawful_interfaces(Result) @@ -23,7 +22,7 @@ def test_lawful_interfaces__container_defined_in_returns() -> None: ] -def test_lawful_interfaces__container_defined_outside_returns() -> None: +def test_container_defined_outside_returns() -> None: """Check container defined outside `returns`.""" result = lawful_interfaces(test_custom_type_applicative._Wrapper) # noqa: SLF001 @@ -33,7 +32,7 @@ def test_lawful_interfaces__container_defined_outside_returns() -> None: ] -def test_lawful_interfaces__interface_defined_outside_returns() -> None: +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