Repository: HypothesisWorks/hypothesis

Issue:
`register_type_strategy` doesn't work with lowercase builtin `list` type
Using `register_type_strategy` doesn't seem to work with the built in `list` type available since python 3.9 (in fact `typing.List` is deprecated). 

hypothesis==6.75.1
pytest==7.3.1
Python 3.10.10

```python
from typing import get_args, Type, List
from hypothesis import strategies as st, given, infer


def _shorter_lists(list_type: Type) -> st.SearchStrategy:
    # doesn't get called unless using capital L List
    print("_shorter_lists called")
    sub_type = get_args(list_type)[0]
    return st.lists(st.from_type(sub_type), max_size=2)

# will make our test fail
st.register_type_strategy(list, _shorter_lists)

# will pass if uncommented, notice capital L
# st.register_type_strategy(List, _shorter_lists)


@given(infer)
def test_example(example: list[int]):
    assert len(example) <= 2

```


Discussion:
```diff
--- a/hypothesis-python/src/hypothesis/strategies/_internal/types.py
+++ b/hypothesis-python/src/hypothesis/strategies/_internal/types.py
@@ -315,7 +315,8 @@ def is_generic_type(type_):
     # We check for `MyClass[T]` and `MyClass[int]` with the first condition,
     # while the second condition is for `MyClass`.
     return isinstance(type_, typing_root_type + (GenericAlias,)) or (
-        isinstance(type_, type) and typing.Generic in type_.__mro__
+        isinstance(type_, type)
+        and (typing.Generic in type_.__mro__ or hasattr(type_, "__class_getitem__"))
     )
```

This is enough to find the registered strategy for `list`... _as well as_ for `typing.List`.  

We'll need to do something to ensure that these are treated as a single lookup key, since resolving to different strategies for each will raise `ResolutionFailed`, but then I think it'll probably work?  My main concern is that we'll be left with a more complicated user experience on Python < 3.9, when builtins aren't yet parametrizable.

Resolving patch:
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
@@ -10,6 +10,7 @@
 
 import builtins
 import collections
+import collections.abc
 import datetime
 import decimal
 import fractions
@@ -250,7 +251,7 @@ def is_a_new_type(thing):
         )
     # In 3.10 and later, NewType is actually a class - which simplifies things.
     # See https://bugs.python.org/issue44353 for links to the various patches.
-    return isinstance(thing, typing.NewType)  # pragma: no cover  # on 3.8, anyway
+    return isinstance(thing, typing.NewType)
 
 
 def is_a_union(thing):
@@ -315,7 +316,8 @@ def is_generic_type(type_):
     # We check for `MyClass[T]` and `MyClass[int]` with the first condition,
     # while the second condition is for `MyClass`.
     return isinstance(type_, typing_root_type + (GenericAlias,)) or (
-        isinstance(type_, type) and typing.Generic in type_.__mro__
+        isinstance(type_, type)
+        and (typing.Generic in type_.__mro__ or hasattr(type_, "__class_getitem__"))
     )
 
 
@@ -404,14 +406,22 @@ def from_typing_type(thing):
         for k, v in _global_type_lookup.items()
         if is_generic_type(k) and try_issubclass(k, thing)
     }
-    if typing.Dict in mapping or typing.Set in mapping:
+    # Drop some unusual cases for simplicity
+    for weird in (tuple, getattr(os, "_Environ", None)):
+        if len(mapping) > 1:
+            mapping.pop(weird, None)
+    # After we drop Python 3.8 and can rely on having generic builtin types, we'll
+    # be able to simplify this logic by dropping the typing-module handling.
+    if {dict, set, typing.Dict, typing.Set}.intersection(mapping):
         # ItemsView can cause test_lookup.py::test_specialised_collection_types
         # to fail, due to weird isinstance behaviour around the elements.
+        mapping.pop(collections.abc.ItemsView, None)
         mapping.pop(typing.ItemsView, None)
-    if typing.Deque in mapping and len(mapping) > 1:
+    if {collections.deque, typing.Deque}.intersection(mapping) and len(mapping) > 1:
         # Resolving generic sequences to include a deque is more trouble for e.g.
         # the ghostwriter than it's worth, via undefined names in the repr.
-        mapping.pop(typing.Deque)
+        mapping.pop(collections.deque, None)
+        mapping.pop(typing.Deque, None)
     if len(mapping) > 1:
         # issubclass treats bytestring as a kind of sequence, which it is,
         # but treating it as such breaks everything else when it is presumed
@@ -431,6 +441,8 @@ def from_typing_type(thing):
             isinstance(T, type) and issubclass(int, get_origin(T) or T)
             for T in list(union_elems) + [elem_type]
         ):
+            mapping.pop(bytes, None)
+            mapping.pop(collections.abc.ByteString, None)
             mapping.pop(typing.ByteString, None)
     elif (
         (not mapping)
@@ -438,9 +450,17 @@ def from_typing_type(thing):
         and thing.__forward_arg__ in vars(builtins)
     ):
         return st.from_type(getattr(builtins, thing.__forward_arg__))
+    # Before Python 3.9, we sometimes have e.g. ByteString from both the typing
+    # module, and collections.abc module.  Discard any type which is not it's own
+    # origin, where the origin is also in the mapping.
+    for t in sorted(mapping, key=type_sorting_key):
+        origin = get_origin(t)
+        if origin is not t and origin in mapping:
+            mapping.pop(t)
+    # Sort strategies according to our type-sorting heuristic for stable output
     strategies = [
         v if isinstance(v, st.SearchStrategy) else v(thing)
-        for k, v in mapping.items()
+        for k, v in sorted(mapping.items(), key=lambda kv: type_sorting_key(kv[0]))
         if sum(try_issubclass(k, T) for T in mapping) == 1
     ]
     empty = ", ".join(repr(s) for s in strategies if s.is_empty)
@@ -685,6 +705,7 @@ def register(type_, fallback=None, *, module=typing):
             return lambda f: f
 
     def inner(func):
+        nonlocal type_
         if fallback is None:
             _global_type_lookup[type_] = func
             return func
@@ -696,6 +717,11 @@ def really_inner(thing):
                 return fallback  # pragma: no cover
             return func(thing)
 
+        if sys.version_info[:2] >= (3, 9):
+            try:
+                type_ = get_origin(type_)
+            except Exception:
+                pass
         _global_type_lookup[type_] = really_inner
         return really_inner
 
@@ -755,6 +781,7 @@ def _from_hashable_type(type_):
 
 
 @register(typing.Set, st.builds(set))
+@register(typing.MutableSet, st.builds(set))
 def resolve_Set(thing):
     return st.sets(_from_hashable_type(thing.__args__[0]))
 


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
@@ -27,7 +27,7 @@
 
 from hypothesis import HealthCheck, assume, given, settings, strategies as st
 from hypothesis.errors import InvalidArgument, ResolutionFailed, SmallSearchSpaceWarning
-from hypothesis.internal.compat import PYPY, get_type_hints
+from hypothesis.internal.compat import PYPY, get_origin, get_type_hints
 from hypothesis.internal.reflection import get_pretty_function_description
 from hypothesis.strategies import from_type
 from hypothesis.strategies._internal import types
@@ -232,7 +232,10 @@ def inner_2(ex):
 
 def test_register_generic_typing_strats():
     # I don't expect anyone to do this, but good to check it works as expected
-    with temp_registered(typing.Sequence, types._global_type_lookup[typing.Set]):
+    with temp_registered(
+        typing.Sequence,
+        types._global_type_lookup[get_origin(typing.Set) or typing.Set],
+    ):
         # We register sets for the abstract sequence type, which masks subtypes
         # from supertype resolution but not direct resolution
         assert_all_examples(
diff --git a/hypothesis-python/tests/cover/test_lookup_py39.py b/hypothesis-python/tests/cover/test_lookup_py39.py
--- a/hypothesis-python/tests/cover/test_lookup_py39.py
+++ b/hypothesis-python/tests/cover/test_lookup_py39.py
@@ -17,7 +17,8 @@
 from hypothesis import given, strategies as st
 from hypothesis.errors import InvalidArgument
 
-from tests.common.debug import find_any
+from tests.common.debug import assert_all_examples, find_any
+from tests.common.utils import temp_registered
 
 
 @pytest.mark.parametrize(
@@ -111,3 +112,16 @@ class BuiltinTuple:
 def test_from_type_with_tuple_works(data, data_class: TestDataClass):
     value: TestDataClass = data.draw(st.from_type(data_class))
     assert len(value.a) >= 0
+
+
+def _shorter_lists(list_type):
+    return st.lists(st.from_type(*typing.get_args(list_type)), max_size=2)
+
+
+def test_can_register_builtin_list():
+    # Regression test for https://github.com/HypothesisWorks/hypothesis/issues/3635
+    with temp_registered(list, _shorter_lists):
+        assert_all_examples(
+            st.from_type(list[int]),
+            lambda ls: len(ls) <= 2 and {type(x) for x in ls}.issubset({int}),
+        )
diff --git a/hypothesis-python/tests/ghostwriter/recorded/merge_dicts.txt b/hypothesis-python/tests/ghostwriter/recorded/merge_dicts.txt
--- a/hypothesis-python/tests/ghostwriter/recorded/merge_dicts.txt
+++ b/hypothesis-python/tests/ghostwriter/recorded/merge_dicts.txt
@@ -9,12 +9,12 @@ from hypothesis import given, strategies as st
 
 @given(
     map1=st.one_of(
-        st.dictionaries(keys=st.text(), values=st.integers()),
         st.dictionaries(keys=st.text(), values=st.integers()).map(ChainMap),
+        st.dictionaries(keys=st.text(), values=st.integers()),
     ),
     map2=st.one_of(
-        st.dictionaries(keys=st.text(), values=st.integers()),
         st.dictionaries(keys=st.text(), values=st.integers()).map(ChainMap),
+        st.dictionaries(keys=st.text(), values=st.integers()),
     ),
 )
 def test_fuzz_merge_dicts(