Skip to content

Commit 8317f59

Browse files
authored
Accept user-defined strategies (#2061)
1 parent 4ce99a7 commit 8317f59

File tree

8 files changed

+393
-45
lines changed

8 files changed

+393
-45
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ See [0Ver](https://0ver.org/).
2727
### Features
2828

2929
- Make `hypothesis` plugin test laws from user-defined interfaces too
30+
- Make `hypothesis` plugin accept user-defined strategies
3031

3132
### Bugfixes
3233

docs/pages/contrib/hypothesis_plugins.rst

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,27 @@ like ``Future``, ``ReaderFutureResult``, etc
140140
that have complex ``__init__`` signatures.
141141
And we don't want to mess with them.
142142

143+
You can also register a custom strategy to be used when running your
144+
container's laws:
145+
146+
.. code:: python
147+
148+
149+
from hypothesis import strategies as st
150+
151+
check_all_laws(Number, container_strategy=st.builds(Number, st.integers()))
152+
153+
The ``container_strategy`` will be used only when running the tests generated
154+
by the ``check_all_laws`` call above. It will have no effect on any other
155+
property tests that involve ``Number``. You cannot use this argument together
156+
with ``use_init``.
157+
158+
Warning::
159+
Avoid directly registering your container's strategy with ``hypothesis``
160+
using ``st.register_type_strategy``. Because of the way we emulate
161+
higher-kinded types, ``hypothesis`` may mistakenly use the strategy
162+
for other incompatible containers and cause spurious test failures.
163+
143164
Warning::
144165
Checking laws is not compatible with ``pytest-xdist``,
145166
because we use a lot of global mutable state there.

returns/contrib/hypothesis/laws.py

Lines changed: 65 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1+
import dataclasses
12
import inspect
2-
from collections.abc import Callable, Iterator
3+
from collections.abc import Callable, Iterator, Mapping
34
from contextlib import ExitStack, contextmanager
4-
from typing import Any, NamedTuple, TypeGuard, TypeVar, final
5+
from typing import Any, TypeGuard, TypeVar, final, overload
56

67
import pytest
78
from hypothesis import given
@@ -10,22 +11,57 @@
1011
from hypothesis.strategies._internal import types # noqa: PLC2701
1112

1213
from returns.contrib.hypothesis.containers import strategy_from_container
14+
from returns.contrib.hypothesis.type_resolver import (
15+
StrategyFactory,
16+
strategies_for_types,
17+
)
1318
from returns.primitives.laws import LAWS_ATTRIBUTE, Law, Lawful
1419

20+
Example_co = TypeVar('Example_co', covariant=True)
21+
1522

1623
@final
17-
class _Settings(NamedTuple):
24+
@dataclasses.dataclass(frozen=True)
25+
class _Settings:
1826
"""Settings that we provide to an end user."""
1927

2028
settings_kwargs: dict[str, Any]
2129
use_init: bool
30+
container_strategy: StrategyFactory | None
31+
32+
def __post_init__(self) -> None:
33+
"""Check that the settings are mutually compatible."""
34+
if self.use_init and self.container_strategy is not None:
35+
raise AssertionError(
36+
'Expected only one of `use_init` and'
37+
' `container_strategy` to be truthy'
38+
)
2239

2340

41+
@overload
2442
def check_all_laws(
25-
container_type: type[Lawful],
43+
container_type: type[Lawful[Example_co]],
44+
*,
45+
settings_kwargs: dict[str, Any] | None = None,
46+
container_strategy: StrategyFactory[Example_co] | None = None,
47+
) -> None: ...
48+
49+
50+
@overload
51+
def check_all_laws(
52+
container_type: type[Lawful[Example_co]],
2653
*,
2754
settings_kwargs: dict[str, Any] | None = None,
2855
use_init: bool = False,
56+
) -> None: ...
57+
58+
59+
def check_all_laws(
60+
container_type: type[Lawful[Example_co]],
61+
*,
62+
settings_kwargs: dict[str, Any] | None = None,
63+
use_init: bool = False,
64+
container_strategy: StrategyFactory[Example_co] | None = None,
2965
) -> None:
3066
"""
3167
Function to check all defined mathematical laws in a specified container.
@@ -56,6 +92,7 @@ def check_all_laws(
5692
settings = _Settings(
5793
settings_kwargs or {},
5894
use_init,
95+
container_strategy,
5996
)
6097

6198
for interface, laws in container_type.laws().items():
@@ -69,62 +106,39 @@ def check_all_laws(
69106

70107

71108
@contextmanager
72-
def container_strategies(
109+
def interface_strategies(
73110
container_type: type[Lawful],
74111
*,
75112
settings: _Settings,
76113
) -> Iterator[None]:
77114
"""
78-
Registers all types inside a container to resolve to a correct strategy.
115+
Make all interfaces of a container resolve to the container's strategy.
79116
80117
For example, let's say we have ``Result`` type.
81118
It is a subtype of ``ContainerN``, ``MappableN``, ``BindableN``, etc.
82119
When we check this type, we need ``MappableN`` to resolve to ``Result``.
83120
84121
Can be used independently from other functions.
85122
"""
86-
our_interfaces = lawful_interfaces(container_type)
87-
for interface in our_interfaces:
88-
st.register_type_strategy(
89-
interface,
90-
strategy_from_container(
91-
container_type,
92-
use_init=settings.use_init,
93-
),
94-
)
95-
96-
try:
123+
mapping: Mapping[type[object], StrategyFactory] = {
124+
interface: _strategy_for_container(container_type, settings)
125+
for interface in lawful_interfaces(container_type)
126+
}
127+
with strategies_for_types(mapping):
97128
yield
98-
finally:
99-
for interface in our_interfaces:
100-
types._global_type_lookup.pop(interface) # noqa: SLF001
101-
_clean_caches()
102129

103130

104131
@contextmanager
105132
def register_container(
106133
container_type: type['Lawful'],
107134
*,
108-
use_init: bool,
135+
settings: _Settings,
109136
) -> Iterator[None]:
110137
"""Temporary registers a container if it is not registered yet."""
111-
used = types._global_type_lookup.pop(container_type, None) # noqa: SLF001
112-
st.register_type_strategy(
113-
container_type,
114-
strategy_from_container(
115-
container_type,
116-
use_init=use_init,
117-
),
118-
)
119-
120-
try:
138+
with strategies_for_types({
139+
container_type: _strategy_for_container(container_type, settings)
140+
}):
121141
yield
122-
finally:
123-
types._global_type_lookup.pop(container_type) # noqa: SLF001
124-
if used:
125-
st.register_type_strategy(container_type, used)
126-
else:
127-
_clean_caches()
128142

129143

130144
@contextmanager
@@ -240,6 +254,17 @@ def _clean_caches() -> None:
240254
st.from_type.__clear_cache() # type: ignore[attr-defined] # noqa: SLF001
241255

242256

257+
def _strategy_for_container(
258+
container_type: type[Lawful],
259+
settings: _Settings,
260+
) -> StrategyFactory:
261+
return (
262+
strategy_from_container(container_type, use_init=settings.use_init)
263+
if settings.container_strategy is None
264+
else settings.container_strategy
265+
)
266+
267+
243268
def _run_law(
244269
container_type: type[Lawful],
245270
law: Law,
@@ -252,10 +277,10 @@ def factory(source: st.DataObject) -> None:
252277
stack.enter_context(type_vars())
253278
stack.enter_context(pure_functions())
254279
stack.enter_context(
255-
container_strategies(container_type, settings=settings),
280+
interface_strategies(container_type, settings=settings),
256281
)
257282
stack.enter_context(
258-
register_container(container_type, use_init=settings.use_init),
283+
register_container(container_type, settings=settings),
259284
)
260285
source.draw(st.builds(law.definition))
261286

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
"""Make `hypothesis` resolve types to the right strategies."""
2+
3+
from collections.abc import Callable, Iterator, Mapping
4+
from contextlib import contextmanager
5+
from typing import TypeAlias, TypeVar
6+
7+
from hypothesis import strategies as st
8+
from hypothesis.strategies._internal import types # noqa: PLC2701
9+
10+
Example_co = TypeVar('Example_co', covariant=True)
11+
12+
StrategyFactory: TypeAlias = (
13+
st.SearchStrategy[Example_co]
14+
| Callable[[type[Example_co]], st.SearchStrategy[Example_co]]
15+
)
16+
17+
18+
@contextmanager
19+
def strategies_for_types(
20+
mapping: Mapping[type[object], StrategyFactory],
21+
) -> Iterator[None]:
22+
"""
23+
Temporarily register strategies with `hypothesis`.
24+
25+
Within this context, `hypothesis` will generate data for `MyType`
26+
using `mapping[MyType]`, if available. Otherwise, it will continue to
27+
use the globally registered strategy for `MyType`.
28+
29+
NOTE: This manually adds and removes strategies from an internal data
30+
structure of `hypothesis`: `types._global_type_lookup`. This is a global
31+
variable used for practically every example generated by `hypothesis`, so
32+
we can easily have unintentional side-effects. We have to be very careful
33+
when modifying it.
34+
"""
35+
previous_strategies: dict[type[object], StrategyFactory | None] = {}
36+
for type_, strategy in mapping.items():
37+
previous_strategies[type_] = look_up_strategy(type_)
38+
st.register_type_strategy(type_, strategy)
39+
40+
try:
41+
yield
42+
finally:
43+
for type_, previous_strategy in previous_strategies.items():
44+
if previous_strategy is None:
45+
_remove_strategy(type_)
46+
else:
47+
st.register_type_strategy(type_, previous_strategy)
48+
49+
50+
def look_up_strategy(
51+
type_: type[Example_co],
52+
) -> StrategyFactory[Example_co] | None:
53+
"""Return the strategy used by `hypothesis`."""
54+
return types._global_type_lookup.get(type_) # noqa: SLF001
55+
56+
57+
def _remove_strategy(
58+
type_: type[object],
59+
) -> None:
60+
"""Remove the strategy registered for `type_`."""
61+
types._global_type_lookup.pop(type_) # noqa: SLF001
62+
_clean_caches()
63+
64+
65+
def apply_strategy(
66+
strategy: StrategyFactory[Example_co], type_: type[Example_co]
67+
) -> StrategyFactory[Example_co]:
68+
"""Apply `strategy` to `type_`."""
69+
if isinstance(strategy, st.SearchStrategy):
70+
return strategy
71+
return strategy(type_)
72+
73+
74+
def _clean_caches() -> None:
75+
st.from_type.__clear_cache() # type: ignore[attr-defined] # noqa: SLF001
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from hypothesis import strategies as st
2+
from test_hypothesis.test_laws import test_custom_type_applicative
3+
4+
from returns.contrib.hypothesis.laws import check_all_laws
5+
6+
container_type = test_custom_type_applicative._Wrapper # noqa: SLF001
7+
8+
check_all_laws(
9+
container_type,
10+
container_strategy=st.builds(container_type, st.integers()),
11+
)

0 commit comments

Comments
 (0)