From 97d622300c7cd98ca9504d89cabfcf660be4439a Mon Sep 17 00:00:00 2001 From: Nicolas Ganz Date: Mon, 23 Jan 2023 09:14:48 +0100 Subject: [PATCH 01/11] fix ghostwriter annotation creation for Optional types --- .../src/hypothesis/extra/ghostwriter.py | 9 +++++++++ .../recorded/optional_parameter.txt | 11 +++++++++++ .../optional_parameter_pre_py_3_9.txt | 11 +++++++++++ .../recorded/optional_union_parameter.txt | 13 +++++++++++++ .../tests/ghostwriter/test_expected_output.py | 19 ++++++++++++++++++- 5 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 hypothesis-python/tests/ghostwriter/recorded/optional_parameter.txt create mode 100644 hypothesis-python/tests/ghostwriter/recorded/optional_parameter_pre_py_3_9.txt create mode 100644 hypothesis-python/tests/ghostwriter/recorded/optional_union_parameter.txt diff --git a/hypothesis-python/src/hypothesis/extra/ghostwriter.py b/hypothesis-python/src/hypothesis/extra/ghostwriter.py index 16d26c66b2..92ea038d3d 100644 --- a/hypothesis-python/src/hypothesis/extra/ghostwriter.py +++ b/hypothesis-python/src/hypothesis/extra/ghostwriter.py @@ -884,6 +884,15 @@ def _join_generics( if origin_type_data is None: return None + # because typing.Optional is converted to a Union, it also contains None + # since typing.Optional only accepts one type variable, we need to remove it + if origin_type_data is not None and origin_type_data[0] == "typing.Optional": + annotations = ( + annotation + for annotation in annotations + if annotation is None or annotation.type_name != "None" + ) + origin_type, imports = origin_type_data joined = _join_argument_annotations(annotations) if joined is None or not joined[0]: diff --git a/hypothesis-python/tests/ghostwriter/recorded/optional_parameter.txt b/hypothesis-python/tests/ghostwriter/recorded/optional_parameter.txt new file mode 100644 index 0000000000..84cef04fa9 --- /dev/null +++ b/hypothesis-python/tests/ghostwriter/recorded/optional_parameter.txt @@ -0,0 +1,11 @@ +# This test code was written by the `hypothesis.extra.ghostwriter` module +# and is provided under the Creative Commons Zero public domain dedication. + +import test_expected_output +import typing +from hypothesis import given, strategies as st + + +@given(a=st.floats(), b=st.one_of(st.none(), st.floats())) +def test_fuzz_optional_parameter(a: float, b: typing.Optional[float]) -> None: + test_expected_output.optional_parameter(a=a, b=b) diff --git a/hypothesis-python/tests/ghostwriter/recorded/optional_parameter_pre_py_3_9.txt b/hypothesis-python/tests/ghostwriter/recorded/optional_parameter_pre_py_3_9.txt new file mode 100644 index 0000000000..33492768e2 --- /dev/null +++ b/hypothesis-python/tests/ghostwriter/recorded/optional_parameter_pre_py_3_9.txt @@ -0,0 +1,11 @@ +# This test code was written by the `hypothesis.extra.ghostwriter` module +# and is provided under the Creative Commons Zero public domain dedication. + +import test_expected_output +import typing +from hypothesis import given, strategies as st + + +@given(a=st.floats(), b=st.one_of(st.none(), st.floats())) +def test_fuzz_optional_parameter(a: float, b: typing.Union[float, None]) -> None: + test_expected_output.optional_parameter(a=a, b=b) diff --git a/hypothesis-python/tests/ghostwriter/recorded/optional_union_parameter.txt b/hypothesis-python/tests/ghostwriter/recorded/optional_union_parameter.txt new file mode 100644 index 0000000000..8715866234 --- /dev/null +++ b/hypothesis-python/tests/ghostwriter/recorded/optional_union_parameter.txt @@ -0,0 +1,13 @@ +# This test code was written by the `hypothesis.extra.ghostwriter` module +# and is provided under the Creative Commons Zero public domain dedication. + +import test_expected_output +import typing +from hypothesis import given, strategies as st + + +@given(a=st.floats(), b=st.one_of(st.none(), st.floats(), st.integers())) +def test_fuzz_optional_union_parameter( + a: float, b: typing.Union[float, int, None] +) -> None: + test_expected_output.optional_union_parameter(a=a, b=b) diff --git a/hypothesis-python/tests/ghostwriter/test_expected_output.py b/hypothesis-python/tests/ghostwriter/test_expected_output.py index 09b7636a12..3e68c6cc7c 100644 --- a/hypothesis-python/tests/ghostwriter/test_expected_output.py +++ b/hypothesis-python/tests/ghostwriter/test_expected_output.py @@ -21,7 +21,7 @@ import pathlib import re import sys -from typing import Sequence +from typing import Optional, Sequence, Union import numpy import pytest @@ -82,6 +82,14 @@ def divide(a: int, b: int) -> float: return a / b +def optional_parameter(a: float, b: Optional[float]) -> float: + return optional_union_parameter(a, b) + + +def optional_union_parameter(a: float, b: Optional[Union[float, int]]) -> float: + return a if b is None else a + b + + # Note: for some of the `expected` outputs, we replace away some small # parts which vary between minor versions of Python. @pytest.mark.parametrize( @@ -94,6 +102,15 @@ def divide(a: int, b: int) -> float: ("fuzz_staticmethod", ghostwriter.fuzz(A_Class.a_staticmethod)), ("fuzz_ufunc", ghostwriter.fuzz(numpy.add)), ("magic_gufunc", ghostwriter.magic(numpy.matmul)), + pytest.param( + ("optional_parameter", ghostwriter.magic(optional_parameter)), + marks=pytest.mark.skipif("sys.version_info[:2] < (3, 9)"), + ), + pytest.param( + ("optional_parameter_pre_py_3_9", ghostwriter.magic(optional_parameter)), + marks=pytest.mark.skipif("sys.version_info[:2] >= (3, 9)"), + ), + ("optional_union_parameter", ghostwriter.magic(optional_union_parameter)), ("magic_base64_roundtrip", ghostwriter.magic(base64.b64encode)), ( "magic_base64_roundtrip_with_annotations", From fa532b2fb1c38e1f63048e8e68e669e7cc8a91de Mon Sep 17 00:00:00 2001 From: Nicolas Ganz Date: Mon, 23 Jan 2023 09:26:19 +0100 Subject: [PATCH 02/11] fix ghostwriter annotation creation for UnionType expressions --- .../src/hypothesis/extra/ghostwriter.py | 11 ++++++++--- .../recorded/union_sequence_parameter.txt | 13 +++++++++++++ .../tests/ghostwriter/test_expected_output.py | 12 ++++++++++++ 3 files changed, 33 insertions(+), 3 deletions(-) create mode 100644 hypothesis-python/tests/ghostwriter/recorded/union_sequence_parameter.txt diff --git a/hypothesis-python/src/hypothesis/extra/ghostwriter.py b/hypothesis-python/src/hypothesis/extra/ghostwriter.py index 92ea038d3d..aca2b97923 100644 --- a/hypothesis-python/src/hypothesis/extra/ghostwriter.py +++ b/hypothesis-python/src/hypothesis/extra/ghostwriter.py @@ -935,9 +935,14 @@ def _parameter_to_annotation(parameter: Any) -> Optional[_AnnotationData]: "None" if parameter.__name__ == "NoneType" else parameter.__name__, set(), ) - return _AnnotationData( - f"{parameter.__module__}.{parameter.__name__}", {parameter.__module__} - ) + + full_name = f"{parameter.__module__}.{parameter.__name__}" + + # the types.UnionType does not support type arguments and needs to be translated + if full_name == "types.UnionType": + return _AnnotationData("typing.Union", {"typing"}) + + return _AnnotationData(full_name, {parameter.__module__}) # the arguments of Callable are in a list if isinstance(parameter, list): diff --git a/hypothesis-python/tests/ghostwriter/recorded/union_sequence_parameter.txt b/hypothesis-python/tests/ghostwriter/recorded/union_sequence_parameter.txt new file mode 100644 index 0000000000..36b4b36c9e --- /dev/null +++ b/hypothesis-python/tests/ghostwriter/recorded/union_sequence_parameter.txt @@ -0,0 +1,13 @@ +# This test code was written by the `hypothesis.extra.ghostwriter` module +# and is provided under the Creative Commons Zero public domain dedication. + +import test_expected_output +import typing +from hypothesis import given, strategies as st + + +@given(items=st.one_of(st.binary(), st.lists(st.one_of(st.floats(), st.integers())))) +def test_fuzz_union_sequence_parameter( + items: typing.Sequence[typing.Union[float, int]] +) -> None: + test_expected_output.union_sequence_parameter(items=items) diff --git a/hypothesis-python/tests/ghostwriter/test_expected_output.py b/hypothesis-python/tests/ghostwriter/test_expected_output.py index 3e68c6cc7c..6637f562b5 100644 --- a/hypothesis-python/tests/ghostwriter/test_expected_output.py +++ b/hypothesis-python/tests/ghostwriter/test_expected_output.py @@ -90,6 +90,17 @@ def optional_union_parameter(a: float, b: Optional[Union[float, int]]) -> float: return a if b is None else a + b +if sys.version_info[:2] >= (3, 10): + + def union_sequence_parameter(items: Sequence[float | int]) -> float: + return sum(items) + +else: + + def union_sequence_parameter(items: Sequence[Union[float, int]]) -> float: + return sum(items) + + # Note: for some of the `expected` outputs, we replace away some small # parts which vary between minor versions of Python. @pytest.mark.parametrize( @@ -111,6 +122,7 @@ def optional_union_parameter(a: float, b: Optional[Union[float, int]]) -> float: marks=pytest.mark.skipif("sys.version_info[:2] >= (3, 9)"), ), ("optional_union_parameter", ghostwriter.magic(optional_union_parameter)), + ("union_sequence_parameter", ghostwriter.magic(union_sequence_parameter)), ("magic_base64_roundtrip", ghostwriter.magic(base64.b64encode)), ( "magic_base64_roundtrip_with_annotations", From 3bb0636110bbf83e8d10f6a999b026ab0cea7de0 Mon Sep 17 00:00:00 2001 From: Nicolas Ganz Date: Wed, 25 Jan 2023 10:55:13 +0100 Subject: [PATCH 03/11] handle writing annotations if future annotations are used --- .../src/hypothesis/extra/ghostwriter.py | 44 ++++++++++--------- .../src/hypothesis/internal/reflection.py | 24 +++++++++- .../ghostwriter/example_code/__init__.py | 9 ++++ .../example_code/future_annotations.py | 30 +++++++++++++ .../recorded/add_custom_classes.txt | 15 +++++++ .../recorded/hypothesis_module_magic.txt | 5 ++- .../ghostwriter/recorded/merge_dicts.txt | 23 ++++++++++ .../tests/ghostwriter/test_expected_output.py | 9 ++++ 8 files changed, 135 insertions(+), 24 deletions(-) create mode 100644 hypothesis-python/tests/ghostwriter/example_code/__init__.py create mode 100644 hypothesis-python/tests/ghostwriter/example_code/future_annotations.py create mode 100644 hypothesis-python/tests/ghostwriter/recorded/add_custom_classes.txt create mode 100644 hypothesis-python/tests/ghostwriter/recorded/merge_dicts.txt diff --git a/hypothesis-python/src/hypothesis/extra/ghostwriter.py b/hypothesis-python/src/hypothesis/extra/ghostwriter.py index aca2b97923..dba6aa8cbb 100644 --- a/hypothesis-python/src/hypothesis/extra/ghostwriter.py +++ b/hypothesis-python/src/hypothesis/extra/ghostwriter.py @@ -443,11 +443,11 @@ def _guess_strategy_by_argname(name: str) -> st.SearchStrategy: return st.nothing() -def _get_params(func: Callable) -> Dict[str, inspect.Parameter]: +def _get_params(func: Callable, eval_str: bool = False) -> Dict[str, inspect.Parameter]: """Get non-vararg parameters of `func` as an ordered dict.""" var_param_kinds = (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD) try: - params = list(get_signature(func).parameters.values()) + params = list(get_signature(func, eval_str=eval_str).parameters.values()) except Exception: if ( isinstance(func, (types.BuiltinFunctionType, types.BuiltinMethodType)) @@ -831,7 +831,7 @@ def _annotate_args( ) -> Iterable[str]: arg_parameters: DefaultDict[str, Set[Any]] = defaultdict(set) for func in funcs: - for key, param in _get_params(func).items(): + for key, param in _get_params(func, eval_str=True).items(): arg_parameters[key].add(param.annotation) for argname in argnames: @@ -929,6 +929,16 @@ def _parameter_to_annotation(parameter: Any) -> Optional[_AnnotationData]: return None return _parameter_to_annotation(forwarded_value) + # the arguments of Callable are in a list + if isinstance(parameter, list): + joined = _join_argument_annotations( + _parameter_to_annotation(param) for param in parameter + ) + if joined is None: + return None + arg_type_names, new_imports = joined + return _AnnotationData("[{}]".format(", ".join(arg_type_names)), new_imports) + if isinstance(parameter, type): if parameter.__module__ == "builtins": return _AnnotationData( @@ -936,33 +946,24 @@ def _parameter_to_annotation(parameter: Any) -> Optional[_AnnotationData]: set(), ) - full_name = f"{parameter.__module__}.{parameter.__name__}" + type_name = f"{parameter.__module__}.{parameter.__name__}" # the types.UnionType does not support type arguments and needs to be translated - if full_name == "types.UnionType": + if type_name == "types.UnionType": return _AnnotationData("typing.Union", {"typing"}) - - return _AnnotationData(full_name, {parameter.__module__}) - - # the arguments of Callable are in a list - if isinstance(parameter, list): - joined = _join_argument_annotations(map(_parameter_to_annotation, parameter)) - if joined is None: - return None - arg_type_names, new_imports = joined - return _AnnotationData("[{}]".format(", ".join(arg_type_names)), new_imports) + else: + if hasattr(parameter, "__module__") and hasattr(parameter, "__name__"): + type_name = f"{parameter.__module__}.{parameter.__name__}" + else: + type_name = str(parameter) origin_type = get_origin(parameter) # if not generic or no generic arguments if origin_type is None or origin_type == parameter: - type_name = str(parameter) - if type_name.startswith("typing."): - return _AnnotationData(type_name, {"typing"}) - return _AnnotationData(type_name, set()) + return _AnnotationData(type_name, set(type_name.rsplit(".", maxsplit=1)[:-1])) arg_types = get_args(parameter) - type_name = str(parameter) # typing types get translated to classes that don't support generics origin_annotation: Optional[_AnnotationData] @@ -977,7 +978,8 @@ def _parameter_to_annotation(parameter: Any) -> Optional[_AnnotationData]: if arg_types: return _join_generics( - origin_annotation, map(_parameter_to_annotation, arg_types) + origin_annotation, + (_parameter_to_annotation(arg_type) for arg_type in arg_types), ) return origin_annotation diff --git a/hypothesis-python/src/hypothesis/internal/reflection.py b/hypothesis-python/src/hypothesis/internal/reflection.py index 5e66b44711..9b7ca7462d 100644 --- a/hypothesis-python/src/hypothesis/internal/reflection.py +++ b/hypothesis-python/src/hypothesis/internal/reflection.py @@ -127,7 +127,9 @@ def check_signature(sig: inspect.Signature) -> None: ) -def get_signature(target: Any, *, follow_wrapped: bool = True) -> inspect.Signature: +def get_signature( + target: Any, *, follow_wrapped: bool = True, eval_str: bool = False +) -> inspect.Signature: # Special case for use of `@unittest.mock.patch` decorator, mimicking the # behaviour of getfullargspec instead of reporting unusable arguments. patches = getattr(target, "patchings", None) @@ -164,11 +166,29 @@ def get_signature(target: Any, *, follow_wrapped: bool = True) -> inspect.Signat return sig.replace( parameters=[v for k, v in sig.parameters.items() if k != "self"] ) - sig = inspect.signature(target, follow_wrapped=follow_wrapped) + sig = _inspect_signature(target, follow_wrapped=follow_wrapped, eval_str=eval_str) check_signature(sig) return sig +# eval_str is only supported by Python 3.10 and newer +if sys.version_info[:2] >= (3, 10): + + def _inspect_signature( + target: Any, *, follow_wrapped: bool = True, eval_str: bool = False + ) -> inspect.Signature: + return inspect.signature( + target, follow_wrapped=follow_wrapped, eval_str=eval_str + ) + +else: + + def _inspect_signature( + target: Any, *, follow_wrapped: bool = True, eval_str: bool = False + ) -> inspect.Signature: + return inspect.signature(target, follow_wrapped=follow_wrapped) + + def arg_is_required(param): return param.default is inspect.Parameter.empty and param.kind in ( inspect.Parameter.POSITIONAL_OR_KEYWORD, diff --git a/hypothesis-python/tests/ghostwriter/example_code/__init__.py b/hypothesis-python/tests/ghostwriter/example_code/__init__.py new file mode 100644 index 0000000000..fcb1ac6538 --- /dev/null +++ b/hypothesis-python/tests/ghostwriter/example_code/__init__.py @@ -0,0 +1,9 @@ +# This file is part of Hypothesis, which may be found at +# https://github.com/HypothesisWorks/hypothesis/ +# +# Copyright the Hypothesis Authors. +# Individual contributors are listed in AUTHORS.rst and the git log. +# +# This Source Code Form is subject to the terms of the Mozilla Public License, +# v. 2.0. If a copy of the MPL was not distributed with this file, You can +# obtain one at https://mozilla.org/MPL/2.0/. diff --git a/hypothesis-python/tests/ghostwriter/example_code/future_annotations.py b/hypothesis-python/tests/ghostwriter/example_code/future_annotations.py new file mode 100644 index 0000000000..88c8e65e34 --- /dev/null +++ b/hypothesis-python/tests/ghostwriter/example_code/future_annotations.py @@ -0,0 +1,30 @@ +# This file is part of Hypothesis, which may be found at +# https://github.com/HypothesisWorks/hypothesis/ +# +# Copyright the Hypothesis Authors. +# Individual contributors are listed in AUTHORS.rst and the git log. +# +# This Source Code Form is subject to the terms of the Mozilla Public License, +# v. 2.0. If a copy of the MPL was not distributed with this file, You can +# obtain one at https://mozilla.org/MPL/2.0/. + +from __future__ import annotations + +import collections.abc + + +class CustomClass: + def __init__(self, number: int) -> None: + self.number = number + + +def add_custom_classes(c1: CustomClass, c2: CustomClass | None = None) -> CustomClass: + if c2 is None: + return CustomClass(c1.number) + return CustomClass(c1.number + c2.number) + + +def merge_dicts( + map1: collections.abc.Mapping[str, int], map2: collections.abc.Mapping[str, int] +) -> collections.abc.Mapping[str, int]: + return {**map1, **map2} diff --git a/hypothesis-python/tests/ghostwriter/recorded/add_custom_classes.txt b/hypothesis-python/tests/ghostwriter/recorded/add_custom_classes.txt new file mode 100644 index 0000000000..e315456ec4 --- /dev/null +++ b/hypothesis-python/tests/ghostwriter/recorded/add_custom_classes.txt @@ -0,0 +1,15 @@ +# This test code was written by the `hypothesis.extra.ghostwriter` module +# and is provided under the Creative Commons Zero public domain dedication. + +import example_code.future_annotations +import typing +from example_code.future_annotations import CustomClass +from hypothesis import given, strategies as st + + +@given(c1=st.builds(CustomClass), c2=st.one_of(st.none(), st.builds(CustomClass))) +def test_fuzz_add_custom_classes( + c1: example_code.future_annotations.CustomClass, + c2: typing.Union[example_code.future_annotations.CustomClass, None], +) -> None: + example_code.future_annotations.add_custom_classes(c1=c1, c2=c2) diff --git a/hypothesis-python/tests/ghostwriter/recorded/hypothesis_module_magic.txt b/hypothesis-python/tests/ghostwriter/recorded/hypothesis_module_magic.txt index 6904a6afd5..75ddec6769 100644 --- a/hypothesis-python/tests/ghostwriter/recorded/hypothesis_module_magic.txt +++ b/hypothesis-python/tests/ghostwriter/recorded/hypothesis_module_magic.txt @@ -4,6 +4,7 @@ import datetime import hypothesis import hypothesis.strategies +import hypothesis.strategies._internal.strategies import random import typing from hypothesis import given, settings, strategies as st @@ -29,7 +30,9 @@ def test_fuzz_event(value: str) -> None: database_key=st.one_of(st.none(), st.binary()), ) def test_fuzz_find( - specifier: hypothesis.strategies.SearchStrategy, + specifier: hypothesis.strategies.SearchStrategy[ + hypothesis.strategies._internal.strategies.Ex + ], condition: typing.Callable[[typing.Any], bool], settings: typing.Union[hypothesis.settings, None], random: typing.Union[random.Random, None], diff --git a/hypothesis-python/tests/ghostwriter/recorded/merge_dicts.txt b/hypothesis-python/tests/ghostwriter/recorded/merge_dicts.txt new file mode 100644 index 0000000000..f3d3d9e24b --- /dev/null +++ b/hypothesis-python/tests/ghostwriter/recorded/merge_dicts.txt @@ -0,0 +1,23 @@ +# This test code was written by the `hypothesis.extra.ghostwriter` module +# and is provided under the Creative Commons Zero public domain dedication. + +import collections.abc +import example_code.future_annotations +from collections import ChainMap +from hypothesis import given, strategies as st + + +@given( + map1=st.one_of( + st.dictionaries(keys=st.text(), values=st.integers()), + st.dictionaries(keys=st.text(), values=st.integers()).map(ChainMap), + ), + map2=st.one_of( + st.dictionaries(keys=st.text(), values=st.integers()), + st.dictionaries(keys=st.text(), values=st.integers()).map(ChainMap), + ), +) +def test_fuzz_merge_dicts( + map1: collections.abc.Mapping[str, int], map2: collections.abc.Mapping[str, int] +) -> None: + example_code.future_annotations.merge_dicts(map1=map1, map2=map2) diff --git a/hypothesis-python/tests/ghostwriter/test_expected_output.py b/hypothesis-python/tests/ghostwriter/test_expected_output.py index 6637f562b5..ab2151c45f 100644 --- a/hypothesis-python/tests/ghostwriter/test_expected_output.py +++ b/hypothesis-python/tests/ghostwriter/test_expected_output.py @@ -25,6 +25,7 @@ import numpy import pytest +from example_code.future_annotations import add_custom_classes, merge_dicts import hypothesis from hypothesis.extra import ghostwriter @@ -123,6 +124,14 @@ def union_sequence_parameter(items: Sequence[Union[float, int]]) -> float: ), ("optional_union_parameter", ghostwriter.magic(optional_union_parameter)), ("union_sequence_parameter", ghostwriter.magic(union_sequence_parameter)), + pytest.param( + ("add_custom_classes", ghostwriter.magic(add_custom_classes)), + marks=pytest.mark.skipif("sys.version_info[:2] < (3, 10)"), + ), + pytest.param( + ("merge_dicts", ghostwriter.magic(merge_dicts)), + marks=pytest.mark.skipif("sys.version_info[:2] < (3, 10)"), + ), ("magic_base64_roundtrip", ghostwriter.magic(base64.b64encode)), ( "magic_base64_roundtrip_with_annotations", From 577fccc79586999b3adf4bead2dc8d429bf01aa2 Mon Sep 17 00:00:00 2001 From: Nicolas Ganz Date: Wed, 25 Jan 2023 10:55:28 +0100 Subject: [PATCH 04/11] added release file for fixes to the ghostwriter annotation detection --- hypothesis-python/RELEASE.rst | 7 +++++++ 1 file changed, 7 insertions(+) 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..f511e7f937 --- /dev/null +++ b/hypothesis-python/RELEASE.rst @@ -0,0 +1,7 @@ +RELEASE_TYPE: patch + +This patch fixes invalid annotations detected for the tests generated by +:doc:`Ghostwritter `. It will now correctly generate `Optional` +types with just one type argument and handle union expressions inside of type +arguments correctly. Additionally, it now supports code with the +`from __future__ import annotations` marker for Python 3.10 and newer. From 0eb8f3ade2d96374daedf30a02b0b6a18d7627f7 Mon Sep 17 00:00:00 2001 From: Nicolas Ganz Date: Wed, 25 Jan 2023 11:43:25 +0100 Subject: [PATCH 05/11] ignore the coverage of the inspect signature function for older python versions --- hypothesis-python/src/hypothesis/internal/reflection.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hypothesis-python/src/hypothesis/internal/reflection.py b/hypothesis-python/src/hypothesis/internal/reflection.py index 9b7ca7462d..afaf126279 100644 --- a/hypothesis-python/src/hypothesis/internal/reflection.py +++ b/hypothesis-python/src/hypothesis/internal/reflection.py @@ -185,7 +185,7 @@ def _inspect_signature( def _inspect_signature( target: Any, *, follow_wrapped: bool = True, eval_str: bool = False - ) -> inspect.Signature: + ) -> inspect.Signature: # pragma: no cover return inspect.signature(target, follow_wrapped=follow_wrapped) From a7ec996a94fd565b2d3ee4bc216c7eb49e420028 Mon Sep 17 00:00:00 2001 From: Zac Hatfield-Dodds Date: Thu, 26 Jan 2023 10:49:25 +1100 Subject: [PATCH 06/11] Update hypothesis-python/RELEASE.rst --- hypothesis-python/RELEASE.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/hypothesis-python/RELEASE.rst b/hypothesis-python/RELEASE.rst index f511e7f937..f2e39c1792 100644 --- a/hypothesis-python/RELEASE.rst +++ b/hypothesis-python/RELEASE.rst @@ -1,7 +1,7 @@ RELEASE_TYPE: patch This patch fixes invalid annotations detected for the tests generated by -:doc:`Ghostwritter `. It will now correctly generate `Optional` +:doc:`Ghostwritter `. It will now correctly generate ``Optional`` types with just one type argument and handle union expressions inside of type arguments correctly. Additionally, it now supports code with the -`from __future__ import annotations` marker for Python 3.10 and newer. +``from __future__ import annotations`` marker for Python 3.10 and newer. From 38ac9d9abd27f7c650146d180cfbd471fd9c3184 Mon Sep 17 00:00:00 2001 From: Nicolas Ganz Date: Thu, 26 Jan 2023 13:07:30 +0100 Subject: [PATCH 07/11] simplified using eval_str for python >= 3.10 --- .../src/hypothesis/internal/reflection.py | 26 +++++-------------- 1 file changed, 7 insertions(+), 19 deletions(-) diff --git a/hypothesis-python/src/hypothesis/internal/reflection.py b/hypothesis-python/src/hypothesis/internal/reflection.py index afaf126279..163185d73e 100644 --- a/hypothesis-python/src/hypothesis/internal/reflection.py +++ b/hypothesis-python/src/hypothesis/internal/reflection.py @@ -166,27 +166,15 @@ def get_signature( return sig.replace( parameters=[v for k, v in sig.parameters.items() if k != "self"] ) - sig = _inspect_signature(target, follow_wrapped=follow_wrapped, eval_str=eval_str) - check_signature(sig) - return sig - - -# eval_str is only supported by Python 3.10 and newer -if sys.version_info[:2] >= (3, 10): - - def _inspect_signature( - target: Any, *, follow_wrapped: bool = True, eval_str: bool = False - ) -> inspect.Signature: - return inspect.signature( + # eval_str is only supported by Python 3.10 and newer + if sys.version_info[:2] >= (3, 10): + sig = inspect.signature( target, follow_wrapped=follow_wrapped, eval_str=eval_str ) - -else: - - def _inspect_signature( - target: Any, *, follow_wrapped: bool = True, eval_str: bool = False - ) -> inspect.Signature: # pragma: no cover - return inspect.signature(target, follow_wrapped=follow_wrapped) + else: + sig = inspect.signature(target, follow_wrapped=follow_wrapped) + check_signature(sig) + return sig def arg_is_required(param): From f5e4ee4e6ddf8dfdea75f3a46c387d9df9157995 Mon Sep 17 00:00:00 2001 From: Nicolas Ganz Date: Thu, 26 Jan 2023 13:23:58 +0100 Subject: [PATCH 08/11] if there is a syntax error in the annotations the should be ignored for the function --- .../src/hypothesis/extra/ghostwriter.py | 10 ++++++++-- .../ghostwriter/example_code/future_annotations.py | 4 ++++ .../tests/ghostwriter/recorded/invalid_types.txt | 12 ++++++++++++ .../tests/ghostwriter/test_expected_output.py | 10 +++++++++- 4 files changed, 33 insertions(+), 3 deletions(-) create mode 100644 hypothesis-python/tests/ghostwriter/recorded/invalid_types.txt diff --git a/hypothesis-python/src/hypothesis/extra/ghostwriter.py b/hypothesis-python/src/hypothesis/extra/ghostwriter.py index dba6aa8cbb..eb24835626 100644 --- a/hypothesis-python/src/hypothesis/extra/ghostwriter.py +++ b/hypothesis-python/src/hypothesis/extra/ghostwriter.py @@ -831,8 +831,14 @@ def _annotate_args( ) -> Iterable[str]: arg_parameters: DefaultDict[str, Set[Any]] = defaultdict(set) for func in funcs: - for key, param in _get_params(func, eval_str=True).items(): - arg_parameters[key].add(param.annotation) + try: + params = _get_params(func, eval_str=True) + except Exception: + # don't add parameters if the annotations could not be evaluated + pass + else: + for key, param in params.items(): + arg_parameters[key].add(param.annotation) for argname in argnames: parameters = arg_parameters.get(argname) diff --git a/hypothesis-python/tests/ghostwriter/example_code/future_annotations.py b/hypothesis-python/tests/ghostwriter/example_code/future_annotations.py index 88c8e65e34..b2a1976db4 100644 --- a/hypothesis-python/tests/ghostwriter/example_code/future_annotations.py +++ b/hypothesis-python/tests/ghostwriter/example_code/future_annotations.py @@ -28,3 +28,7 @@ def merge_dicts( map1: collections.abc.Mapping[str, int], map2: collections.abc.Mapping[str, int] ) -> collections.abc.Mapping[str, int]: return {**map1, **map2} + + +def invalid_types(attr1: int, attr2: UnknownClass, attr3: str) -> None: + pass diff --git a/hypothesis-python/tests/ghostwriter/recorded/invalid_types.txt b/hypothesis-python/tests/ghostwriter/recorded/invalid_types.txt new file mode 100644 index 0000000000..930a9d3de8 --- /dev/null +++ b/hypothesis-python/tests/ghostwriter/recorded/invalid_types.txt @@ -0,0 +1,12 @@ +# This test code was written by the `hypothesis.extra.ghostwriter` module +# and is provided under the Creative Commons Zero public domain dedication. + +import example_code.future_annotations +from hypothesis import given, strategies as st + +# TODO: replace st.nothing() with appropriate strategies + + +@given(attr1=st.nothing(), attr2=st.nothing(), attr3=st.nothing()) +def test_fuzz_invalid_types(attr1, attr2, attr3) -> None: + example_code.future_annotations.invalid_types(attr1=attr1, attr2=attr2, attr3=attr3) diff --git a/hypothesis-python/tests/ghostwriter/test_expected_output.py b/hypothesis-python/tests/ghostwriter/test_expected_output.py index ab2151c45f..60a3c4f4d9 100644 --- a/hypothesis-python/tests/ghostwriter/test_expected_output.py +++ b/hypothesis-python/tests/ghostwriter/test_expected_output.py @@ -25,7 +25,11 @@ import numpy import pytest -from example_code.future_annotations import add_custom_classes, merge_dicts +from example_code.future_annotations import ( + add_custom_classes, + invalid_types, + merge_dicts, +) import hypothesis from hypothesis.extra import ghostwriter @@ -132,6 +136,10 @@ def union_sequence_parameter(items: Sequence[Union[float, int]]) -> float: ("merge_dicts", ghostwriter.magic(merge_dicts)), marks=pytest.mark.skipif("sys.version_info[:2] < (3, 10)"), ), + pytest.param( + ("invalid_types", ghostwriter.magic(invalid_types)), + marks=pytest.mark.skipif("sys.version_info[:2] < (3, 10)"), + ), ("magic_base64_roundtrip", ghostwriter.magic(base64.b64encode)), ( "magic_base64_roundtrip_with_annotations", From 736eb0fc975b0aa1a8c03895e977112a74df11a9 Mon Sep 17 00:00:00 2001 From: Nicolas Ganz Date: Thu, 26 Jan 2023 14:53:43 +0100 Subject: [PATCH 09/11] added missing no cover comment --- hypothesis-python/src/hypothesis/internal/reflection.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/hypothesis-python/src/hypothesis/internal/reflection.py b/hypothesis-python/src/hypothesis/internal/reflection.py index 163185d73e..f64b527e45 100644 --- a/hypothesis-python/src/hypothesis/internal/reflection.py +++ b/hypothesis-python/src/hypothesis/internal/reflection.py @@ -172,7 +172,9 @@ def get_signature( target, follow_wrapped=follow_wrapped, eval_str=eval_str ) else: - sig = inspect.signature(target, follow_wrapped=follow_wrapped) + sig = inspect.signature( + target, follow_wrapped=follow_wrapped + ) # pragma: no cover check_signature(sig) return sig From f7ed6951e55930390e69f7081307c8d6ac57bffa Mon Sep 17 00:00:00 2001 From: Nicolas Ganz Date: Thu, 26 Jan 2023 16:05:00 +0100 Subject: [PATCH 10/11] dont use _get_params for detecting the annotations, since the fallbacks dont have any annotations --- .../src/hypothesis/extra/ghostwriter.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/hypothesis-python/src/hypothesis/extra/ghostwriter.py b/hypothesis-python/src/hypothesis/extra/ghostwriter.py index eb24835626..2ef9f6b384 100644 --- a/hypothesis-python/src/hypothesis/extra/ghostwriter.py +++ b/hypothesis-python/src/hypothesis/extra/ghostwriter.py @@ -443,11 +443,10 @@ def _guess_strategy_by_argname(name: str) -> st.SearchStrategy: return st.nothing() -def _get_params(func: Callable, eval_str: bool = False) -> Dict[str, inspect.Parameter]: +def _get_params(func: Callable) -> Dict[str, inspect.Parameter]: """Get non-vararg parameters of `func` as an ordered dict.""" - var_param_kinds = (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD) try: - params = list(get_signature(func, eval_str=eval_str).parameters.values()) + params = list(get_signature(func).parameters.values()) except Exception: if ( isinstance(func, (types.BuiltinFunctionType, types.BuiltinMethodType)) @@ -493,6 +492,13 @@ def _get_params(func: Callable, eval_str: bool = False) -> Dict[str, inspect.Par # If we haven't managed to recover a signature through the tricks above, # we're out of ideas and should just re-raise the exception. raise + return _params_to_dict(params) + + +def _params_to_dict( + params: Iterable[inspect.Parameter], +) -> Dict[str, inspect.Parameter]: + var_param_kinds = (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD) return OrderedDict((p.name, p) for p in params if p.kind not in var_param_kinds) @@ -832,12 +838,12 @@ def _annotate_args( arg_parameters: DefaultDict[str, Set[Any]] = defaultdict(set) for func in funcs: try: - params = _get_params(func, eval_str=True) + params = tuple(get_signature(func, eval_str=True).parameters.values()) except Exception: # don't add parameters if the annotations could not be evaluated pass else: - for key, param in params.items(): + for key, param in _params_to_dict(params).items(): arg_parameters[key].add(param.annotation) for argname in argnames: From 8a1cd94aaa130d3aa843a4069fe861debc46e91a Mon Sep 17 00:00:00 2001 From: Nicolas Ganz Date: Thu, 26 Jan 2023 16:05:36 +0100 Subject: [PATCH 11/11] filter the empty annotations earlier --- .../src/hypothesis/extra/ghostwriter.py | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/hypothesis-python/src/hypothesis/extra/ghostwriter.py b/hypothesis-python/src/hypothesis/extra/ghostwriter.py index 2ef9f6b384..312c181335 100644 --- a/hypothesis-python/src/hypothesis/extra/ghostwriter.py +++ b/hypothesis-python/src/hypothesis/extra/ghostwriter.py @@ -844,15 +844,12 @@ def _annotate_args( pass else: for key, param in _params_to_dict(params).items(): - arg_parameters[key].add(param.annotation) + if param.annotation != inspect.Parameter.empty: + arg_parameters[key].add(param.annotation) for argname in argnames: parameters = arg_parameters.get(argname) - annotation = ( - None - if parameters is None - else _parameters_to_annotation_name(parameters, imports) - ) + annotation = _parameters_to_annotation_name(parameters, imports) if annotation is None: yield argname else: @@ -865,15 +862,13 @@ class _AnnotationData(NamedTuple): def _parameters_to_annotation_name( - parameters: Iterable[Any], imports: ImportSet + parameters: Optional[Iterable[Any]], imports: ImportSet ) -> Optional[str]: + if parameters is None: + return None annotations = tuple( annotation - for annotation in ( - _parameter_to_annotation(parameter) - for parameter in parameters - if parameter != inspect.Parameter.empty - ) + for annotation in map(_parameter_to_annotation, parameters) if annotation is not None ) if not annotations: