diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f18ff25a..0e81d9b08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ See [0Ver](https://0ver.org/). ### Features - Make `hypothesis` plugin test laws from user-defined interfaces too +- Make `hypothesis` plugin accept user-defined strategies ### Bugfixes diff --git a/docs/pages/contrib/hypothesis_plugins.rst b/docs/pages/contrib/hypothesis_plugins.rst index e0be685e1..cbfcabe69 100644 --- a/docs/pages/contrib/hypothesis_plugins.rst +++ b/docs/pages/contrib/hypothesis_plugins.rst @@ -140,6 +140,27 @@ like ``Future``, ``ReaderFutureResult``, etc that have complex ``__init__`` signatures. And we don't want to mess with them. +You can also register a custom strategy to be used when running your +container's laws: + +.. code:: python + + + from hypothesis import strategies as st + + check_all_laws(Number, container_strategy=st.builds(Number, st.integers())) + +The ``container_strategy`` will be used only when running the tests generated +by the ``check_all_laws`` call above. It will have no effect on any other +property tests that involve ``Number``. You cannot use this argument together +with ``use_init``. + +Warning:: + Avoid directly registering your container's strategy with ``hypothesis`` + using ``st.register_type_strategy``. Because of the way we emulate + higher-kinded types, ``hypothesis`` may mistakenly use the strategy + for other incompatible containers and cause spurious test failures. + Warning:: Checking laws is not compatible with ``pytest-xdist``, because we use a lot of global mutable state there. diff --git a/returns/contrib/hypothesis/laws.py b/returns/contrib/hypothesis/laws.py index 03349ea60..bc84818a2 100644 --- a/returns/contrib/hypothesis/laws.py +++ b/returns/contrib/hypothesis/laws.py @@ -1,7 +1,8 @@ +import dataclasses import inspect -from collections.abc import Callable, Iterator +from collections.abc import Callable, Iterator, Mapping from contextlib import ExitStack, contextmanager -from typing import Any, NamedTuple, TypeGuard, TypeVar, final +from typing import Any, TypeGuard, TypeVar, final, overload import pytest from hypothesis import given @@ -10,22 +11,57 @@ from hypothesis.strategies._internal import types # noqa: PLC2701 from returns.contrib.hypothesis.containers import strategy_from_container +from returns.contrib.hypothesis.type_resolver import ( + StrategyFactory, + strategies_for_types, +) from returns.primitives.laws import LAWS_ATTRIBUTE, Law, Lawful +Example_co = TypeVar('Example_co', covariant=True) + @final -class _Settings(NamedTuple): +@dataclasses.dataclass(frozen=True) +class _Settings: """Settings that we provide to an end user.""" settings_kwargs: dict[str, Any] use_init: bool + container_strategy: StrategyFactory | None + + def __post_init__(self) -> None: + """Check that the settings are mutually compatible.""" + if self.use_init and self.container_strategy is not None: + raise AssertionError( + 'Expected only one of `use_init` and' + ' `container_strategy` to be truthy' + ) +@overload def check_all_laws( - container_type: type[Lawful], + container_type: type[Lawful[Example_co]], + *, + settings_kwargs: dict[str, Any] | None = None, + container_strategy: StrategyFactory[Example_co] | None = None, +) -> None: ... + + +@overload +def check_all_laws( + container_type: type[Lawful[Example_co]], *, settings_kwargs: dict[str, Any] | None = None, use_init: bool = False, +) -> None: ... + + +def check_all_laws( + container_type: type[Lawful[Example_co]], + *, + settings_kwargs: dict[str, Any] | None = None, + use_init: bool = False, + container_strategy: StrategyFactory[Example_co] | None = None, ) -> None: """ Function to check all defined mathematical laws in a specified container. @@ -56,6 +92,7 @@ def check_all_laws( settings = _Settings( settings_kwargs or {}, use_init, + container_strategy, ) for interface, laws in container_type.laws().items(): @@ -69,13 +106,13 @@ def check_all_laws( @contextmanager -def container_strategies( +def interface_strategies( container_type: type[Lawful], *, settings: _Settings, ) -> Iterator[None]: """ - Registers all types inside a container to resolve to a correct strategy. + Make all interfaces of a container resolve to the container's strategy. For example, let's say we have ``Result`` type. It is a subtype of ``ContainerN``, ``MappableN``, ``BindableN``, etc. @@ -83,48 +120,25 @@ def container_strategies( Can be used independently from other functions. """ - our_interfaces = lawful_interfaces(container_type) - for interface in our_interfaces: - st.register_type_strategy( - interface, - strategy_from_container( - container_type, - use_init=settings.use_init, - ), - ) - - try: + mapping: Mapping[type[object], StrategyFactory] = { + interface: _strategy_for_container(container_type, settings) + for interface in lawful_interfaces(container_type) + } + with strategies_for_types(mapping): yield - finally: - for interface in our_interfaces: - types._global_type_lookup.pop(interface) # noqa: SLF001 - _clean_caches() @contextmanager def register_container( container_type: type['Lawful'], *, - use_init: bool, + settings: _Settings, ) -> Iterator[None]: """Temporary registers a container if it is not registered yet.""" - used = types._global_type_lookup.pop(container_type, None) # noqa: SLF001 - st.register_type_strategy( - container_type, - strategy_from_container( - container_type, - use_init=use_init, - ), - ) - - try: + with strategies_for_types({ + container_type: _strategy_for_container(container_type, settings) + }): yield - finally: - types._global_type_lookup.pop(container_type) # noqa: SLF001 - if used: - st.register_type_strategy(container_type, used) - else: - _clean_caches() @contextmanager @@ -240,6 +254,17 @@ def _clean_caches() -> None: st.from_type.__clear_cache() # type: ignore[attr-defined] # noqa: SLF001 +def _strategy_for_container( + container_type: type[Lawful], + settings: _Settings, +) -> StrategyFactory: + return ( + strategy_from_container(container_type, use_init=settings.use_init) + if settings.container_strategy is None + else settings.container_strategy + ) + + def _run_law( container_type: type[Lawful], law: Law, @@ -252,10 +277,10 @@ def factory(source: st.DataObject) -> None: stack.enter_context(type_vars()) stack.enter_context(pure_functions()) stack.enter_context( - container_strategies(container_type, settings=settings), + interface_strategies(container_type, settings=settings), ) stack.enter_context( - register_container(container_type, use_init=settings.use_init), + register_container(container_type, settings=settings), ) source.draw(st.builds(law.definition)) diff --git a/returns/contrib/hypothesis/type_resolver.py b/returns/contrib/hypothesis/type_resolver.py new file mode 100644 index 000000000..0a574a4cd --- /dev/null +++ b/returns/contrib/hypothesis/type_resolver.py @@ -0,0 +1,75 @@ +"""Make `hypothesis` resolve types to the right strategies.""" + +from collections.abc import Callable, Iterator, Mapping +from contextlib import contextmanager +from typing import TypeAlias, TypeVar + +from hypothesis import strategies as st +from hypothesis.strategies._internal import types # noqa: PLC2701 + +Example_co = TypeVar('Example_co', covariant=True) + +StrategyFactory: TypeAlias = ( + st.SearchStrategy[Example_co] + | Callable[[type[Example_co]], st.SearchStrategy[Example_co]] +) + + +@contextmanager +def strategies_for_types( + mapping: Mapping[type[object], StrategyFactory], +) -> Iterator[None]: + """ + Temporarily register strategies with `hypothesis`. + + Within this context, `hypothesis` will generate data for `MyType` + using `mapping[MyType]`, if available. Otherwise, it will continue to + use the globally registered strategy for `MyType`. + + NOTE: This manually adds and removes strategies from an internal data + structure of `hypothesis`: `types._global_type_lookup`. This is a global + variable used for practically every example generated by `hypothesis`, so + we can easily have unintentional side-effects. We have to be very careful + when modifying it. + """ + previous_strategies: dict[type[object], StrategyFactory | None] = {} + for type_, strategy in mapping.items(): + previous_strategies[type_] = look_up_strategy(type_) + st.register_type_strategy(type_, strategy) + + try: + yield + finally: + for type_, previous_strategy in previous_strategies.items(): + if previous_strategy is None: + _remove_strategy(type_) + else: + st.register_type_strategy(type_, previous_strategy) + + +def look_up_strategy( + type_: type[Example_co], +) -> StrategyFactory[Example_co] | None: + """Return the strategy used by `hypothesis`.""" + return types._global_type_lookup.get(type_) # noqa: SLF001 + + +def _remove_strategy( + type_: type[object], +) -> None: + """Remove the strategy registered for `type_`.""" + types._global_type_lookup.pop(type_) # noqa: SLF001 + _clean_caches() + + +def apply_strategy( + strategy: StrategyFactory[Example_co], type_: type[Example_co] +) -> StrategyFactory[Example_co]: + """Apply `strategy` to `type_`.""" + if isinstance(strategy, st.SearchStrategy): + return strategy + return strategy(type_) + + +def _clean_caches() -> None: + st.from_type.__clear_cache() # type: ignore[attr-defined] # noqa: SLF001 diff --git a/tests/test_contrib/test_hypothesis/test_laws/test_user_specified_strategy.py b/tests/test_contrib/test_hypothesis/test_laws/test_user_specified_strategy.py new file mode 100644 index 000000000..f17992100 --- /dev/null +++ b/tests/test_contrib/test_hypothesis/test_laws/test_user_specified_strategy.py @@ -0,0 +1,11 @@ +from hypothesis import strategies as st +from test_hypothesis.test_laws import test_custom_type_applicative + +from returns.contrib.hypothesis.laws import check_all_laws + +container_type = test_custom_type_applicative._Wrapper # noqa: SLF001 + +check_all_laws( + container_type, + container_strategy=st.builds(container_type, st.integers()), +) diff --git a/tests/test_contrib/test_hypothesis/test_type_resolution.py b/tests/test_contrib/test_hypothesis/test_type_resolution.py index 72cadd05a..282237c69 100644 --- a/tests/test_contrib/test_hypothesis/test_type_resolution.py +++ b/tests/test_contrib/test_hypothesis/test_type_resolution.py @@ -1,5 +1,5 @@ from collections.abc import Sequence -from typing import Any +from typing import Any, Final import pytest from hypothesis import given @@ -15,12 +15,25 @@ RequiresContextResult, RequiresContextResultE, ) +from returns.contrib.hypothesis.laws import ( + _Settings, # noqa: PLC2701 + interface_strategies, + lawful_interfaces, + register_container, +) +from returns.contrib.hypothesis.type_resolver import ( + StrategyFactory, + apply_strategy, + look_up_strategy, + strategies_for_types, +) from returns.future import Future, FutureResult from returns.io import IO, IOResult, IOResultE from returns.maybe import Maybe from returns.pipeline import is_successful from returns.primitives.laws import Lawful -from returns.result import Result, ResultE +from returns.result import Result, ResultE, Success +from test_hypothesis.test_laws import test_custom_type_applicative _all_containers: Sequence[type[Lawful]] = ( Maybe, @@ -100,3 +113,143 @@ def test_custom_readerresult_types_resolve( assert isinstance(real_result.unwrap(), int) else: assert isinstance(real_result.failure(), str) + + +DEFAULT_RESULT_STRATEGY: Final = ( + "one_of(builds(from_value, shared(sampled_from([, " + ", , , , " + "]), key='typevar=~_FirstType').flatmap(from_type)), " + "builds(from_failure, shared(sampled_from([, " + ", , , , " + "]), " + "key='typevar=~_SecondType').flatmap(from_type)))" +) + + +def test_register_container_with_no_strategy() -> None: + """Check that a container without a strategy gets a strategy.""" + container_type = Result + + with register_container( + container_type, + settings=_Settings( + settings_kwargs={}, use_init=False, container_strategy=None + ), + ): + strategy_factory = look_up_strategy(container_type) + + assert ( + _strategy_string(strategy_factory, container_type) + == DEFAULT_RESULT_STRATEGY + ) + + +def test_register_container_with_strategy() -> None: + """Check that when a container has an existing strategy, we drop it.""" + container_type = Result + + with ( + strategies_for_types({ + container_type: st.builds(container_type, st.integers()) + }), + register_container( + container_type, + settings=_Settings( + settings_kwargs={}, use_init=False, container_strategy=None + ), + ), + ): + strategy_factory = look_up_strategy(container_type) + + assert ( + _strategy_string(strategy_factory, container_type) + == DEFAULT_RESULT_STRATEGY + ) + + +def test_register_container_with_setting() -> None: + """Check that we prefer a strategy given in settings.""" + container_type = Result + + with register_container( + container_type, + settings=_Settings( + settings_kwargs={}, + use_init=False, + container_strategy=st.builds(Success, st.integers()), + ), + ): + strategy_factory = look_up_strategy(container_type) + + assert ( + _strategy_string(strategy_factory, container_type) + == 'builds(Success, integers())' + ) + + +def test_interface_strategies() -> None: + """Check that ancestor interfaces get resolved to the concrete container.""" + container_type = test_custom_type_applicative._Wrapper # noqa: SLF001 + + with interface_strategies( + container_type, + settings=_Settings( + settings_kwargs={}, use_init=False, container_strategy=None + ), + ): + strategy_factories_inside = _interface_factories(container_type) + + assert _strategy_strings(strategy_factories_inside, container_type) == [ + "builds(from_value, shared(sampled_from([," + " , , , ," + " ]), key='typevar=~_FirstType').flatmap(from_type))", + "builds(from_value, shared(sampled_from([," + " , , , ," + " ]), key='typevar=~_FirstType').flatmap(from_type))", + ] + + +def test_interface_strategies_with_settings() -> None: + """Check that we prefer the strategy in the settings.""" + container_type = test_custom_type_applicative._Wrapper # noqa: SLF001 + + with interface_strategies( + container_type, + settings=_Settings( + settings_kwargs={}, + use_init=False, + container_strategy=st.builds(container_type, st.integers()), + ), + ): + strategy_factories_inside = _interface_factories(container_type) + + assert _strategy_strings(strategy_factories_inside, container_type) == [ + 'builds(_Wrapper, integers())', + 'builds(_Wrapper, integers())', + ] + + +def _interface_factories(type_: type[Lawful]) -> list[StrategyFactory | None]: + return [ + look_up_strategy(interface) for interface in lawful_interfaces(type_) + ] + + +def _strategy_strings( + strategy_factories: Sequence[StrategyFactory | None], type_: type[object] +) -> list[str]: + return [ + _strategy_string(strategy_factory, type_) + for strategy_factory in strategy_factories + ] + + +def _strategy_string( + strategy_factory: StrategyFactory | None, type_: type[object] +) -> str: + """Return an easily testable string representation.""" + return ( + str(None) + if strategy_factory is None + else str(apply_strategy(strategy_factory, type_)) + ) diff --git a/tests/test_contrib/test_hypothesis/test_type_resolver.py b/tests/test_contrib/test_hypothesis/test_type_resolver.py new file mode 100644 index 000000000..baeb83953 --- /dev/null +++ b/tests/test_contrib/test_hypothesis/test_type_resolver.py @@ -0,0 +1,56 @@ +from typing import Generic, TypeVar + +from hypothesis import strategies as st + +from returns.contrib.hypothesis.type_resolver import ( + look_up_strategy, + strategies_for_types, +) + +_ValueType = TypeVar('_ValueType') + + +class _Wrapper1(Generic[_ValueType]): + _inner_value: _ValueType + + +class _Wrapper2(Generic[_ValueType]): + _inner_value: _ValueType + + +def test_types_without_strategies() -> None: # noqa: WPS210 + """Check that it temporarily resolves a type that has no strategy.""" + strategy_before1 = look_up_strategy(_Wrapper1) + strategy_before2 = look_up_strategy(_Wrapper2) + + with strategies_for_types({ + _Wrapper1: st.builds(_Wrapper1, st.integers()), + _Wrapper2: st.builds(_Wrapper2, st.text()), + }): + strategy_inside1 = look_up_strategy(_Wrapper1) + strategy_inside2 = look_up_strategy(_Wrapper2) + + strategy_after1 = look_up_strategy(_Wrapper1) + strategy_after2 = look_up_strategy(_Wrapper2) + + assert strategy_before1 is None + assert strategy_before2 is None + assert str(strategy_inside1) == 'builds(_Wrapper1, integers())' + assert str(strategy_inside2) == 'builds(_Wrapper2, text())' + assert strategy_after1 is None + assert strategy_after2 is None + + +def test_type_with_strategy() -> None: + """Check that it restores the original strategy.""" + with strategies_for_types({_Wrapper1: st.builds(_Wrapper1, st.integers())}): + strategy_before = look_up_strategy(_Wrapper1) + + with strategies_for_types({_Wrapper1: st.builds(_Wrapper1, st.text())}): + strategy_inside = look_up_strategy(_Wrapper1) + + strategy_after = look_up_strategy(_Wrapper1) + + assert str(strategy_before) == 'builds(_Wrapper1, integers())' + assert str(strategy_inside) == 'builds(_Wrapper1, text())' + assert str(strategy_after) == 'builds(_Wrapper1, integers())' diff --git a/typesafety/test_contrib/test_hypothesis/test_laws/test_check_all_laws.yml b/typesafety/test_contrib/test_hypothesis/test_laws/test_check_all_laws.yml index 9c63c4fb3..05bc40fa5 100644 --- a/typesafety/test_contrib/test_hypothesis/test_laws/test_check_all_laws.yml +++ b/typesafety/test_contrib/test_hypothesis/test_laws/test_check_all_laws.yml @@ -25,8 +25,7 @@ x: Type[Lawful] = {{ container }} - -- case: test_all_laws_sig +- case: test_all_laws_accepts_only_one_approach disable_cache: false # TODO: remove this config after # mypy/typeshed/stdlib/unittest/mock.pyi:120: @@ -35,6 +34,13 @@ mypy_config: disallow_subclassing_any = False main: | + from hypothesis import strategies as st from returns.contrib.hypothesis.laws import check_all_laws + from returns.result import Result, Success - reveal_type(check_all_laws) # N: Revealed type is "def (container_type: Type[returns.primitives.laws.Lawful[Any]], *, settings_kwargs: Union[builtins.dict[builtins.str, Any], None] =, use_init: builtins.bool =)" + check_all_laws(Result) + check_all_laws(Result, use_init=True) + check_all_laws(Result, container_strategy=st.builds(Success, st.integers())) + check_all_laws( # E: No overload variant of "check_all_laws" matches argument types "Type[Result[_ValueType_co, _ErrorType_co]]", "bool", "SearchStrategy[Success[Any]]" [call-overload] # N: Possible overload variants: # N: def [Example_co] check_all_laws(container_type: Type[Lawful[Example_co]], *, settings_kwargs: Optional[Dict[str, Any]] = ..., container_strategy: Optional[Union[SearchStrategy[Example_co], Callable[[Type[Example_co]], SearchStrategy[Example_co]]]] = ...) -> None # N: def [Example_co] check_all_laws(container_type: Type[Lawful[Example_co]], *, settings_kwargs: Optional[Dict[str, Any]] = ..., use_init: bool = ...) -> None + Result, use_init=True, container_strategy=st.builds(Success, st.integers()) + )