Repository: HypothesisWorks/hypothesis

Issue:
Teach `from_type()` to handle `TypedDict` classes with PEP-655 `(Not)Required` keys
[PEP-655](https://peps.python.org/pep-0655/) defines a better way to mark some keys as required and others as not-required on the `TypedDict` class - before, you had to define a parent class with some keys and a child class with others.  We should upgrade our resolver code to handle this, and write test cases for both pure-new-style and mixes of inheritance-based (old) and `(Not)Required`-based (new) styles.

https://github.com/HypothesisWorks/hypothesis/blob/2a071c88df9f4661e71623ca6a58de8ec5098b6c/hypothesis-python/src/hypothesis/strategies/_internal/core.py#L1075-L1095

Prior work on recent types: #3331, #3280, #2978.


Discussion:
I will give this a try as well @Zac-HD 

Resolving patch:
diff --git a/hypothesis-python/src/hypothesis/strategies/_internal/core.py b/hypothesis-python/src/hypothesis/strategies/_internal/core.py
--- a/hypothesis-python/src/hypothesis/strategies/_internal/core.py
+++ b/hypothesis-python/src/hypothesis/strategies/_internal/core.py
@@ -1082,8 +1082,22 @@ def as_strategy(strat_or_callable, thing, final=True):
         # The __optional_keys__ attribute may or may not be present, but if there's no
         # way to tell and we just have to assume that everything is required.
         # See https://github.com/python/cpython/pull/17214 for details.
-        optional = getattr(thing, "__optional_keys__", ())
-        anns = {k: from_type(v) for k, v in get_type_hints(thing).items()}
+        optional = set(getattr(thing, "__optional_keys__", ()))
+        anns = {}
+        for k, v in get_type_hints(thing).items():
+            origin = getattr(v, "__origin__", None)
+            if origin in types.RequiredTypes + types.NotRequiredTypes:
+                if origin in types.NotRequiredTypes:
+                    optional.add(k)
+                else:
+                    optional.discard(k)
+                try:
+                    v = v.__args__[0]
+                except IndexError:
+                    raise InvalidArgument(
+                        f"`{k}: {v.__name__}` is not a valid type annotation"
+                    ) from None
+            anns[k] = from_type(v)
         if (
             (not anns)
             and thing.__annotations__
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
@@ -121,6 +121,28 @@
     pass  # `typing_extensions` might not be installed
 
 
+RequiredTypes: tuple = ()
+try:
+    RequiredTypes += (typing.Required,)  # type: ignore
+except AttributeError:  # pragma: no cover
+    pass  # Is missing for `python<3.11`
+try:
+    RequiredTypes += (typing_extensions.Required,)
+except AttributeError:  # pragma: no cover
+    pass  # `typing_extensions` might not be installed
+
+
+NotRequiredTypes: tuple = ()
+try:
+    NotRequiredTypes += (typing.NotRequired,)  # type: ignore
+except AttributeError:  # pragma: no cover
+    pass  # Is missing for `python<3.11`
+try:
+    NotRequiredTypes += (typing_extensions.NotRequired,)
+except AttributeError:  # pragma: no cover
+    pass  # `typing_extensions` might not be installed
+
+
 # We use this variable to be sure that we are working with a type from `typing`:
 typing_root_type = (typing._Final, typing._GenericAlias)  # type: ignore
 


Test patch:
diff --git a/hypothesis-python/tests/typing_extensions/test_backported_types.py b/hypothesis-python/tests/typing_extensions/test_backported_types.py
--- a/hypothesis-python/tests/typing_extensions/test_backported_types.py
+++ b/hypothesis-python/tests/typing_extensions/test_backported_types.py
@@ -19,7 +19,9 @@
     DefaultDict,
     Literal,
     NewType,
+    NotRequired,
     ParamSpec,
+    Required,
     Type,
     TypedDict,
     TypeGuard,
@@ -30,6 +32,8 @@
 from hypothesis.strategies import from_type
 from hypothesis.strategies._internal.types import NON_RUNTIME_TYPES
 
+from tests.common.debug import assert_all_examples, find_any
+
 # See also nocover/test_type_lookp.py
 
 
@@ -202,3 +206,93 @@ def test_callable_return_typegard_type():
 
     with pytest.raises(InvalidArgument, match="Cannot register generic type"):
         st.register_type_strategy(Callable[[], TypeGuard[int]], st.none())
+
+
+class Movie(TypedDict):  # implicitly total=True
+    title: str
+    year: NotRequired[int]
+
+
+@given(from_type(Movie))
+def test_typeddict_not_required(value):
+    assert type(value) == dict
+    assert set(value).issubset({"title", "year"})
+    assert isinstance(value["title"], str)
+    if "year" in value:
+        assert isinstance(value["year"], int)
+
+
+def test_typeddict_not_required_can_skip():
+    find_any(from_type(Movie), lambda movie: "year" not in movie)
+
+
+class OtherMovie(TypedDict, total=False):
+    title: Required[str]
+    year: int
+
+
+@given(from_type(OtherMovie))
+def test_typeddict_required(value):
+    assert type(value) == dict
+    assert set(value).issubset({"title", "year"})
+    assert isinstance(value["title"], str)
+    if "year" in value:
+        assert isinstance(value["year"], int)
+
+
+def test_typeddict_required_must_have():
+    assert_all_examples(from_type(OtherMovie), lambda movie: "title" in movie)
+
+
+class Story(TypedDict, total=True):
+    author: str
+
+
+class Book(Story, total=False):
+    pages: int
+
+
+class Novel(Book):
+    genre: Required[str]
+    rating: NotRequired[str]
+
+
+@pytest.mark.parametrize(
+    "check,condition",
+    [
+        pytest.param(
+            assert_all_examples,
+            lambda novel: "author" in novel,
+            id="author-is-required",
+        ),
+        pytest.param(
+            assert_all_examples, lambda novel: "genre" in novel, id="genre-is-required"
+        ),
+        pytest.param(
+            find_any, lambda novel: "pages" in novel, id="pages-may-be-present"
+        ),
+        pytest.param(
+            find_any, lambda novel: "pages" not in novel, id="pages-may-be-absent"
+        ),
+        pytest.param(
+            find_any, lambda novel: "rating" in novel, id="rating-may-be-present"
+        ),
+        pytest.param(
+            find_any, lambda novel: "rating" not in novel, id="rating-may-be-absent"
+        ),
+    ],
+)
+def test_required_and_not_required_keys(check, condition):
+    check(from_type(Novel), condition)
+
+
+def test_typeddict_error_msg():
+    with pytest.raises(TypeError, match="is not valid as type argument"):
+
+        class Foo(TypedDict):
+            attr: Required
+
+    with pytest.raises(TypeError, match="is not valid as type argument"):
+
+        class Bar(TypedDict):
+            attr: NotRequired