From 04416291eaad3050581c97012c6056de3ee98151 Mon Sep 17 00:00:00 2001 From: Gabe Joseph Date: Mon, 10 Jan 2022 18:12:13 -0700 Subject: [PATCH 1/7] Annotate decorators with `ParamSpec` This uses `typing.ParamSpec` on `composite`, `functions`, and `given`. This should allow type-checkers and IDEs to infer the signatures of functions returned by these decorators. For `composite` and `functions`, I find this especially helpful. --- hypothesis-python/RELEASE.rst | 5 +++++ hypothesis-python/src/hypothesis/core.py | 8 +++++--- .../src/hypothesis/strategies/_internal/core.py | 13 ++++++++++--- 3 files changed, 20 insertions(+), 6 deletions(-) create mode 100644 hypothesis-python/RELEASE.rst diff --git a/hypothesis-python/RELEASE.rst b/hypothesis-python/RELEASE.rst new file mode 100644 index 0000000000..b48108a1b3 --- /dev/null +++ b/hypothesis-python/RELEASE.rst @@ -0,0 +1,5 @@ +RELEASE_TYPE: minor + +This preserves the type annotations of functions passed to :func:`hypothesis.strategies.composite`, :func:`hypothesis.strategies.functions`, and :func:`hypothesis.given` by using :obj:`python:typing.ParamSpec`. + +This improves the ability of static type-checkers to check test code that uses Hypothesis, and improves auto-completion in IDEs. \ No newline at end of file diff --git a/hypothesis-python/src/hypothesis/core.py b/hypothesis-python/src/hypothesis/core.py index 6c3f838d5b..13cf10b776 100644 --- a/hypothesis-python/src/hypothesis/core.py +++ b/hypothesis-python/src/hypothesis/core.py @@ -37,6 +37,7 @@ from unittest import TestCase import attr +from typing_extensions import ParamSpec from hypothesis import strategies as st from hypothesis._settings import ( @@ -948,12 +949,13 @@ def fuzz_one_input( return self.__cached_target +P = ParamSpec("P") + + def given( *_given_arguments: Union[SearchStrategy, InferType], **_given_kwargs: Union[SearchStrategy, InferType], -) -> Callable[ - [Callable[..., Union[None, Coroutine[Any, Any, None]]]], Callable[..., None] -]: +) -> Callable[[Callable[P, Union[None, Coroutine[Any, Any, None]]]], Callable[P, None]]: """A decorator for turning a test function that accepts arguments into a randomized test. diff --git a/hypothesis-python/src/hypothesis/strategies/_internal/core.py b/hypothesis-python/src/hypothesis/strategies/_internal/core.py index 7530ec62d7..d6fc120f07 100644 --- a/hypothesis-python/src/hypothesis/strategies/_internal/core.py +++ b/hypothesis-python/src/hypothesis/strategies/_internal/core.py @@ -43,6 +43,7 @@ from uuid import UUID import attr +from typing_extensions import Concatenate, ParamSpec from hypothesis.control import cleanup, note from hypothesis.errors import InvalidArgument, ResolutionFailed @@ -1450,8 +1451,13 @@ def __call__(self, strategy: SearchStrategy[Ex], label: object = None) -> Ex: raise NotImplementedError +P = ParamSpec("P") + + @cacheable -def composite(f: Callable[..., Ex]) -> Callable[..., SearchStrategy[Ex]]: +def composite( + f: Callable[Concatenate[DrawFn, P], Ex] +) -> Callable[P, SearchStrategy[Ex]]: """Defines a strategy that is built out of potentially arbitrarily many other strategies. @@ -1847,13 +1853,14 @@ def emails() -> SearchStrategy[str]: ) +P = ParamSpec("P") @defines_strategy() def functions( *, - like: Callable[..., Any] = lambda: None, + like: Callable[P, Any] = lambda: None, returns: Optional[SearchStrategy[Any]] = None, pure: bool = False, -) -> SearchStrategy[Callable[..., Any]]: +) -> SearchStrategy[Callable[P, Any]]: # The proper type signature of `functions()` would have T instead of Any, but mypy # disallows default args for generics: https://github.com/python/mypy/issues/3737 """functions(*, like=lambda: None, returns=none(), pure=False) From ebf158c1d63d935014de7007a1fbb72897b69c30 Mon Sep 17 00:00:00 2001 From: Gabe Joseph Date: Mon, 10 Jan 2022 18:45:19 -0700 Subject: [PATCH 2/7] TypeVar for return type of `functions` Probably won't work, but let's see --- .../src/hypothesis/strategies/_internal/core.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/hypothesis-python/src/hypothesis/strategies/_internal/core.py b/hypothesis-python/src/hypothesis/strategies/_internal/core.py index d6fc120f07..98912de901 100644 --- a/hypothesis-python/src/hypothesis/strategies/_internal/core.py +++ b/hypothesis-python/src/hypothesis/strategies/_internal/core.py @@ -1854,13 +1854,16 @@ def emails() -> SearchStrategy[str]: P = ParamSpec("P") +T = TypeVar("T") + + @defines_strategy() def functions( *, - like: Callable[P, Any] = lambda: None, - returns: Optional[SearchStrategy[Any]] = None, + like: Callable[P, T] = lambda: None, + returns: Optional[SearchStrategy[T]] = None, pure: bool = False, -) -> SearchStrategy[Callable[P, Any]]: +) -> SearchStrategy[Callable[P, T]]: # The proper type signature of `functions()` would have T instead of Any, but mypy # disallows default args for generics: https://github.com/python/mypy/issues/3737 """functions(*, like=lambda: None, returns=none(), pure=False) From baa19bfbd8583eac8b6d22d122966d31176fcb85 Mon Sep 17 00:00:00 2001 From: Gabe Joseph Date: Mon, 10 Jan 2022 18:46:58 -0700 Subject: [PATCH 3/7] Tests for annotations? --- whole-repo-tests/test_type_hints.py | 40 +++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/whole-repo-tests/test_type_hints.py b/whole-repo-tests/test_type_hints.py index fedf21ad71..749885e444 100644 --- a/whole-repo-tests/test_type_hints.py +++ b/whole-repo-tests/test_type_hints.py @@ -142,6 +142,32 @@ def test_drawfn_type_tracing(tmpdir): assert got == "str" +def test_composite_type_tracing(tmpdir): + f = tmpdir.join("check_mypy_on_st_composite.py") + f.write( + "from hypothesis.strategies import composite, DrawFn\n" + "@composite" + "def comp(draw: DrawFn, x: int) -> int:\n" + " return x\n" + "reveal_type(comp)" + ) + got = get_mypy_analysed_type(str(f.realpath()), ...) + assert got == "def (x: int) -> int" + + +def test_functions_type_tracing(tmpdir): + f = tmpdir.join("check_mypy_on_st_functions.py") + f.write( + "from hypothesis.strategies import functions\n" + "def like(x: int, y: str) -> str:" + " return str(x) + y" + "st = functions(like)" + "reveal_type(st)" + ) + got = get_mypy_analysed_type(str(f.realpath()), ...) + assert got == "SearchStrategy[Callable[[int, str], str]]" + + def test_settings_preserves_type(tmpdir): f = tmpdir.join("check_mypy_on_settings.py") f.write( @@ -155,6 +181,20 @@ def test_settings_preserves_type(tmpdir): assert got == "def (x: int) -> int" +def test_given_preserves_type(tmpdir): + f = tmpdir.join("check_mypy_on_given.py") + f.write( + "from hypothesis import given\n" + "from hypothesis.strategies import integers\n" + "@given(integers())\n" + "def f(x: int) -> int:\n" + " return x\n" + "reveal_type(f)\n" + ) + got = get_mypy_analysed_type(str(f.realpath()), ...) + assert got == "def (x: int) -> int" + + def test_stateful_bundle_generic_type(tmpdir): f = tmpdir.join("check_mypy_on_stateful_bundle.py") f.write( From fe48210d6d4411c48eb213818f2127426929c389 Mon Sep 17 00:00:00 2001 From: Gabe Joseph Date: Mon, 10 Jan 2022 19:11:41 -0700 Subject: [PATCH 4/7] Single shared ParamSpec P --- .../src/hypothesis/strategies/_internal/core.py | 10 ++-------- .../src/hypothesis/strategies/_internal/strategies.py | 3 +++ 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/hypothesis-python/src/hypothesis/strategies/_internal/core.py b/hypothesis-python/src/hypothesis/strategies/_internal/core.py index 98912de901..8d045703e3 100644 --- a/hypothesis-python/src/hypothesis/strategies/_internal/core.py +++ b/hypothesis-python/src/hypothesis/strategies/_internal/core.py @@ -43,7 +43,7 @@ from uuid import UUID import attr -from typing_extensions import Concatenate, ParamSpec +from typing_extensions import Concatenate from hypothesis.control import cleanup, note from hypothesis.errors import InvalidArgument, ResolutionFailed @@ -95,6 +95,7 @@ from hypothesis.strategies._internal.shared import SharedStrategy from hypothesis.strategies._internal.strategies import ( Ex, + P, SampledFromStrategy, T, one_of, @@ -1451,9 +1452,6 @@ def __call__(self, strategy: SearchStrategy[Ex], label: object = None) -> Ex: raise NotImplementedError -P = ParamSpec("P") - - @cacheable def composite( f: Callable[Concatenate[DrawFn, P], Ex] @@ -1853,10 +1851,6 @@ def emails() -> SearchStrategy[str]: ) -P = ParamSpec("P") -T = TypeVar("T") - - @defines_strategy() def functions( *, diff --git a/hypothesis-python/src/hypothesis/strategies/_internal/strategies.py b/hypothesis-python/src/hypothesis/strategies/_internal/strategies.py index 680e6b58ce..c3a50ef1fe 100644 --- a/hypothesis-python/src/hypothesis/strategies/_internal/strategies.py +++ b/hypothesis-python/src/hypothesis/strategies/_internal/strategies.py @@ -24,6 +24,8 @@ overload, ) +from typing_extensions import ParamSpec + from hypothesis._settings import HealthCheck, Phase, Verbosity, settings from hypothesis.control import _current_build_context, assume from hypothesis.errors import ( @@ -54,6 +56,7 @@ T3 = TypeVar("T3") T4 = TypeVar("T4") T5 = TypeVar("T5") +P = ParamSpec("P") calculating = UniqueIdentifier("calculating") From 3a19cc9678afa47b77c0f2b04f649d11094e4cc1 Mon Sep 17 00:00:00 2001 From: Gabe Joseph Date: Mon, 10 Jan 2022 19:18:45 -0700 Subject: [PATCH 5/7] missing newlines in test scripts --- whole-repo-tests/test_type_hints.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/whole-repo-tests/test_type_hints.py b/whole-repo-tests/test_type_hints.py index 749885e444..596fb94716 100644 --- a/whole-repo-tests/test_type_hints.py +++ b/whole-repo-tests/test_type_hints.py @@ -146,10 +146,10 @@ def test_composite_type_tracing(tmpdir): f = tmpdir.join("check_mypy_on_st_composite.py") f.write( "from hypothesis.strategies import composite, DrawFn\n" - "@composite" + "@composite\n" "def comp(draw: DrawFn, x: int) -> int:\n" " return x\n" - "reveal_type(comp)" + "reveal_type(comp)\n" ) got = get_mypy_analysed_type(str(f.realpath()), ...) assert got == "def (x: int) -> int" @@ -159,10 +159,10 @@ def test_functions_type_tracing(tmpdir): f = tmpdir.join("check_mypy_on_st_functions.py") f.write( "from hypothesis.strategies import functions\n" - "def like(x: int, y: str) -> str:" - " return str(x) + y" - "st = functions(like)" - "reveal_type(st)" + "def like(x: int, y: str) -> str:\n" + " return str(x) + y\n" + "st = functions(like)\n" + "reveal_type(st)\n" ) got = get_mypy_analysed_type(str(f.realpath()), ...) assert got == "SearchStrategy[Callable[[int, str], str]]" From 453636568fb2d1a9f288b1a7d5c8e113bc192058 Mon Sep 17 00:00:00 2001 From: Gabe Joseph Date: Mon, 10 Jan 2022 19:26:48 -0700 Subject: [PATCH 6/7] =?UTF-8?q?Remove=20`given`=20annotations=E2=80=94they?= =?UTF-8?q?=20were=20wrong?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `given` does consume the function's arguments (obviously), and ParamSpec isn't sophisticated enough to indicate that the _number_ of arguments to `given` is equal to the _number_ of arguments consumed from the wrapped function, even though the types of arguments given to `given` don't match the types of arguments of the function (you'd need to be able to unpack the type argument from the parametrized type somehow). This was the least important one to annotate anyway IMO. --- hypothesis-python/RELEASE.rst | 2 +- hypothesis-python/src/hypothesis/core.py | 6 +----- whole-repo-tests/test_type_hints.py | 14 -------------- 3 files changed, 2 insertions(+), 20 deletions(-) diff --git a/hypothesis-python/RELEASE.rst b/hypothesis-python/RELEASE.rst index b48108a1b3..590b76ffda 100644 --- a/hypothesis-python/RELEASE.rst +++ b/hypothesis-python/RELEASE.rst @@ -1,5 +1,5 @@ RELEASE_TYPE: minor -This preserves the type annotations of functions passed to :func:`hypothesis.strategies.composite`, :func:`hypothesis.strategies.functions`, and :func:`hypothesis.given` by using :obj:`python:typing.ParamSpec`. +This preserves the type annotations of functions passed to :func:`hypothesis.strategies.composite` and :func:`hypothesis.strategies.functions` by using :obj:`python:typing.ParamSpec`. This improves the ability of static type-checkers to check test code that uses Hypothesis, and improves auto-completion in IDEs. \ No newline at end of file diff --git a/hypothesis-python/src/hypothesis/core.py b/hypothesis-python/src/hypothesis/core.py index 13cf10b776..0dd11e3042 100644 --- a/hypothesis-python/src/hypothesis/core.py +++ b/hypothesis-python/src/hypothesis/core.py @@ -37,7 +37,6 @@ from unittest import TestCase import attr -from typing_extensions import ParamSpec from hypothesis import strategies as st from hypothesis._settings import ( @@ -949,13 +948,10 @@ def fuzz_one_input( return self.__cached_target -P = ParamSpec("P") - - def given( *_given_arguments: Union[SearchStrategy, InferType], **_given_kwargs: Union[SearchStrategy, InferType], -) -> Callable[[Callable[P, Union[None, Coroutine[Any, Any, None]]]], Callable[P, None]]: +) -> Callable[[Callable[..., Union[None, Coroutine[Any, Any, None]]]], Callable[..., None]]: """A decorator for turning a test function that accepts arguments into a randomized test. diff --git a/whole-repo-tests/test_type_hints.py b/whole-repo-tests/test_type_hints.py index 596fb94716..7fd318cb77 100644 --- a/whole-repo-tests/test_type_hints.py +++ b/whole-repo-tests/test_type_hints.py @@ -181,20 +181,6 @@ def test_settings_preserves_type(tmpdir): assert got == "def (x: int) -> int" -def test_given_preserves_type(tmpdir): - f = tmpdir.join("check_mypy_on_given.py") - f.write( - "from hypothesis import given\n" - "from hypothesis.strategies import integers\n" - "@given(integers())\n" - "def f(x: int) -> int:\n" - " return x\n" - "reveal_type(f)\n" - ) - got = get_mypy_analysed_type(str(f.realpath()), ...) - assert got == "def (x: int) -> int" - - def test_stateful_bundle_generic_type(tmpdir): f = tmpdir.join("check_mypy_on_stateful_bundle.py") f.write( From 75472b0f2da276f5695e4894eeb93d80dcc01a98 Mon Sep 17 00:00:00 2001 From: Gabe Joseph Date: Mon, 10 Jan 2022 19:30:42 -0700 Subject: [PATCH 7/7] Remove `functions` return-type annotation https://github.com/python/mypy/issues/3737 is still unfortunate :( --- .../src/hypothesis/strategies/_internal/core.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/hypothesis-python/src/hypothesis/strategies/_internal/core.py b/hypothesis-python/src/hypothesis/strategies/_internal/core.py index 8d045703e3..4e2225fdef 100644 --- a/hypothesis-python/src/hypothesis/strategies/_internal/core.py +++ b/hypothesis-python/src/hypothesis/strategies/_internal/core.py @@ -1854,10 +1854,10 @@ def emails() -> SearchStrategy[str]: @defines_strategy() def functions( *, - like: Callable[P, T] = lambda: None, - returns: Optional[SearchStrategy[T]] = None, + like: Callable[P, Any] = lambda: None, + returns: Optional[SearchStrategy[Any]] = None, pure: bool = False, -) -> SearchStrategy[Callable[P, T]]: +) -> SearchStrategy[Callable[P, Any]]: # The proper type signature of `functions()` would have T instead of Any, but mypy # disallows default args for generics: https://github.com/python/mypy/issues/3737 """functions(*, like=lambda: None, returns=none(), pure=False)