Repository: HypothesisWorks/hypothesis

Issue:
Better handling of covariance, contravariance, and `TypeVar` in `from_type()`
https://github.com/HypothesisWorks/hypothesis/blob/2c6b6f33c1aec30ff4dc7572bc975660d72853ec/hypothesis-python/src/hypothesis/searchstrategy/types.py#L118-L126

Type variables are tricky!

- We should handle constraints before bounds.  They're mutually exclusive, but easier to handle the "neither" case that way.
- The `shared()` strategy should be keyed on `f"typevar-{thing.__name__}"`
- The bounds case should also use `shared()`!  Otherwise it's not really a typevar...
- We can use a (`shared()` of) `from_type(getattr(thing, "__bound__", object))` to generalise the `text()` hack.

A larger problem is that `from_type()` currently always treats things as covariant (i.e. returns a strategy for subtypes if the exact type is unknown).  We should probably provide an argument for in/co/contra variance and default to invariance, which will be an annoying deprecation pathway.  Plus use that from the TypeVar handling, obviously.

(this should be retagged from `enhancement` to `bug` if it's seen in the wild)


Discussion:

Resolving patch:
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
@@ -1437,7 +1437,7 @@ def as_strategy(strat_or_callable, thing):
     # not included because bool is a subclass of int as well as Number.
     strategies = [
         as_strategy(v, thing)
-        for k, v in types._global_type_lookup.items()
+        for k, v in sorted(types._global_type_lookup.items(), key=repr)
         if isinstance(k, type)
         and issubclass(k, thing)
         and sum(types.try_issubclass(k, typ) for typ in types._global_type_lookup) == 1
diff --git a/hypothesis-python/src/hypothesis/searchstrategy/types.py b/hypothesis-python/src/hypothesis/searchstrategy/types.py
--- a/hypothesis-python/src/hypothesis/searchstrategy/types.py
+++ b/hypothesis-python/src/hypothesis/searchstrategy/types.py
@@ -38,6 +38,8 @@
     text_type,
     typing_root_type,
 )
+from hypothesis.searchstrategy.lazy import unwrap_strategies
+from hypothesis.searchstrategy.strategies import OneOfStrategy
 
 
 def type_sorting_key(t):
@@ -117,10 +119,20 @@ def from_typing_type(thing):
         return st.tuples(*map(st.from_type, elem_types))
     if isinstance(thing, typing.TypeVar):
         if getattr(thing, "__bound__", None) is not None:
-            return st.from_type(thing.__bound__)
+            strat = unwrap_strategies(st.from_type(thing.__bound__))
+            if not isinstance(strat, OneOfStrategy):
+                return strat
+            # The bound was a union, or we resolved it as a union of subtypes,
+            # so we need to unpack the strategy to ensure consistency across uses.
+            # This incantation runs a sampled_from over the strategies inferred for
+            # each part of the union, wraps that in shared so that we only generate
+            # from one type per testcase, and flatmaps that back to instances.
+            return st.shared(
+                st.sampled_from(strat.original_strategies), key="typevar=%r" % (thing,)
+            ).flatmap(lambda s: s)
         if getattr(thing, "__constraints__", None):
             return st.shared(
-                st.sampled_from(thing.__constraints__), key="typevar-with-constraint"
+                st.sampled_from(thing.__constraints__), key="typevar=%r" % (thing,)
             ).flatmap(st.from_type)
         # Constraints may be None or () on various Python versions.
         return st.text()  # An arbitrary type for the typevar


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
@@ -37,7 +37,7 @@
 )
 from hypothesis.searchstrategy import types
 from hypothesis.strategies import from_type
-from tests.common.debug import minimal
+from tests.common.debug import find_any, minimal
 from tests.common.utils import fails_with
 
 typing = pytest.importorskip("typing")
@@ -259,11 +259,30 @@ def test_resolves_weird_types(typ):
     from_type(typ).example()
 
 
+class Foo:
+    def __init__(self, x):
+        pass
+
+
+class Bar(Foo):
+    pass
+
+
+class Baz(Foo):
+    pass
+
+
+st.register_type_strategy(Bar, st.builds(Bar, st.integers()))
+st.register_type_strategy(Baz, st.builds(Baz, st.integers()))
+
+
 @pytest.mark.parametrize(
     "var,expected",
     [
         (typing.TypeVar("V"), object),
         (typing.TypeVar("V", bound=int), int),
+        (typing.TypeVar("V", bound=Foo), (Bar, Baz)),
+        (typing.TypeVar("V", bound=typing.Union[int, str]), (int, str)),
         (typing.TypeVar("V", int, str), (int, str)),
     ],
 )
@@ -277,6 +296,15 @@ def test_typevar_type_is_consistent(data, var, expected):
     assert isinstance(v1, expected)
 
 
+def test_distinct_typevars_same_constraint():
+    A = typing.TypeVar("A", int, str)
+    B = typing.TypeVar("B", int, str)
+    find_any(
+        st.tuples(st.from_type(A), st.from_type(B)),
+        lambda ab: type(ab[0]) != type(ab[1]),  # noqa
+    )
+
+
 def annotated_func(a: int, b: int = 2, *, c: int, d: int = 4):
     return a + b + c + d