Repository: HypothesisWorks/hypothesis

Issue:
Including optional fields in generated examples using the from_type strategy
Awesome, awesome project!

The `from_type` strategy is really useful when generating data from pydantic models (see https://github.com/samuelcolvin/pydantic/issues/1666), but it would be nice if it would recognise optional fields and _sometimes_ generate values for these fields, resulting in a more complete set of examples. The behaviour could be the same as that of the [fixed dictionaries strategy](https://hypothesis.readthedocs.io/en/latest/data.html?highlight=optional#hypothesis.strategies.fixed_dictionaries) when provided with the `optional` keyword argument, except that the optional fields are already known, from the model type hints.

To give a concrete example:

```python
from typing import Optional

from hypothesis import given
from hypothesis.strategies import from_type
from pydantic import BaseModel

class Adress(BaseModel):
    city: str
    street: str
    house_number: int
    postal_code: int


class Person(BaseModel):
    prename: str
    middlename: Optional[str]
    lastname: str
    address: Adress


@given(from_type(Person))
def test_me(person: Person):
    assert isinstance(person, Person)
```

It would be great if the behaviour was that the `middlename` field _may or may not_ be in the generated value. (Currently, it's always `None`.) Maybe something like:

```python
@given(from_type(Person, include_optional=True))
def test_me(person: Person):
    assert isinstance(person, Person)
```

Is this a sensible idea, or am I missing some other easy way to do this? 

So far I've tried:

- Using `fixed_dictionaries` - not great as you either have to manually provide the required and optional fields and their strategies, or write your own functions to extract them from the pydantic model
- `@given(st.builds(Person, middlename=infer))` - `middlename` is always set, which isn't what we want.


Discussion:
`st.builds(Person, middlename=infer)` is the idiomatic way to do it, possibly with a `st.register_type_strategy()` to make `st.from_type()` work too thereafter.  Unfortunately it's not working right now, due to some complicated introspection issues below.  As a workaround until we fix this, you could use `hypothesis_jsonschema.from_schema(Person.schema()).map(Person)`.

The underlying problem is as follows:

```python
import inspect, typing
from hypothesis.internal.compat import get_type_hints as hypothesis_hints
from pydantic import BaseModel

class Person(BaseModel):
    middlename: typing.Optional[str]

assert typing.get_type_hints(Person) == {'middlename': typing.Union[str, NoneType]}
assert hypothesis_hints(Person) == {'middlename': str, ...}

# and this ultimately comes down to...
assert Person.__annotations__['middlename'] == Optional[str]
assert Person.__signature__.parameters['middlename'].annotation == str

# but consider the following - I think we need to make our __signature__
# logic explicitly handle the default value of None:
def f(a: str=None): ...

assert typing_hints(f) == {'a': typing.Union[str, NoneType]}
assert inspect.signature(f).parameters["a"].annotation == str
```



Resolving patch:
diff --git a/hypothesis-python/docs/conf.py b/hypothesis-python/docs/conf.py
--- a/hypothesis-python/docs/conf.py
+++ b/hypothesis-python/docs/conf.py
@@ -31,6 +31,7 @@
     "sphinx.ext.viewcode",
     "sphinx.ext.intersphinx",
     "hoverxref.extension",
+    "sphinx_selective_exclude.eager_only",
 ]
 
 templates_path = ["_templates"]
@@ -53,6 +54,12 @@
     version = _d["__version__"]
     release = _d["__version__"]
 
+
+def setup(app):
+    if os.path.isfile(os.path.join(os.path.dirname(__file__), "..", "RELEASE.rst")):
+        app.tags.add("has_release_file")
+
+
 language = None
 
 exclude_patterns = ["_build"]
diff --git a/hypothesis-python/src/hypothesis/internal/compat.py b/hypothesis-python/src/hypothesis/internal/compat.py
--- a/hypothesis-python/src/hypothesis/internal/compat.py
+++ b/hypothesis-python/src/hypothesis/internal/compat.py
@@ -132,14 +132,13 @@ def get_type_hints(thing):
             # for more details.
             from hypothesis.strategies._internal.types import is_a_type
 
-            spec = inspect.getfullargspec(thing)
-            hints.update(
-                {
-                    k: v
-                    for k, v in spec.annotations.items()
-                    if k in (spec.args + spec.kwonlyargs) and is_a_type(v)
-                }
-            )
+            vkinds = (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD)
+            for p in inspect.signature(thing).parameters.values():
+                if p.kind not in vkinds and is_a_type(p.annotation):
+                    if p.default is None:
+                        hints[p.name] = typing.Optional[p.annotation]
+                    else:
+                        hints[p.name] = p.annotation
     except (AttributeError, TypeError, NameError):
         pass
 


Test patch:
diff --git a/hypothesis-python/tests/cover/test_lookup.py b/hypothesis-python/tests/cover/test_lookup.py
--- a/hypothesis-python/tests/cover/test_lookup.py
+++ b/hypothesis-python/tests/cover/test_lookup.py
@@ -17,6 +17,7 @@
 import collections
 import datetime
 import enum
+import inspect
 import io
 import string
 import sys
@@ -655,3 +656,26 @@ class TreeForwardRefs(typing.NamedTuple):
 @given(st.builds(TreeForwardRefs))
 def test_resolves_forward_references_outside_annotations(t):
     assert isinstance(t, TreeForwardRefs)
+
+
+def constructor(a: str = None):
+    pass
+
+
+class WithOptionalInSignature:
+    __signature__ = inspect.signature(constructor)
+    __annotations__ = typing.get_type_hints(constructor)
+
+    def __init__(self, **kwargs):
+        assert set(kwargs) == {"a"}
+        self.a = kwargs["a"]
+
+
+def test_compat_get_type_hints_aware_of_None_default():
+    # Regression test for https://github.com/HypothesisWorks/hypothesis/issues/2648
+    strategy = st.builds(WithOptionalInSignature, a=infer)
+    find_any(strategy, lambda x: x.a is None)
+    find_any(strategy, lambda x: x.a is not None)
+
+    assert typing.get_type_hints(constructor)["a"] == typing.Optional[str]
+    assert inspect.signature(constructor).parameters["a"].annotation == str