Repository: HypothesisWorks/hypothesis

Issue:
settings decorator produces mypy error in strict mode: "untyped decorator makes function untyped"
Using the `@settings` decorator on a test function results in a mypy error in strict mode:

> Untyped decorator makes function "..." untyped  [misc]

This is caused by the missing type annotation for `hypothesis._settings.settings.__call__`:

```python
    def __call__(self, test):
```

This function always returns its argument, so an easy fix would be to use a `TypeVar`; 

```python
    def __call__(self, test: T) -> T:
```

**Reproduction**

```python
# test_settings.py
from hypothesis import given
from hypothesis import settings
from hypothesis import strategies


@given(number=strategies.integers())
@settings(max_examples=10)
def test_settings(number: int) -> None:
    pass
```

```sh
virtualenv venv
. venv/bin/activate
pip install hypothesis
pip install pytest
pip install mypy
```

```sh
$ venv/bin/mypy --strict --pretty --show-error-codes --show-column-numbers test_settings.py 
test_settings.py:7:2: error: Untyped decorator makes function "test_settings" untyped  [misc]
    @settings(max_examples=10)
     ^
Found 1 error in 1 file (checked 1 source file)
```



Discussion:

Resolving patch:
diff --git a/hypothesis-python/src/hypothesis/_settings.py b/hypothesis-python/src/hypothesis/_settings.py
--- a/hypothesis-python/src/hypothesis/_settings.py
+++ b/hypothesis-python/src/hypothesis/_settings.py
@@ -25,7 +25,7 @@
 import os
 import warnings
 from enum import Enum, IntEnum, unique
-from typing import TYPE_CHECKING, Any, Collection, Dict, List, Optional, Union
+from typing import TYPE_CHECKING, Any, Collection, Dict, List, Optional, TypeVar, Union
 
 import attr
 
@@ -46,6 +46,8 @@
 
 all_settings: Dict[str, "Setting"] = {}
 
+T = TypeVar("T")
+
 
 class settingsProperty:
     def __init__(self, name, show_default):
@@ -181,12 +183,18 @@ def __init__(
                 else:
                     object.__setattr__(self, setting.name, setting.validator(value))
 
-    def __call__(self, test):
+    def __call__(self, test: T) -> T:
         """Make the settings object (self) an attribute of the test.
 
         The settings are later discovered by looking them up on the test itself.
         """
-        if not callable(test):
+        # Aliasing as Any avoids mypy errors (attr-defined) when accessing and
+        # setting custom attributes on the decorated function or class.
+        _test: Any = test
+
+        # Using the alias here avoids a mypy error (return-value) later when
+        # ``test`` is returned, because this check results in type refinement.
+        if not callable(_test):
             raise InvalidArgument(
                 "settings objects can be called as a decorator with @given, "
                 f"but decorated test={test!r} is not callable."
@@ -194,7 +202,7 @@ def __call__(self, test):
         if inspect.isclass(test):
             from hypothesis.stateful import RuleBasedStateMachine
 
-            if issubclass(test, RuleBasedStateMachine):
+            if issubclass(_test, RuleBasedStateMachine):
                 attr_name = "_hypothesis_internal_settings_applied"
                 if getattr(test, attr_name, False):
                     raise InvalidArgument(
@@ -203,25 +211,25 @@ def __call__(self, test):
                         "instead."
                     )
                 setattr(test, attr_name, True)
-                test.TestCase.settings = self
+                _test.TestCase.settings = self
                 return test
             else:
                 raise InvalidArgument(
                     "@settings(...) can only be used as a decorator on "
                     "functions, or on subclasses of RuleBasedStateMachine."
                 )
-        if hasattr(test, "_hypothesis_internal_settings_applied"):
+        if hasattr(_test, "_hypothesis_internal_settings_applied"):
             # Can't use _hypothesis_internal_use_settings as an indicator that
             # @settings was applied, because @given also assigns that attribute.
             descr = get_pretty_function_description(test)
             raise InvalidArgument(
                 f"{descr} has already been decorated with a settings object.\n"
-                f"    Previous:  {test._hypothesis_internal_use_settings!r}\n"
+                f"    Previous:  {_test._hypothesis_internal_use_settings!r}\n"
                 f"    This:  {self!r}"
             )
 
-        test._hypothesis_internal_use_settings = self
-        test._hypothesis_internal_settings_applied = True
+        _test._hypothesis_internal_use_settings = self
+        _test._hypothesis_internal_settings_applied = True
         return test
 
     @classmethod
diff --git a/hypothesis-python/src/hypothesis/core.py b/hypothesis-python/src/hypothesis/core.py
--- a/hypothesis-python/src/hypothesis/core.py
+++ b/hypothesis-python/src/hypothesis/core.py
@@ -1292,8 +1292,11 @@ def test(v):
     if random is not None:
         test = seed(random.getrandbits(64))(test)
 
-    test._hypothesis_internal_is_find = True
-    test._hypothesis_internal_database_key = database_key
+    # Aliasing as Any avoids mypy errors (attr-defined) when accessing and
+    # setting custom attributes on the decorated function or class.
+    _test: Any = test
+    _test._hypothesis_internal_is_find = True
+    _test._hypothesis_internal_database_key = database_key
 
     try:
         test()


Test patch:
diff --git a/whole-repo-tests/test_type_hints.py b/whole-repo-tests/test_type_hints.py
--- a/whole-repo-tests/test_type_hints.py
+++ b/whole-repo-tests/test_type_hints.py
@@ -96,3 +96,16 @@ def test_data_object_type_tracing(tmpdir):
     )
     got = get_mypy_analysed_type(str(f.realpath()), "data().draw(integers())")
     assert got == "int"
+
+
+def test_settings_preserves_type(tmpdir):
+    f = tmpdir.join("check_mypy_on_settings.py")
+    f.write(
+        "from hypothesis import settings\n"
+        "@settings(max_examples=10)\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"