Repository: HypothesisWorks/hypothesis

Issue:
Fix class xx(NamedTuple) introspection
```python
class AnnotatedNamedTuple(typing.NamedTuple):
    a: str
    i: int
```
is not buildble by `@build(AnnotedNamedTuple)` because they don't have an `__init__` method to introspect and find the argument (field) types. Fixed things so that introspection will happen in this case.


Discussion:
And can I say thanks so much for making this such a positive process. And a great project!
Hi @jbu - I'm really sorry to ask this, but would you mind if we pause work on this problem for a little while?

I've done some deeper investigation and I think the best way to solve this is to pull the NamedTuple-specific logic from `from_type` up to `builds`, and that will be *much* easier to do after #1282 is merged (ETA one to two weeks).  I therefore advise *against* working on this before the other pull is finalised, in case of relevant changes there.

Apologies for not catching this earlier - and I hope it doesn't put you off contributing :broken_heart:  (if you're impatient, maybe try one of the "good first issue"s?)
Hi. No problem. That makes sense

James

> On 1 Jun 2018, at 17:26, Zac Hatfield-Dodds <notifications@github.com> wrote:
> 
> Hi @jbu - I'm really sorry to ask this, but would you mind if we pause work on this problem for a little while?
> 
> I've done some deeper investigation and I think the best way to solve this is to pull the NamedTuple-specific logic from from_type up to builds, and that will be much easier to do after #1282 is merged (ETA one to two weeks). I therefore advise against working on this before the other pull is finalised, in case of relevant changes there.
> 
> Apologies for not catching this earlier - and I hope it doesn't put you off contributing 💔 (if you're impatient, maybe try one of the "good first issue"s?)
> 
> —
> You are receiving this because you were mentioned.
> Reply to this email directly, view it on GitHub, or mute the thread.

Hi again @jbu!  It took a little longer than I'd hoped, but #1282 is finally merged and so I'd love to get this PR moving again.  It would be awesome if you'd like to resume work, but if you don't feel like it I can finish it up (you'd still get contributor credit, of course).

As style notes, we avoid code snippets and headings in the release notes (otherwise great), I'd classify this as a patch release rather than a minor release, and the contributors list is alphabetised so you don't go at the end :smile:  After that... rebase on master and see what the tests point out!

Resolving patch:
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
@@ -25,6 +25,7 @@
 import time
 import array
 import codecs
+import inspect
 import platform
 import importlib
 from base64 import b64encode
@@ -277,7 +278,6 @@ def qualname(f):
                              'kwonlyargs, kwonlydefaults, annotations')
 
     def getfullargspec(func):
-        import inspect
         args, varargs, varkw, defaults = inspect.getargspec(func)
         return FullArgSpec(args, varargs, varkw, defaults, [], None,
                            getattr(func, '__annotations__', {}))
@@ -296,9 +296,10 @@ def get_type_hints(thing):
         except TypeError:
             return {}
 else:
+    import typing
+
     def get_type_hints(thing):
         try:
-            import typing
             return typing.get_type_hints(thing)
         except TypeError:
             return {}
diff --git a/hypothesis-python/src/hypothesis/internal/reflection.py b/hypothesis-python/src/hypothesis/internal/reflection.py
--- a/hypothesis-python/src/hypothesis/internal/reflection.py
+++ b/hypothesis-python/src/hypothesis/internal/reflection.py
@@ -88,6 +88,18 @@ def function_digest(function):
     return hasher.digest()
 
 
+def is_typed_named_tuple(cls):
+    """Return True if cls is probably a subtype of `typing.NamedTuple`.
+
+    Unfortunately types created with `class T(NamedTuple):` actually
+    subclass `tuple` directly rather than NamedTuple.  This is annoying,
+    and means we just have to hope that nobody defines a different tuple
+    subclass with similar attributes.
+    """
+    return issubclass(cls, tuple) and hasattr(cls, '_fields') and \
+        hasattr(cls, '_field_types')
+
+
 def required_args(target, args=(), kwargs=()):
     """Return a set of names of required args to target that were not supplied
     in args or kwargs.
@@ -97,6 +109,11 @@ def required_args(target, args=(), kwargs=()):
     and bound methods).  args and kwargs should be as they are passed to
     builds() - that is, a tuple of values and a dict of names: values.
     """
+    # We start with a workaround for NamedTuples, which don't have nice inits
+    if inspect.isclass(target) and is_typed_named_tuple(target):
+        provided = set(kwargs) | set(target._fields[:len(args)])
+        return set(target._fields) - provided
+    # Then we try to do the right thing with getfullargspec
     try:
         spec = getfullargspec(
             target.__init__ if inspect.isclass(target) else target)
diff --git a/hypothesis-python/src/hypothesis/strategies.py b/hypothesis-python/src/hypothesis/strategies.py
--- a/hypothesis-python/src/hypothesis/strategies.py
+++ b/hypothesis-python/src/hypothesis/strategies.py
@@ -46,7 +46,7 @@
 from hypothesis.internal.renaming import renamed_arguments
 from hypothesis.utils.conventions import infer, not_set
 from hypothesis.internal.reflection import proxies, required_args, \
-    define_function_signature
+    is_typed_named_tuple, define_function_signature
 from hypothesis.internal.validation import check_type, try_convert, \
     check_valid_size, check_valid_bound, check_valid_sizes, \
     check_valid_integer, check_valid_interval, check_valid_magnitude
@@ -1125,8 +1125,14 @@ def builds(
             from hypothesis.searchstrategy.attrs import from_attrs
             return from_attrs(target, args, kwargs, required | to_infer)
         # Otherwise, try using type hints
-        hints = get_type_hints(
-            target.__init__ if isclass(target) else target)
+        if isclass(target):
+            if is_typed_named_tuple(target):
+                # Special handling for typing.NamedTuple
+                hints = target._field_types
+            else:
+                hints = get_type_hints(target.__init__)
+        else:
+            hints = get_type_hints(target)
         if to_infer - set(hints):
             raise InvalidArgument(
                 'passed infer for %s, but there is no type annotation'
@@ -1248,25 +1254,22 @@ def from_type(thing):
         return one_of(strategies)
     # If we don't have a strategy registered for this type or any subtype, we
     # may be able to fall back on type annotations.
-    # Types created via typing.NamedTuple use a custom attribute instead -
-    # but we can still use builds(), if we work out the right kwargs.
-    if issubclass(thing, tuple) and hasattr(thing, '_fields') \
-            and hasattr(thing, '_field_types'):
-        kwargs = {k: from_type(thing._field_types[k]) for k in thing._fields}
-        return builds(thing, **kwargs)
     if issubclass(thing, enum.Enum):
         assert len(thing), repr(thing) + ' has no members to sample'
         return sampled_from(thing)
-    # If the constructor has an annotation for all required arguments,
-    # or the class was defined with attrs, builds(thing) will work.
-    # (unless inference fails for an argument, but that's the same above too)
+    # If we know that builds(thing) will fail, give a better error message
     required = required_args(thing)
-    if not required or required.issubset(get_type_hints(thing.__init__)) or \
-            attr.has(thing):
-        return builds(thing)
-    # We have utterly failed, and might as well say so now.
-    raise ResolutionFailed('Could not resolve %r to a strategy; consider '
-                           'using register_type_strategy' % (thing,))
+    if not any([
+        not required,
+        required.issubset(get_type_hints(thing.__init__)),
+        attr.has(thing),
+        # NamedTuples are weird enough that we need a specific check for them.
+        is_typed_named_tuple(thing),
+    ]):
+        raise ResolutionFailed('Could not resolve %r to a strategy; consider '
+                               'using register_type_strategy' % (thing,))
+    # Finally, try to build an instance by calling the type object
+    return builds(thing)
 
 
 @cacheable


Test patch:
diff --git a/hypothesis-python/tests/py3/test_lookup.py b/hypothesis-python/tests/py3/test_lookup.py
--- a/hypothesis-python/tests/py3/test_lookup.py
+++ b/hypothesis-python/tests/py3/test_lookup.py
@@ -367,6 +367,24 @@ def test_required_args(target, args, kwargs):
               **{k: st.just(v) for k, v in kwargs.items()}).example()
 
 
+AnnotatedNamedTuple = typing.NamedTuple('AnnotatedNamedTuple', [('a', str)])
+
+
+@given(st.builds(AnnotatedNamedTuple))
+def test_infers_args_for_namedtuple_builds(thing):
+    assert isinstance(thing.a, str)
+
+
+@given(st.from_type(AnnotatedNamedTuple))
+def test_infers_args_for_namedtuple_from_type(thing):
+    assert isinstance(thing.a, str)
+
+
+@given(st.builds(AnnotatedNamedTuple, a=st.none()))
+def test_override_args_for_namedtuple(thing):
+    assert thing.a is None
+
+
 @pytest.mark.parametrize('thing', [
     typing.Optional, typing.List, getattr(typing, 'Type', typing.Set)
 ])  # check Type if it's available, otherwise Set is redundant but harmless