Skip to content

Instantly share code, notes, and snippets.

@mpkocher
Last active October 2, 2019 05:08
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save mpkocher/e123aa0613242715717d291f9df7afdd to your computer and use it in GitHub Desktop.
Save mpkocher/e123aa0613242715717d291f9df7afdd to your computer and use it in GitHub Desktop.
Exploring TypedDict in Python 3
#!/usr/bin/env python3
"""Output from mypy 0.711
typeddict_pad.py:28: error: Module 'typing' has no attribute 'TypedDict'; maybe "_TypedDict"?
typeddict_pad.py:28: error: Name 'TypedDict' already defined (possibly by an import)
typeddict_pad.py:90: error: Unsupported operand types for + ("str" and "int")
typeddict_pad.py:107: error: Argument 2 has incompatible type "int"; expected "str"
typeddict_pad.py:110: error: Unsupported operand types for + ("int" and "str")
typeddict_pad.py:118: error: "Movie" has no attribute "clear"
typeddict_pad.py:128: error: Argument 1 to "update" of "TypedDict" has incompatible type "Dict[str, object]"; expected "TypedDict({'name'?: str, 'year'?: int})"
typeddict_pad.py:137: error: Argument 1 to "update" of "TypedDict" has incompatible type "Movie"; expected "TypedDict({'name'?: str, 'year'?: int})"
typeddict_pad.py:167: error: Argument 2 has incompatible type "int"; expected "str"
typeddict_pad.py:173: error: "Movie2" has no attribute "clear"
typeddict_pad.py:189: error: Unsupported operand types for + ("None" and "int")
typeddict_pad.py:189: note: Left operand is of type "Optional[int]"
typeddict_pad.py:221: error: Cannot use isinstance() with a TypedDict type
"""
import logging
import sys
from typing import Optional
try:
# Python < 3.8
# pip install mypy_extensions
from mypy_extensions import TypedDict
except ImportError:
# Python 3.8
from typing import TypedDict
log = logging.getLogger()
FUNCS = []
def register(f):
FUNCS.append(f)
def fx(*args, **kwargs):
return f(*args, **kwargs)
return fx
def log_expected_error(ex, fx):
raised_error = False
try:
return fx()
except ex as e:
raised_error = True
log.info(f"Got Expected error `{e}` of type {ex} from {fx.__name__}")
finally:
if not raised_error:
log.error(f"Expected {fx} to raise {ex}")
class Movie(TypedDict):
name: str
year: int
@register
def example_00() -> int:
"""Simple Example of Using raw dict and how mypy won't catch
these errors with the keys
"""
m = dict(name='Star Wars', year=1234)
def f():
# mypy will NOT catch this error
t = m['name'] + 100
log_expected_error(TypeError, f)
return 0
@register
def example_01() -> int:
"""Use TypedDict to see what static type check errors are
found by mypy"""
m = Movie(name='Star Wars', year=1977)
log.info(m)
def f() -> int:
# the type checker will catch this
n = m['name'] + 100
return n
log_expected_error(TypeError, f)
return 0
@register
def e_02() -> int:
"""Now explore starting to mutate the Movie
and mixing Movie instances with raw dicts
"""
m = Movie(name='Star Wars', year=1977)
log.info(m)
# mypy will catch this
m['name'] = 11111
def f() -> int:
m['year'] = m['year'] + 'asfdsasdf'
return 0
log_expected_error(TypeError, f)
# Use dict methods to mutate
# mypy is confused by this and generates
# `"Movie" has no attribute "clear"`
m.clear()
def f2() -> int:
return m['year'] + 100
log_expected_error(KeyError, f2)
d2 = dict(extras=True, alpha=1234, name=12345, year='1978')
# mypy will raise TypeError here
m.update(d2)
log.info(m)
# Update a Movie with a Movie
m2 = Movie(name='Star Wars', year=1977)
new_m = Movie(name='Movie Title', year=1234)
# No mypy type error
m.update(new_m)
log.info(m2)
return 0
# I find this entire idea of total=False
# to be confusing from a type
# standpoint. This means that every field of type T
# is fundamentally Optional[T]. How is this
# helping to communicate the interface and not
# raise a not of NPEs?
class Movie2(TypedDict, total=False):
name:str
year:int
release_year: Optional[int]
@register
def e_03() -> int:
"""
Explore with defining an 'incomplete' Movie data model and how
None/Null checking works with mypy
"""
m = Movie2(name='Star Wars')
log.info(m)
def f() -> int:
# mypy will catch this
m['name'] = 1234
return 0
# Use dict methods to mutate
# mypy is confused by this. The error is:
# `"Movie" has no attribute "clear"`
m.clear()
def f2() -> int:
# mypy doesn't catch this NPE
# I don't think it treats the type
# as Optional[int]
m['year'] = m['year'] + 100
return 0
log_expected_error(KeyError, f2)
# Explicit test with release_year which
# is fundamentally Optional[int]
def f3() -> int:
# mypy WILL catch this NPE
m['release_year'] = m['release_year'] + 100
return 0
log_expected_error(KeyError, f3)
# This works as expected and
m2 = Movie2(name='Star Wars', release_year=2049)
# This works as expected and mypy won't raise an error
if m2['release_year'] is not None:
_ = m2['release_year'] + 10
return 0
@register
def e_04() -> int:
"""Testing isinstance"""
m = Movie(name='Movie', year=1234)
def f() -> int:
is_true = isinstance(m, dict)
return 0
# This is a bit unexpected that this
# will raise an exception at runtime
# ` Cannot use isinstance() with a TypedDict type`
def f2() -> int:
is_true = isinstance(m, Movie)
return 0
log_expected_error(TypeError, f2)
return 0
def run_main() -> int:
"""Note, each func needs a type, otherwise mypy will skip static analysis. Hence
all the example funcs return an int
"""
formatter = '%(asctime)s [%(levelname)s] %(lineno)s %(msg)s'
logging.basicConfig(stream=sys.stdout, level=logging.DEBUG, format=formatter)
# this will raise from mypy
#nx: Optional[int] = None
#log.info(nx)
#t = nx + 10
def runner(f) -> int:
log.info(f"Running {f.__name__}")
return f()
_ = list(map(runner, FUNCS))
log.info("Exiting main with exit code 0")
return 0
if __name__ == '__main__':
sys.exit(run_main())
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment