Skip to content

Commit 475dc52

Browse files
authored
Make the hypothesis plugin check laws from user-defined interfaces too (#2060)
* 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. * 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. * 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`. * 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.
1 parent adb4999 commit 475dc52

File tree

7 files changed

+187
-14
lines changed

7 files changed

+187
-14
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ See [0Ver](https://0ver.org/).
2424

2525
## 0.24.1
2626

27+
### Features
28+
29+
- Make `hypothesis` plugin test laws from user-defined interfaces too
30+
2731
### Bugfixes
2832

2933
- Add pickling support for `UnwrapFailedError` exception

docs/pages/contrib/hypothesis_plugins.rst

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,9 +81,9 @@ It works in a combination with "Laws as Values" feature we provide in the core.
8181
8282
check_all_laws(YourCustomContainer)
8383
84-
This one line of code will generate ~100 tests for all defined law
84+
This one line of code will generate ~100 tests for all defined laws
8585
in both ``YourCustomContainer`` and all its super types,
86-
including our internal ones.
86+
including our internal ones and user-defined ones.
8787

8888
We also provide a way to configure
8989
the checking process with ``settings_kwargs``:

returns/contrib/hypothesis/laws.py

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import inspect
22
from collections.abc import Callable, Iterator
33
from contextlib import ExitStack, contextmanager
4-
from typing import Any, NamedTuple, TypeVar, final
4+
from typing import Any, NamedTuple, TypeGuard, TypeVar, final
55

66
import pytest
77
from hypothesis import given
@@ -10,7 +10,7 @@
1010
from hypothesis.strategies._internal import types # noqa: PLC2701
1111

1212
from returns.contrib.hypothesis.containers import strategy_from_container
13-
from returns.primitives.laws import Law, Lawful
13+
from returns.primitives.laws import LAWS_ATTRIBUTE, Law, Lawful
1414

1515

1616
@final
@@ -83,14 +83,7 @@ def container_strategies(
8383
8484
Can be used independently from other functions.
8585
"""
86-
our_interfaces = {
87-
base_type
88-
for base_type in container_type.__mro__
89-
if (
90-
getattr(base_type, '__module__', '').startswith('returns.')
91-
and base_type not in {Lawful, container_type}
92-
)
93-
}
86+
our_interfaces = lawful_interfaces(container_type)
9487
for interface in our_interfaces:
9588
st.register_type_strategy(
9689
interface,
@@ -221,6 +214,28 @@ def clean_plugin_context() -> Iterator[None]:
221214
st.register_type_strategy(*saved_state)
222215

223216

217+
def lawful_interfaces(container_type: type[Lawful]) -> set[type[Lawful]]:
218+
"""Return ancestors of `container_type` that are lawful interfaces."""
219+
return {
220+
base_type
221+
for base_type in container_type.__mro__
222+
if _is_lawful_interface(base_type)
223+
and base_type not in {Lawful, container_type}
224+
}
225+
226+
227+
def _is_lawful_interface(
228+
interface_type: type[object],
229+
) -> TypeGuard[type[Lawful]]:
230+
return issubclass(interface_type, Lawful) and _has_non_inherited_attribute(
231+
interface_type, LAWS_ATTRIBUTE
232+
)
233+
234+
235+
def _has_non_inherited_attribute(type_: type[object], attribute: str) -> bool:
236+
return attribute in type_.__dict__
237+
238+
224239
def _clean_caches() -> None:
225240
st.from_type.__clear_cache() # type: ignore[attr-defined] # noqa: SLF001
226241

returns/primitives/laws.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from collections.abc import Callable, Sequence
2-
from typing import ClassVar, Generic, TypeVar, final
2+
from typing import ClassVar, Final, Generic, TypeVar, final
33

44
from returns.primitives.types import Immutable
55

@@ -12,6 +12,8 @@
1212
#: Special alias to define laws as functions even inside a class
1313
law_definition = staticmethod
1414

15+
LAWS_ATTRIBUTE: Final = '_laws'
16+
1517

1618
class Law(Immutable):
1719
"""
@@ -132,7 +134,7 @@ def laws(cls) -> dict[type['Lawful'], Sequence[Law]]: # noqa: WPS210
132134

133135
laws = {}
134136
for klass in seen.values():
135-
current_laws = klass.__dict__.get('_laws', ())
137+
current_laws = klass.__dict__.get(LAWS_ATTRIBUTE, ())
136138
if not current_laws:
137139
continue
138140
laws[klass] = current_laws

tests/test_contrib/test_hypothesis/__init__.py

Whitespace-only changes.
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
from returns.contrib.hypothesis.laws import lawful_interfaces
2+
from returns.result import Result
3+
from test_hypothesis.test_laws import (
4+
test_custom_interface_with_laws,
5+
test_custom_type_applicative,
6+
)
7+
8+
9+
def test_container_defined_in_returns() -> None:
10+
"""Check that it returns all interfaces for a container in `returns`."""
11+
result = lawful_interfaces(Result)
12+
13+
assert sorted(str(interface) for interface in result) == [
14+
"<class 'returns.interfaces.altable.AltableN'>",
15+
"<class 'returns.interfaces.applicative.ApplicativeN'>",
16+
"<class 'returns.interfaces.container.ContainerN'>",
17+
"<class 'returns.interfaces.equable.Equable'>",
18+
"<class 'returns.interfaces.failable.DiverseFailableN'>",
19+
"<class 'returns.interfaces.failable.FailableN'>",
20+
"<class 'returns.interfaces.mappable.MappableN'>",
21+
"<class 'returns.interfaces.swappable.SwappableN'>",
22+
]
23+
24+
25+
def test_container_defined_outside_returns() -> None:
26+
"""Check container defined outside `returns`."""
27+
result = lawful_interfaces(test_custom_type_applicative._Wrapper) # noqa: SLF001
28+
29+
assert sorted(str(interface) for interface in result) == [
30+
"<class 'returns.interfaces.applicative.ApplicativeN'>",
31+
"<class 'returns.interfaces.mappable.MappableN'>",
32+
]
33+
34+
35+
def test_interface_defined_outside_returns() -> None:
36+
"""Check container with interface defined outside `returns`."""
37+
result = lawful_interfaces(test_custom_interface_with_laws._Wrapper) # noqa: SLF001
38+
39+
assert sorted(str(interface) for interface in result) == [
40+
"<class 'test_hypothesis.test_laws.test_custom_interface_with_laws"
41+
"._MappableN'>"
42+
]
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
from abc import abstractmethod
2+
from collections.abc import Callable, Sequence
3+
from typing import ClassVar, Generic, TypeAlias, TypeVar, final
4+
5+
from typing_extensions import Never
6+
7+
from returns.contrib.hypothesis.laws import check_all_laws
8+
from returns.functions import compose, identity
9+
from returns.primitives.asserts import assert_equal
10+
from returns.primitives.container import BaseContainer
11+
from returns.primitives.hkt import KindN, SupportsKind1
12+
from returns.primitives.laws import (
13+
Law,
14+
Law1,
15+
Law3,
16+
Lawful,
17+
LawSpecDef,
18+
law_definition,
19+
)
20+
21+
_FirstType = TypeVar('_FirstType')
22+
_SecondType = TypeVar('_SecondType')
23+
_ThirdType = TypeVar('_ThirdType')
24+
_UpdatedType = TypeVar('_UpdatedType')
25+
26+
_MappableType = TypeVar('_MappableType', bound='_MappableN')
27+
_ValueType = TypeVar('_ValueType')
28+
_NewValueType = TypeVar('_NewValueType')
29+
30+
# Used in laws:
31+
_NewType1 = TypeVar('_NewType1')
32+
_NewType2 = TypeVar('_NewType2')
33+
34+
35+
@final
36+
class _LawSpec(LawSpecDef):
37+
"""Copy of the functor laws for `MappableN`."""
38+
39+
__slots__ = ()
40+
41+
@law_definition
42+
def identity_law(
43+
mappable: '_MappableN[_FirstType, _SecondType, _ThirdType]',
44+
) -> None:
45+
"""Mapping identity over a value must return the value unchanged."""
46+
assert_equal(mappable.map(identity), mappable)
47+
48+
@law_definition
49+
def associative_law(
50+
mappable: '_MappableN[_FirstType, _SecondType, _ThirdType]',
51+
first: Callable[[_FirstType], _NewType1],
52+
second: Callable[[_NewType1], _NewType2],
53+
) -> None:
54+
"""Mapping twice or mapping a composition is the same thing."""
55+
assert_equal(
56+
mappable.map(first).map(second),
57+
mappable.map(compose(first, second)),
58+
)
59+
60+
61+
class _MappableN(
62+
Lawful['_MappableN[_FirstType, _SecondType, _ThirdType]'],
63+
Generic[_FirstType, _SecondType, _ThirdType],
64+
):
65+
"""Simple "user-defined" copy of `MappableN`."""
66+
67+
__slots__ = ()
68+
69+
_laws: ClassVar[Sequence[Law]] = (
70+
Law1(_LawSpec.identity_law),
71+
Law3(_LawSpec.associative_law),
72+
)
73+
74+
@abstractmethod # noqa: WPS125
75+
def map(
76+
self: _MappableType,
77+
function: Callable[[_FirstType], _UpdatedType],
78+
) -> KindN[_MappableType, _UpdatedType, _SecondType, _ThirdType]:
79+
"""Allows to run a pure function over a container."""
80+
81+
82+
_Mappable1: TypeAlias = _MappableN[_FirstType, Never, Never]
83+
84+
85+
class _ParentWrapper(_Mappable1[_ValueType]):
86+
"""Class that is an ancestor of `_Wrapper` but has no laws."""
87+
88+
89+
class _Wrapper(
90+
BaseContainer,
91+
SupportsKind1['_Wrapper', _ValueType],
92+
_ParentWrapper[_ValueType],
93+
):
94+
"""Simple instance of `_MappableN`."""
95+
96+
_inner_value: _ValueType
97+
98+
def __init__(self, inner_value: _ValueType) -> None:
99+
super().__init__(inner_value)
100+
101+
def map(
102+
self,
103+
function: Callable[[_ValueType], _NewValueType],
104+
) -> '_Wrapper[_NewValueType]':
105+
return _Wrapper(function(self._inner_value))
106+
107+
108+
# We need to use `use_init=True` because `MappableN` does not automatically
109+
# get a strategy from `strategy_from_container`.
110+
check_all_laws(_Wrapper, use_init=True)

0 commit comments

Comments
 (0)