Skip to content

Commit

Permalink
Added support for TypedDict inheritance
Browse files Browse the repository at this point in the history
This is problematic due to CPython bug 42059, but is easily worked around by using class-based declaration instead.
Fixes #101.
  • Loading branch information
agronholm committed Oct 17, 2020
1 parent a0cff15 commit d0a6404
Show file tree
Hide file tree
Showing 5 changed files with 61 additions and 23 deletions.
13 changes: 9 additions & 4 deletions docs/userguide.rst
Original file line number Diff line number Diff line change
Expand Up @@ -199,17 +199,22 @@ Type Notes
``Literal``
``NamedTuple`` Field values are typechecked
``NoReturn``
``Protocol`` Run-time protocols are checked with :func:`isinstance`, others are
ignored
``Protocol`` Run-time protocols are checked with :func:`isinstance`,
others are ignored
``Set`` Contents are typechecked
``Sequence`` Contents are typechecked
``Tuple`` Contents are typechecked
``Type``
``TypedDict`` Contents are typechecked; ``total`` from superclasses is not
respected (see `#101`_ for more information)
``TypedDict`` Contents are typechecked; On Python 3.8 and earlier,
``total`` from superclasses is not respected (see `#101`_ for
more information); On Python 3.9.0 or ``typing_extensions``
<= 3.7.4.3, false positives can happen when constructing
``TypedDict`` classes using old-style syntax (see
`issue 42059`_)
``TypeVar`` Constraints, bound types and co/contravariance are supported
but custom generic types are not (due to type erasure)
``Union``
=============== =============================================================

.. _#101: https://github.com/agronholm/typeguard/issues/101
.. _issue 42059: https://bugs.python.org/issue42059
2 changes: 2 additions & 0 deletions docs/versionhistory.rst
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ This library adheres to `Semantic Versioning 2.0 <https://semver.org/#semantic-v

- Added support for Python 3.9 (PR by Csergő Bálint)
- Added support for nested ``Literal``
- Added support for ``TypedDict`` inheritance (with some caveats; see the user guide on that for
details)
- An appropriate ``TypeError`` is now raised when encountering an illegal ``Literal`` value
- Fixed checking ``NoReturn`` on Python < 3.8 when ``typing_extensions`` was not installed
- Fixed import hook matching unwanted modules (PR by Wouter Bolsterlee)
Expand Down
31 changes: 20 additions & 11 deletions tests/test_typeguard.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@
TConstrained = TypeVar('TConstrained', 'Parent', 'Child')
JSONType = Union[str, int, float, bool, None, List['JSONType'], Dict[str, 'JSONType']]

DummyDict = TypedDict('DummyDict', {'x': int}, total=False)
issue_42059 = pytest.mark.xfail(bool(DummyDict.__required_keys__),
reason='Fails due to upstream bug BPO-42059')
del DummyDict


class Parent:
pass
Expand Down Expand Up @@ -1242,17 +1247,21 @@ def foo(a: Literal[1, 1.1]):
pytest.raises(TypeError, foo, 4).match(r"Illegal literal value: 1.1$")

@pytest.mark.parametrize('value, total, error_re', [
({'x': 6, 'y': 'foo'}, True, None),
({'y': 'foo'}, True, r'required key\(s\) \("x"\) missing from argument "arg"'),
({'x': 6, 'y': 3}, True,
'type of dict item "y" for argument "arg" must be str; got int instead'),
({'x': 6}, True, r'required key\(s\) \("y"\) missing from argument "arg"'),
({'x': 6}, False, None),
({'x': 'abc'}, False,
'type of dict item "x" for argument "arg" must be int; got str instead'),
({'x': 6, 'foo': 'abc'}, False, r'extra key\(s\) \("foo"\) in argument "arg"'),
], ids=['correct', 'missing_x', 'wrong_y', 'missing_y_error', 'missing_y_ok', 'wrong_x',
'unknown_key'])
pytest.param({'x': 6, 'y': 'foo'}, True, None, id='correct'),
pytest.param({'y': 'foo'}, True, r'required key\(s\) \("x"\) missing from argument "arg"',
id='missing_x'),
pytest.param({'x': 6, 'y': 3}, True,
'type of dict item "y" for argument "arg" must be str; got int instead',
id='wrong_y'),
pytest.param({'x': 6}, True, r'required key\(s\) \("y"\) missing from argument "arg"',
id='missing_y_error'),
pytest.param({'x': 6}, False, None, id='missing_y_ok', marks=[issue_42059]),
pytest.param({'x': 'abc'}, False,
'type of dict item "x" for argument "arg" must be int; got str instead',
id='wrong_x', marks=[issue_42059]),
pytest.param({'x': 6, 'foo': 'abc'}, False, r'extra key\(s\) \("foo"\) in argument "arg"',
id='unknown_key')
])
def test_typed_dict(self, value, total, error_re):
DummyDict = TypedDict('DummyDict', {'x': int, 'y': str}, total=total)

Expand Down
19 changes: 19 additions & 0 deletions tests/test_typeguard_py36.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@

from typeguard import TypeChecker, typechecked

try:
from typing_extensions import TypedDict
except ImportError:
from typing import TypedDict


@runtime_checkable
class RuntimeProtocol(Protocol):
Expand Down Expand Up @@ -87,6 +92,20 @@ def foo() -> AsyncGenerator[int, None]:

foo()

def test_typeddict_inherited(self):
class ParentDict(TypedDict):
x: int

class ChildDict(ParentDict, total=False):
y: int

@typechecked
def foo(arg: ChildDict):
pass

foo({'x': 1})
pytest.raises(TypeError, foo, {'y': 1})


async def asyncgenfunc() -> AsyncGenerator[int, None]:
yield 1
Expand Down
19 changes: 11 additions & 8 deletions typeguard/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -311,19 +311,22 @@ def check_dict(argname: str, value, expected_type, memo: _TypeCheckMemo) -> None


def check_typed_dict(argname: str, value, expected_type, memo: _TypeCheckMemo) -> None:
expected_keys = frozenset(expected_type.__annotations__)
existing_keys = frozenset(value)
declared_keys = frozenset(expected_type.__annotations__)
if hasattr(expected_type, '__required_keys__'):
required_keys = expected_type.__required_keys__
else: # py3.8 and lower
required_keys = declared_keys if expected_type.__total__ else frozenset()

extra_keys = existing_keys - expected_keys
existing_keys = frozenset(value)
extra_keys = existing_keys - declared_keys
if extra_keys:
keys_formatted = ', '.join('"{}"'.format(key) for key in sorted(extra_keys))
raise TypeError('extra key(s) ({}) in {}'.format(keys_formatted, argname))

if expected_type.__total__:
missing_keys = expected_keys - existing_keys
if missing_keys:
keys_formatted = ', '.join('"{}"'.format(key) for key in sorted(missing_keys))
raise TypeError('required key(s) ({}) missing from {}'.format(keys_formatted, argname))
missing_keys = required_keys - existing_keys
if missing_keys:
keys_formatted = ', '.join('"{}"'.format(key) for key in sorted(missing_keys))
raise TypeError('required key(s) ({}) missing from {}'.format(keys_formatted, argname))

for key, argtype in get_type_hints(expected_type).items():
argvalue = value.get(key, _missing)
Expand Down

0 comments on commit d0a6404

Please # to comment.