Python TypedDict
Python 3.8에서는 dictionary에 타이핑을 추가할 수 있는 TypedDict가 추가되었다. (PEP 589)
from typing import TypedDict
class Student(TypedDict):
grade: int
name: str
student: Student = { "grade": 1, "name": "tonynamy" }
student = Student(grade=1, name="tonynamy")
student = Student({ "grade": 1, "name": "tonynamy" })
위와 같이 typing
모듈의 TypedDict
를 상속 받는 형태로 타입 정의가 가능하며, Pyright, mypy 등을 이용하여 타입 검사에도 도움을 받을 수 있다.
단, 주의할 점은 TypedDict
를 상속받은 클래스는 일반적인 클래스가 아닌, 타입이라는 점이다.
따라서 추가적인 프로퍼티나 함수를 정의할 수 없다.
TypedDict
구현 뜯어보기
구현 코드
inspect
모듈의 getsource
함수를 이용하면 함수/클래스 등의 정의를 확인할 수 있다.
>>> import inspect
from typing import TypedDict
print(inspect.getsource(TypedDict))
def TypedDict(typename, fields=None, /, *, total=True, **kwargs):
"""A simple typed namespace. At runtime it is equivalent to a plain dict.
TypedDict creates a dictionary type such that a type checker will expect all
instances to have a certain set of keys, where each key is
associated with a value of a consistent type. This expectation
is not checked at runtime.
Usage::
class Point2D(TypedDict):
x: int
y: int
label: str
a: Point2D = {'x': 1, 'y': 2, 'label': 'good'} # OK
b: Point2D = {'z': 3, 'label': 'bad'} # Fails type check
assert Point2D(x=1, y=2, label='first') == dict(x=1, y=2, label='first')
The type info can be accessed via the Point2D.__annotations__ dict, and
the Point2D.__required_keys__ and Point2D.__optional_keys__ frozensets.
TypedDict supports an additional equivalent form::
Point2D = TypedDict('Point2D', {'x': int, 'y': int, 'label': str})
By default, all keys must be present in a TypedDict. It is possible
to override this by specifying totality::
class Point2D(TypedDict, total=False):
x: int
y: int
This means that a Point2D TypedDict can have any of the keys omitted. A type
checker is only expected to support a literal False or True as the value of
the total argument. True is the default, and makes all items defined in the
class body be required.
The Required and NotRequired special forms can also be used to mark
individual keys as being required or not required::
class Point2D(TypedDict):
x: int # the "x" key must always be present (Required is the default)
y: NotRequired[int] # the "y" key can be omitted
See PEP 655 for more details on Required and NotRequired.
"""
if fields is None:
fields = kwargs
elif kwargs:
raise TypeError("TypedDict takes either a dict or keyword arguments,"
" but not both")
if kwargs:
warnings.warn(
"The kwargs-based syntax for TypedDict definitions is deprecated "
"in Python 3.11, will be removed in Python 3.13, and may not be "
"understood by third-party type checkers.",
DeprecationWarning,
stacklevel=2,
)
ns = {'__annotations__': dict(fields)}
module = _caller()
if module is not None:
# Setting correct module is necessary to make typed dict classes pickleable.
ns['__module__'] = module
return _TypedDictMeta(typename, (), ns, total=total)
python 3.11.4 기준 TypedDict
는 위와 같이 정의되어 있다.
주요 attribute
The type info can be accessed via the Point2D.__annotations__ dict, and
the Point2D.__required_keys__ and Point2D.__optional_keys__ frozensets.
TypedDict supports an additional equivalent form::
위 주석으로 미루어보아, TypedDict
의 __annotations__
, __required_keys__
및 __optional_keys__
에 접근할 수 있음을 알 수 있다.
{'grade': <class 'int'>, 'name': <class 'str'>}
>>> Student.__required_keys__
frozenset({'name', 'grade'})
>>> Student.__optional_keys__
frozenset()
앞서 정의한 Student
의 __annotations__
, __required_keys__
및 __optional_keys__
는 위와 같다.
변수명 그대로, __annotations__
는 TypedDict의 각 필드 이름: 타입 dictionary를 반환하고, required_key, optional_key는 각각 required인 key와 optional한 key의 frozenset을 반환한다.
Optional Key
Optional key는 typing.NotRequired
를 이용하여 아래와 같이 정의할 수 있다.
import datetime
from typing import NotRequired, TypedDict
class Student(TypedDict):
grade: int
name: str
graduated_at: NotRequired[datetime.datetime]
student = Student({ # OK
"grade": 1,
"name": "tonynamy",
"graduated_at": datetime.datetime(2023, 2, 14)
})
student = Student({ # Also OK
"grade": 1,
"name": "tonynamy",
})
위 예제에서, __required_keys__
와 __optional_keys__
를 출력해보면 아래와 같이 나온다.
>>> Student.__required_keys__
frozenset({'name', 'grade'})
>>> Student.__optional_keys__
frozenset({'graduated_at'})
Totality
TypedDict 정의 시 total
이라는 kwarg를 전달할 수 있다. TypedDict
정의에서 보면, def TypedDict
가 total
kwarg(default는 True
임)를 _TypedDictMeta
에 전달하는 것을 볼 수 있다.
아까와 동일한 방법으로 _TypedDictMeta
의 정의를 살펴보자.
>>> import inspect
from typing import _TypedDictMeta
print(inspect.getsource(_TypedDictMeta))
class _TypedDictMeta(type):
def __new__(cls, name, bases, ns, total=True):
"""Create a new typed dict class object.
This method is called when TypedDict is subclassed,
or when TypedDict is instantiated. This way
TypedDict supports all three syntax forms described in its docstring.
Subclasses and instances of TypedDict return actual dictionaries.
"""
for base in bases:
if type(base) is not _TypedDictMeta and base is not Generic:
raise TypeError('cannot inherit from both a TypedDict type '
'and a non-TypedDict base class')
if any(issubclass(b, Generic) for b in bases):
generic_base = (Generic,)
else:
generic_base = ()
tp_dict = type.__new__(_TypedDictMeta, name, (*generic_base, dict), ns)
annotations = {}
own_annotations = ns.get('__annotations__', {})
msg = "TypedDict('Name', {f0: t0, f1: t1, ...}); each t must be a type"
own_annotations = {
n: _type_check(tp, msg, module=tp_dict.__module__)
for n, tp in own_annotations.items()
}
required_keys = set()
optional_keys = set()
for base in bases:
annotations.update(base.__dict__.get('__annotations__', {}))
required_keys.update(base.__dict__.get('__required_keys__', ()))
optional_keys.update(base.__dict__.get('__optional_keys__', ()))
annotations.update(own_annotations)
for annotation_key, annotation_type in own_annotations.items():
annotation_origin = get_origin(annotation_type)
if annotation_origin is Annotated:
annotation_args = get_args(annotation_type)
if annotation_args:
annotation_type = annotation_args[0]
annotation_origin = get_origin(annotation_type)
if annotation_origin is Required:
required_keys.add(annotation_key)
elif annotation_origin is NotRequired:
optional_keys.add(annotation_key)
elif total:
required_keys.add(annotation_key)
else:
optional_keys.add(annotation_key)
tp_dict.__annotations__ = annotations
tp_dict.__required_keys__ = frozenset(required_keys)
tp_dict.__optional_keys__ = frozenset(optional_keys)
if not hasattr(tp_dict, '__total__'):
tp_dict.__total__ = total
return tp_dict
__call__ = dict # static method
def __subclasscheck__(cls, other):
# Typed dicts are only for static structural subtyping.
raise TypeError('TypedDict does not support instance and class checks')
__instancecheck__ = __subclasscheck__
코드를 잘 살펴보면 key의 Required 여부를 판단하는 아래 조건문이 보인다.
if annotation_origin is Required:
required_keys.add(annotation_key)
elif annotation_origin is NotRequired:
optional_keys.add(annotation_key)
elif total:
required_keys.add(annotation_key)
else:
optional_keys.add(annotation_key)
annotation_origin
은 타입의 origin 타입을 말하는 것으로, Required[str]
의 경우에는 Required
, NotRequired[str]
의 경우에는 NotRequired
가 이에 해당한다.
즉 key의 required 여부 판단은 아래의 우선 순위를 따른다.
-
Required
로 감싸진 경우 Required -
NotRequired
로 감싸진 경우 Optional - (
Required
혹은NotRequired
로 감싸져 있지 않고)total=True
인 경우 Required - (
Required
혹은NotRequired
로 감싸져 있지 않고)total=False
인 경우 Optional
TypedDict
의 total
kwarg가 default value로 True
를 가지므로, TypedDict
에서 타입은 기본적으로 Required임을 알 수 있다.
실제로, total
kwarg를 테스트해보면 아래와 같이 출력된다.
# `total=True`
>>> class Student(TypedDict, total=True):
... grade: int
... name: str
...
>>> Student.__required_keys__
frozenset({'name', 'grade'})
>>> Student.__optional_keys__
frozenset()
# `total=False`
>>> class Student(TypedDict, total=False):
... grade: int
... name: str
...
>>> Student.__required_keys__
frozenset()
>>> Student.__optional_keys__
frozenset({'name', 'grade'})
total=False
의 경우 모든 키들이 Optional이 된 것을 볼 수 있다.
total=False
인 TypedDict
에서 키를 Required하게 바꾸려면 total=True
일 때와 비슷하게 typing.Required
를 사용해 감싸주어야 한다.
>>> from typing import Required
>>> class Student(TypedDict, total=False):
... grade: Required[int]
... name: str
>>> Student.__required_keys__