Repository: HypothesisWorks/hypothesis

Issue:
[Bug] Registering a type inheriting from `Sequence`
Hi,
I observe a strange behavior when I create a class that inherits from `Sequence[str]` and registering it with Hypothesis. Namely, the generating strategy for that class is added to one of generating strategies for *general* `Sequence`'s.

Perhaps it is related to https://github.com/HypothesisWorks/hypothesis/issues/2940?

Please have a look:

```python
from typing import Sequence, Union, overload

import hypothesis.strategies as st

class Lines(Sequence[str]):
    """Represent a sequence of text lines."""

    def __new__(cls, lines: Sequence[str]) -> "Lines":
        assert all('\n' not in line and '\r' not in line for line in lines)
        return cast(Lines, lines)

    def __add__(self, other: "Lines") -> "Lines":
        raise NotImplementedError("Only for type annotations")

    # pylint: disable=function-redefined

    @overload
    def __getitem__(self, index: int) -> str:
        pass

    @overload
    def __getitem__(self, index: slice) -> "Lines":
        pass

    def __getitem__(self, index: Union[int, slice]) -> Union[str, "Lines"]:
        raise NotImplementedError("Only for type annotations")

    def __len__(self) -> int:
        raise NotImplementedError("Only for type annotations")

st.register_type_strategy(
    Lines,
    st.fixed_dictionaries(
        {'lines': st.lists(st.text())
            .filter(
            lambda lines:
            all(
                '\n' not in line and '\r' not in line
                for line in lines))
        }
    ).map(lambda d: Lines(**d))
)

strategy = st.from_type(Lines)
assert (str(strategy) ==
        "fixed_dictionaries("
        "{'lines': lists(text())"
        ".filter(lambda lines: <unknown>)})"
        ".map(lambda d: <unknown>)")


# Now let's test that the strategy for ``Sequence[int]`` did not change just because
# we registered a strategy for ``Lines``.
strategy = st.from_type(Sequence[int])

# NOTE: something is strange here.
assert (str(strategy) ==
        "one_of("
        "binary(), "
        "lists(integers()), "
        "fixed_dictionaries("
        "{'lines': lists(text())"
        ".filter(lambda lines: <unknown>)})"
        ".map(lambda d: <unknown>))")
```


Discussion:
This line seems to be the culprit, but I am not sure:
https://github.com/HypothesisWorks/hypothesis/blob/883ab7f890f88a416213e244ee91e0f3167c0fa2/hypothesis-python/src/hypothesis/strategies/_internal/types.py#L249

Consider the following snippet (you can just append it to the above example):

```
import hypothesis.strategies._internal.types

is_subclass = hypothesis.strategies._internal.types.try_issubclass(Lines, Sequence[int])
is_generic_type = hypothesis.strategies._internal.types.is_generic_type(Lines)

assert is_subclass and is_generic_type
```
Perhaps the problem is in `is_subclass` since you don't check for `__args__`?

https://github.com/HypothesisWorks/hypothesis/blob/883ab7f890f88a416213e244ee91e0f3167c0fa2/hypothesis-python/src/hypothesis/strategies/_internal/types.py#L90

```python
def try_issubclass(thing, superclass):
    thing = getattr(thing, "__origin__", None) or thing
    superclass = getattr(superclass, "__origin__", None) or superclass
    try:
        return issubclass(thing, superclass)
    except (AttributeError, TypeError):
        # Some types can't be the subject or object of an instance or subclass check
        return False
```

Resolving patch:
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
@@ -381,7 +381,7 @@ def get_pretty_function_description(f):
         # their module as __self__.  This might include c-extensions generally?
         if not (self is None or inspect.isclass(self) or inspect.ismodule(self)):
             return f"{self!r}.{name}"
-    elif getattr(dict, name, object()) is f:
+    elif isinstance(name, str) and getattr(dict, name, object()) is f:
         # special case for keys/values views in from_type() / ghostwriter output
         return f"dict.{name}"
     return name
diff --git a/hypothesis-python/src/hypothesis/strategies/_internal/types.py b/hypothesis-python/src/hypothesis/strategies/_internal/types.py
--- a/hypothesis-python/src/hypothesis/strategies/_internal/types.py
+++ b/hypothesis-python/src/hypothesis/strategies/_internal/types.py
@@ -88,10 +88,45 @@ def type_sorting_key(t):
 
 
 def try_issubclass(thing, superclass):
-    thing = getattr(thing, "__origin__", None) or thing
-    superclass = getattr(superclass, "__origin__", None) or superclass
     try:
-        return issubclass(thing, superclass)
+        # In this case we're looking at two distinct classes - which might be generics.
+        # That brings in some complications:
+        if issubclass(
+            getattr(thing, "__origin__", None) or thing,
+            getattr(superclass, "__origin__", None) or superclass,
+        ):
+            superclass_args = getattr(superclass, "__args__", None)
+            if sys.version_info[:2] == (3, 6) or not superclass_args:
+                # Python 3.6 doesn't have PEP-560 semantics or __orig_bases__,
+                # so there's no point continuing; we therefore stop early.
+                # The superclass is not generic, so we're definitely a subclass.
+                return True
+            # Sadly this is just some really fiddly logic to handle all the cases
+            # of user-defined generic types, types inheriting from parametrised
+            # generics, and so on.  If you need to change this code, read PEP-560
+            # and Hypothesis issue #2951 closely first, and good luck.  The tests
+            # will help you, I hope - good luck.
+            thing_args = getattr(thing, "__args__", None)
+            thing_orig_bases = getattr(thing, "__orig_bases__", None) or [None]
+            if thing_args is None and len(thing_orig_bases) != 1:  # pragma: no cover
+                raise ResolutionFailed(
+                    f"It looks like you're trying to resolve {thing!r} which "
+                    "inherits from more than one generic type, which isn't supported. "
+                    "Try using register_type_strategy()."
+                )
+            thing_orig_bases_args = getattr(thing_orig_bases[0], "__args__", None)
+            if thing_args is None and thing_orig_bases_args is not None:
+                return len(thing_orig_bases_args) == len(superclass_args) and all(
+                    # "a==b or either is a typevar" is a hacky approximation, but it's
+                    # good enough for all the cases that I've seen so far and has the
+                    # substantial virtue of (relative) simplicity.
+                    a == b
+                    or isinstance(a, typing.TypeVar)
+                    or isinstance(b, typing.TypeVar)
+                    for a, b in zip(thing_orig_bases_args, superclass_args)
+                )
+            return True
+        return False
     except (AttributeError, TypeError):
         # Some types can't be the subject or object of an instance or subclass check
         return False
@@ -297,8 +332,8 @@ def from_typing_type(thing):
     empty = ", ".join(repr(s) for s in strategies if s.is_empty)
     if empty or not strategies:
         raise ResolutionFailed(
-            "Could not resolve %s to a strategy; consider using "
-            "register_type_strategy" % (empty or thing,)
+            f"Could not resolve {empty or thing} to a strategy; "
+            "consider using register_type_strategy"
         )
     return st.one_of(strategies)
 


Test patch:
diff --git a/hypothesis-python/tests/cover/test_lookup_py37.py b/hypothesis-python/tests/cover/test_lookup_py37.py
--- a/hypothesis-python/tests/cover/test_lookup_py37.py
+++ b/hypothesis-python/tests/cover/test_lookup_py37.py
@@ -238,6 +238,7 @@ def test_resolving_standard_valuesview_as_generic(x: collections.abc.ValuesView[
     check(collections.abc.ValuesView, x)
 
 
+@pytest.mark.xfail  # Weird interaction with fixes in PR #2952
 @given(x=infer)
 def test_resolving_standard_contextmanager_as_generic(
     x: contextlib.AbstractContextManager[Elem],
diff --git a/hypothesis-python/tests/cover/test_type_lookup.py b/hypothesis-python/tests/cover/test_type_lookup.py
--- a/hypothesis-python/tests/cover/test_type_lookup.py
+++ b/hypothesis-python/tests/cover/test_type_lookup.py
@@ -16,7 +16,7 @@
 import abc
 import enum
 import sys
-from typing import Callable, Generic, List, Sequence, TypeVar, Union
+from typing import Callable, Dict, Generic, List, Sequence, TypeVar, Union
 
 import pytest
 
@@ -26,6 +26,7 @@
     InvalidArgument,
     ResolutionFailed,
 )
+from hypothesis.internal.reflection import get_pretty_function_description
 from hypothesis.strategies._internal import types
 from hypothesis.strategies._internal.core import _from_type
 from hypothesis.strategies._internal.types import _global_type_lookup
@@ -232,6 +233,18 @@ def __init__(self, arg: T) -> None:
         self.arg = arg
 
 
+class Lines(Sequence[str]):
+    """Represent a sequence of text lines.
+
+    It turns out that resolving a class which inherits from a parametrised generic
+    type is... tricky.  See https://github.com/HypothesisWorks/hypothesis/issues/2951
+    """
+
+
+class SpecificDict(Dict[int, int]):
+    pass
+
+
 def using_generic(instance: MyGeneric[T]) -> T:
     return instance.arg
 
@@ -248,6 +261,26 @@ def test_generic_origin_empty():
 _skip_callables_mark = pytest.mark.skipif(sys.version_info[:2] < (3, 7), reason="old")
 
 
+@_skip_callables_mark
+def test_issue_2951_regression():
+    lines_strat = st.builds(Lines, lines=st.lists(st.text()))
+    with temp_registered(Lines, lines_strat):
+        assert st.from_type(Lines) == lines_strat
+        # Now let's test that the strategy for ``Sequence[int]`` did not
+        # change just because we registered a strategy for ``Lines``:
+        expected = "one_of(binary(), lists(integers()))"
+        assert repr(st.from_type(Sequence[int])) == expected
+
+
+@_skip_callables_mark
+def test_issue_2951_regression_two_params():
+    map_strat = st.builds(SpecificDict, st.dictionaries(st.integers(), st.integers()))
+    expected = repr(st.from_type(Dict[int, int]))
+    with temp_registered(SpecificDict, map_strat):
+        assert st.from_type(SpecificDict) == map_strat
+        assert expected == repr(st.from_type(Dict[int, int]))
+
+
 @pytest.mark.parametrize(
     "generic",
     (
@@ -287,13 +320,20 @@ def test_generic_origin_without_type_args(generic):
         pass
 
 
-def test_generic_origin_from_type():
+@pytest.mark.parametrize(
+    "strat, type_",
+    [
+        (st.from_type, MyGeneric[T]),
+        (st.from_type, MyGeneric[int]),
+        (st.from_type, MyGeneric),
+        (st.builds, using_generic),
+        (st.builds, using_concrete_generic),
+    ],
+    ids=get_pretty_function_description,
+)
+def test_generic_origin_from_type(strat, type_):
     with temp_registered(MyGeneric, st.builds(MyGeneric)):
-        find_any(st.from_type(MyGeneric[T]))
-        find_any(st.from_type(MyGeneric[int]))
-        find_any(st.from_type(MyGeneric))
-        find_any(st.builds(using_generic))
-        find_any(st.builds(using_concrete_generic))
+        find_any(strat(type_))
 
 
 def test_generic_origin_concrete_builds():