Skip to content

Commit 773f759

Browse files
authored
PEP 655 Add interaction with __required_keys__, __optional_keys__ and get_type_hints() (#1057)
* PEP 655 Add interaction w/ required/optional keys Change TypedDict to respect keys that are marked as Required or NotRequired (requires PEP 560). Make TypedDict and is_typeddict accessible if typing doesn't implement Required. * PEP 655 Add interaction with get_type_hints() Replace _strip_annotations() with _strip_extras() to strip Annotated, Required and NotRequired. Change get_type_hints() to pass include_extras=True to newer versions of typing.get_type_hints() and use _strip_extras(). Make get_type_hints accessible if typing doesn't implement Required.
1 parent f6e8272 commit 773f759

File tree

3 files changed

+139
-63
lines changed

3 files changed

+139
-63
lines changed

typing_extensions/CHANGELOG

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# Release 4.x.x
22

3+
- Add interaction of `Required` and `NotRequired` with `__required_keys__`,
4+
`__optional_keys__` and `get_type_hints()`. Patch by David Cabot (@d-k-bo).
35
- Runtime support for PEP 675 and `typing_extensions.LiteralString`.
46
- Add `Never` and `assert_never`. Backport from bpo-46475.
57
- `ParamSpec` args and kwargs are now equal to themselves. Backport from

typing_extensions/src/test_typing_extensions.py

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -543,6 +543,18 @@ class Animal(BaseAnimal, total=False):
543543
class Cat(Animal):
544544
fur_color: str
545545

546+
class TotalMovie(TypedDict):
547+
title: str
548+
year: NotRequired[int]
549+
550+
class NontotalMovie(TypedDict, total=False):
551+
title: Required[str]
552+
year: int
553+
554+
class AnnotatedMovie(TypedDict):
555+
title: Annotated[Required[str], "foobar"]
556+
year: NotRequired[Annotated[int, 2000]]
557+
546558

547559
gth = get_type_hints
548560

@@ -1651,7 +1663,7 @@ def test_typeddict_create_errors(self):
16511663

16521664
def test_typeddict_errors(self):
16531665
Emp = TypedDict('Emp', {'name': str, 'id': int})
1654-
if sys.version_info >= (3, 9, 2):
1666+
if hasattr(typing, "Required"):
16551667
self.assertEqual(TypedDict.__module__, 'typing')
16561668
else:
16571669
self.assertEqual(TypedDict.__module__, 'typing_extensions')
@@ -1719,6 +1731,15 @@ def test_optional_keys(self):
17191731
assert Point2Dor3D.__required_keys__ == frozenset(['x', 'y'])
17201732
assert Point2Dor3D.__optional_keys__ == frozenset(['z'])
17211733

1734+
@skipUnless(PEP_560, "runtime support for Required and NotRequired requires PEP 560")
1735+
def test_required_notrequired_keys(self):
1736+
assert NontotalMovie.__required_keys__ == frozenset({'title'})
1737+
assert NontotalMovie.__optional_keys__ == frozenset({'year'})
1738+
1739+
assert TotalMovie.__required_keys__ == frozenset({'title'})
1740+
assert TotalMovie.__optional_keys__ == frozenset({'year'})
1741+
1742+
17221743
def test_keys_inheritance(self):
17231744
assert BaseAnimal.__required_keys__ == frozenset(['name'])
17241745
assert BaseAnimal.__optional_keys__ == frozenset([])
@@ -2023,6 +2044,19 @@ def __iand__(self, other: Const["MySet[T]"]) -> "MySet[T]":
20232044
{'other': MySet[T], 'return': MySet[T]}
20242045
)
20252046

2047+
def test_get_type_hints_typeddict(self):
2048+
assert get_type_hints(TotalMovie) == {'title': str, 'year': int}
2049+
assert get_type_hints(TotalMovie, include_extras=True) == {
2050+
'title': str,
2051+
'year': NotRequired[int],
2052+
}
2053+
2054+
assert get_type_hints(AnnotatedMovie) == {'title': str, 'year': int}
2055+
assert get_type_hints(AnnotatedMovie, include_extras=True) == {
2056+
'title': Annotated[Required[str], "foobar"],
2057+
'year': NotRequired[Annotated[int, 2000]],
2058+
}
2059+
20262060

20272061
class TypeAliasTests(BaseTestCase):
20282062
def test_canonical_usage_with_variable_annotation(self):
@@ -2606,7 +2640,8 @@ def test_typing_extensions_defers_when_possible(self):
26062640
'TypedDict',
26072641
'TYPE_CHECKING',
26082642
'Final',
2609-
'get_type_hints'
2643+
'get_type_hints',
2644+
'is_typeddict',
26102645
}
26112646
if sys.version_info < (3, 10):
26122647
exclude |= {'get_args', 'get_origin'}

typing_extensions/src/typing_extensions.py

Lines changed: 100 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -991,13 +991,16 @@ def __index__(self) -> int:
991991
pass
992992

993993

994-
if sys.version_info >= (3, 9, 2):
994+
if hasattr(typing, "Required"):
995995
# The standard library TypedDict in Python 3.8 does not store runtime information
996996
# about which (if any) keys are optional. See https://bugs.python.org/issue38834
997997
# The standard library TypedDict in Python 3.9.0/1 does not honour the "total"
998998
# keyword with old-style TypedDict(). See https://bugs.python.org/issue42059
999+
# The standard library TypedDict below Python 3.11 does not store runtime
1000+
# information about optional and required keys when using Required or NotRequired.
9991001
TypedDict = typing.TypedDict
10001002
_TypedDictMeta = typing._TypedDictMeta
1003+
is_typeddict = typing.is_typeddict
10011004
else:
10021005
def _check_fails(cls, other):
10031006
try:
@@ -1081,7 +1084,6 @@ def __new__(cls, name, bases, ns, total=True):
10811084

10821085
annotations = {}
10831086
own_annotations = ns.get('__annotations__', {})
1084-
own_annotation_keys = set(own_annotations.keys())
10851087
msg = "TypedDict('Name', {f0: t0, f1: t1, ...}); each t must be a type"
10861088
own_annotations = {
10871089
n: typing._type_check(tp, msg) for n, tp in own_annotations.items()
@@ -1095,10 +1097,29 @@ def __new__(cls, name, bases, ns, total=True):
10951097
optional_keys.update(base.__dict__.get('__optional_keys__', ()))
10961098

10971099
annotations.update(own_annotations)
1098-
if total:
1099-
required_keys.update(own_annotation_keys)
1100+
if PEP_560:
1101+
for annotation_key, annotation_type in own_annotations.items():
1102+
annotation_origin = get_origin(annotation_type)
1103+
if annotation_origin is Annotated:
1104+
annotation_args = get_args(annotation_type)
1105+
if annotation_args:
1106+
annotation_type = annotation_args[0]
1107+
annotation_origin = get_origin(annotation_type)
1108+
1109+
if annotation_origin is Required:
1110+
required_keys.add(annotation_key)
1111+
elif annotation_origin is NotRequired:
1112+
optional_keys.add(annotation_key)
1113+
elif total:
1114+
required_keys.add(annotation_key)
1115+
else:
1116+
optional_keys.add(annotation_key)
11001117
else:
1101-
optional_keys.update(own_annotation_keys)
1118+
own_annotation_keys = set(own_annotations.keys())
1119+
if total:
1120+
required_keys.update(own_annotation_keys)
1121+
else:
1122+
optional_keys.update(own_annotation_keys)
11021123

11031124
tp_dict.__annotations__ = annotations
11041125
tp_dict.__required_keys__ = frozenset(required_keys)
@@ -1141,10 +1162,6 @@ class Point2D(TypedDict):
11411162
syntax forms work for Python 2.7 and 3.2+
11421163
"""
11431164

1144-
1145-
if hasattr(typing, "is_typeddict"):
1146-
is_typeddict = typing.is_typeddict
1147-
else:
11481165
if hasattr(typing, "_TypedDictMeta"):
11491166
_TYPEDDICT_TYPES = (typing._TypedDictMeta, _TypedDictMeta)
11501167
else:
@@ -1163,11 +1180,83 @@ class Film(TypedDict):
11631180
"""
11641181
return isinstance(tp, tuple(_TYPEDDICT_TYPES))
11651182

1183+
if hasattr(typing, "Required"):
1184+
get_type_hints = typing.get_type_hints
1185+
elif PEP_560:
1186+
import functools
1187+
import types
11661188

1167-
# Python 3.9+ has PEP 593 (Annotated and modified get_type_hints)
1189+
# replaces _strip_annotations()
1190+
def _strip_extras(t):
1191+
"""Strips Annotated, Required and NotRequired from a given type."""
1192+
if isinstance(t, _AnnotatedAlias):
1193+
return _strip_extras(t.__origin__)
1194+
if hasattr(t, "__origin__") and t.__origin__ in (Required, NotRequired):
1195+
return _strip_extras(t.__args__[0])
1196+
if isinstance(t, typing._GenericAlias):
1197+
stripped_args = tuple(_strip_extras(a) for a in t.__args__)
1198+
if stripped_args == t.__args__:
1199+
return t
1200+
return t.copy_with(stripped_args)
1201+
if hasattr(types, "GenericAlias") and isinstance(t, types.GenericAlias):
1202+
stripped_args = tuple(_strip_extras(a) for a in t.__args__)
1203+
if stripped_args == t.__args__:
1204+
return t
1205+
return types.GenericAlias(t.__origin__, stripped_args)
1206+
if hasattr(types, "UnionType") and isinstance(t, types.UnionType):
1207+
stripped_args = tuple(_strip_extras(a) for a in t.__args__)
1208+
if stripped_args == t.__args__:
1209+
return t
1210+
return functools.reduce(operator.or_, stripped_args)
1211+
1212+
return t
1213+
1214+
def get_type_hints(obj, globalns=None, localns=None, include_extras=False):
1215+
"""Return type hints for an object.
1216+
1217+
This is often the same as obj.__annotations__, but it handles
1218+
forward references encoded as string literals, adds Optional[t] if a
1219+
default value equal to None is set and recursively replaces all
1220+
'Annotated[T, ...]', 'Required[T]' or 'NotRequired[T]' with 'T'
1221+
(unless 'include_extras=True').
1222+
1223+
The argument may be a module, class, method, or function. The annotations
1224+
are returned as a dictionary. For classes, annotations include also
1225+
inherited members.
1226+
1227+
TypeError is raised if the argument is not of a type that can contain
1228+
annotations, and an empty dictionary is returned if no annotations are
1229+
present.
1230+
1231+
BEWARE -- the behavior of globalns and localns is counterintuitive
1232+
(unless you are familiar with how eval() and exec() work). The
1233+
search order is locals first, then globals.
1234+
1235+
- If no dict arguments are passed, an attempt is made to use the
1236+
globals from obj (or the respective module's globals for classes),
1237+
and these are also used as the locals. If the object does not appear
1238+
to have globals, an empty dictionary is used.
1239+
1240+
- If one dict argument is passed, it is used for both globals and
1241+
locals.
1242+
1243+
- If two dict arguments are passed, they specify globals and
1244+
locals, respectively.
1245+
"""
1246+
if hasattr(typing, "Annotated"):
1247+
hint = typing.get_type_hints(
1248+
obj, globalns=globalns, localns=localns, include_extras=True
1249+
)
1250+
else:
1251+
hint = typing.get_type_hints(obj, globalns=globalns, localns=localns)
1252+
if include_extras:
1253+
return hint
1254+
return {k: _strip_extras(t) for k, t in hint.items()}
1255+
1256+
1257+
# Python 3.9+ has PEP 593 (Annotated)
11681258
if hasattr(typing, 'Annotated'):
11691259
Annotated = typing.Annotated
1170-
get_type_hints = typing.get_type_hints
11711260
# Not exported and not a public API, but needed for get_origin() and get_args()
11721261
# to work.
11731262
_AnnotatedAlias = typing._AnnotatedAlias
@@ -1269,56 +1358,6 @@ def __init_subclass__(cls, *args, **kwargs):
12691358
raise TypeError(
12701359
f"Cannot subclass {cls.__module__}.Annotated"
12711360
)
1272-
1273-
def _strip_annotations(t):
1274-
"""Strips the annotations from a given type.
1275-
"""
1276-
if isinstance(t, _AnnotatedAlias):
1277-
return _strip_annotations(t.__origin__)
1278-
if isinstance(t, typing._GenericAlias):
1279-
stripped_args = tuple(_strip_annotations(a) for a in t.__args__)
1280-
if stripped_args == t.__args__:
1281-
return t
1282-
res = t.copy_with(stripped_args)
1283-
res._special = t._special
1284-
return res
1285-
return t
1286-
1287-
def get_type_hints(obj, globalns=None, localns=None, include_extras=False):
1288-
"""Return type hints for an object.
1289-
1290-
This is often the same as obj.__annotations__, but it handles
1291-
forward references encoded as string literals, adds Optional[t] if a
1292-
default value equal to None is set and recursively replaces all
1293-
'Annotated[T, ...]' with 'T' (unless 'include_extras=True').
1294-
1295-
The argument may be a module, class, method, or function. The annotations
1296-
are returned as a dictionary. For classes, annotations include also
1297-
inherited members.
1298-
1299-
TypeError is raised if the argument is not of a type that can contain
1300-
annotations, and an empty dictionary is returned if no annotations are
1301-
present.
1302-
1303-
BEWARE -- the behavior of globalns and localns is counterintuitive
1304-
(unless you are familiar with how eval() and exec() work). The
1305-
search order is locals first, then globals.
1306-
1307-
- If no dict arguments are passed, an attempt is made to use the
1308-
globals from obj (or the respective module's globals for classes),
1309-
and these are also used as the locals. If the object does not appear
1310-
to have globals, an empty dictionary is used.
1311-
1312-
- If one dict argument is passed, it is used for both globals and
1313-
locals.
1314-
1315-
- If two dict arguments are passed, they specify globals and
1316-
locals, respectively.
1317-
"""
1318-
hint = typing.get_type_hints(obj, globalns=globalns, localns=localns)
1319-
if include_extras:
1320-
return hint
1321-
return {k: _strip_annotations(t) for k, t in hint.items()}
13221361
# 3.6
13231362
else:
13241363

0 commit comments

Comments
 (0)