Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement class syntax for TypedDict #2808

Merged
merged 5 commits into from
Feb 7, 2017
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions mypy/semanal.py
Original file line number Diff line number Diff line change
Expand Up @@ -564,6 +564,8 @@ def check_function_signature(self, fdef: FuncItem) -> None:

def visit_class_def(self, defn: ClassDef) -> None:
self.clean_up_bases_and_infer_type_variables(defn)
if self.analyze_typeddict_classdef(defn):
return
if self.analyze_namedtuple_classdef(defn):
return
self.setup_class_def_analysis(defn)
Expand Down Expand Up @@ -944,6 +946,96 @@ def bind_class_type_variables_in_symbol_table(
nodes.append(node)
return nodes

def is_typeddict(self, expr: Expression) -> bool:
return (isinstance(expr, RefExpr) and isinstance(expr.node, TypeInfo) and
expr.node.typeddict_type is not None)

def analyze_typeddict_classdef(self, defn: ClassDef) -> bool:
# special case for TypedDict
possible = False
for base_expr in defn.base_type_exprs:
if isinstance(base_expr, RefExpr):
base_expr.accept(self)
if (base_expr.fullname == 'mypy_extensions.TypedDict' or
self.is_typeddict(base_expr)):
possible = True
if possible:
node = self.lookup(defn.name, defn)
if node is not None:
node.kind = GDEF # TODO in process_namedtuple_definition also applies here
if (len(defn.base_type_exprs) == 1 and
isinstance(defn.base_type_exprs[0], RefExpr) and
defn.base_type_exprs[0].fullname == 'mypy_extensions.TypedDict'):
# Building a new TypedDict
fields, types = self.check_typeddict_classdef(defn)
node.node = self.build_typeddict_typeinfo(defn.name, fields, types)
return True
# Extending/merging existing TypedDicts
if any(not isinstance(expr, RefExpr) or
expr.fullname != 'mypy_extensions.TypedDict' and
not self.is_typeddict(expr) for expr in defn.base_type_exprs):
self.fail("All bases of a new TypedDict must be TypedDict's", defn)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Grammar nit: Replace TypedDict's with TypedDict types or similar.

typeddict_bases = list(filter(self.is_typeddict, defn.base_type_exprs))
newfields = [] # type: List[str]
newtypes = [] # type: List[Type]
tpdict = None # type: OrderedDict[str, Type]
for base in typeddict_bases:
# mypy doesn't yet understand predicates like is_typeddict
tpdict = base.node.typeddict_type.items # type: ignore
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd recommend using a cast instead of # type: ignore, since it's more specific about where mypy gets confused.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It required me three casts to convince mypy that everything is OK and it became unreadable, I used asserts instead.

newdict = tpdict.copy()
for key in tpdict:
if key in newfields:
self.fail('Cannot overwrite TypedDict field {} while merging'
.format(key), defn)
newdict.pop(key)
newfields.extend(newdict.keys())
newtypes.extend(newdict.values())
fields, types = self.check_typeddict_classdef(defn, newfields)
newfields.extend(fields)
newtypes.extend(types)
node.node = self.build_typeddict_typeinfo(defn.name, newfields, newtypes)
return True
return False

def check_typeddict_classdef(self, defn: ClassDef,
oldfields: List[str] = None) -> Tuple[List[str], List[Type]]:
TPDICT_CLASS_ERROR = ('Invalid statement in TypedDict definition; '
'expected "field_name: field_type"')
if self.options.python_version < (3, 6):
self.fail('TypedDict class syntax is only supported in Python 3.6', defn)
return [], []
fields = [] # type: List[str]
types = [] # type: List[Type]
for stmt in defn.defs.body:
if not isinstance(stmt, AssignmentStmt):
# Still allow pass or ... (for empty TypedDict's).
if (not isinstance(stmt, PassStmt) and
not (isinstance(stmt, ExpressionStmt) and
isinstance(stmt.expr, EllipsisExpr))):
self.fail(TPDICT_CLASS_ERROR, stmt)
elif len(stmt.lvalues) > 1 or not isinstance(stmt.lvalues[0], NameExpr):
# An assignment, but an invalid one.
self.fail(TPDICT_CLASS_ERROR, stmt)
else:
name = stmt.lvalues[0].name
if name in (oldfields or []):
self.fail('Cannot overwrite TypedDict field {} while extending'
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use double quotes around field name (e.g., ... TypedDict field "x" ...).

.format(name), stmt)
continue
# Append name and type in this case...
fields.append(name)
types.append(AnyType() if stmt.type is None else self.anal_type(stmt.type))
# ...despite possible minor failures that allow further analyzis.
if name.startswith('_'):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not allow fields starting with underscores? Existing JSON data may use underscore prefixes, I think.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree this is probably an unnecessary limitation (I copied it from the functional form, and that one probably copied it from namedtuple). Maybe it is worth making a separate PR removing this limitation from both forms?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That sounds reasonable.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The no-underscore-prefix limitation allows extending the syntax of the functional form with new keywords in the future if it becomes useful.

I believe the no-underscore-prefix limitation for namedtuples used a similar rationale.

self.fail('TypedDict field name cannot start with an underscore: {}'
.format(name), stmt)
if stmt.type is None or hasattr(stmt, 'new_syntax') and not stmt.new_syntax:
self.fail(TPDICT_CLASS_ERROR, stmt)
elif not isinstance(stmt.rvalue, TempNode):
# x: int assigns rvalue to TempNode(AnyType())
self.fail('Right hand side values are not supported in TypedDict', stmt)
return fields, types

def visit_import(self, i: Import) -> None:
for id, as_id in i.ids:
if as_id is not None:
Expand Down
133 changes: 133 additions & 0 deletions test-data/unit/check-typeddict.test
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,139 @@ p = Point(x='meaning_of_life', y=1337) # E: Incompatible types (expression has
[builtins fixtures/dict.pyi]


-- Define TypedDict (Class syntax)

[case testCanCreateTypedDictWithClass]
# flags: --python-version 3.6
from mypy_extensions import TypedDict

class Point(TypedDict):
x: int
y: int

p = Point(x=42, y=1337)
reveal_type(p) # E: Revealed type is 'TypedDict(x=builtins.int, y=builtins.int, _fallback=typing.Mapping[builtins.str, builtins.int])'
[builtins fixtures/dict.pyi]

[case testCanCreateTypedDictWithSubclass]
# flags: --python-version 3.6
from mypy_extensions import TypedDict

class Point1D(TypedDict):
x: int
class Point2D(Point1D):
y: int
r: Point1D
p: Point2D
reveal_type(r) # E: Revealed type is 'TypedDict(x=builtins.int, _fallback=__main__.Point1D)'
reveal_type(p) # E: Revealed type is 'TypedDict(x=builtins.int, y=builtins.int, _fallback=__main__.Point2D)'
[builtins fixtures/dict.pyi]

[case testCanCreateTypedDictWithSubclass2]
# flags: --python-version 3.6
from mypy_extensions import TypedDict

class Point1D(TypedDict):
x: int
class Point2D(TypedDict, Point1D): # We also allow to include TypedDict in bases, it is simply ignored at runtime
y: int

p: Point2D
reveal_type(p) # E: Revealed type is 'TypedDict(x=builtins.int, y=builtins.int, _fallback=__main__.Point2D)'
[builtins fixtures/dict.pyi]

[case testCanCreateTypedDictClassEmpty]
# flags: --python-version 3.6
from mypy_extensions import TypedDict

class EmptyDict(TypedDict):
pass

p = EmptyDict()
reveal_type(p) # E: Revealed type is 'TypedDict(_fallback=typing.Mapping[builtins.str, builtins.None])'
[builtins fixtures/dict.pyi]


-- Define TypedDict (Class syntax errors)

[case testCanCreateTypedDictWithClassOldVersion]
# flags: --python-version 3.5
from mypy_extensions import TypedDict

class Point(TypedDict): # E: TypedDict class syntax is only supported in Python 3.6
pass
[builtins fixtures/dict.pyi]

[case testCannotCreateTypedDictWithClassOtherBases]
# flags: --python-version 3.6
from mypy_extensions import TypedDict

class A: pass

class Point1D(TypedDict, A): # E: All bases of a new TypedDict must be TypedDict's
x: int
class Point2D(Point1D, A): # E: All bases of a new TypedDict must be TypedDict's
y: int

p: Point2D
reveal_type(p) # E: Revealed type is 'TypedDict(x=builtins.int, y=builtins.int, _fallback=__main__.Point2D)'
[builtins fixtures/dict.pyi]

[case testCannotCreateTypedDictWithClassWithOtherStuff]
# flags: --python-version 3.6
from mypy_extensions import TypedDict

class Point(TypedDict):
x: int
y: int = 1 # E: Right hand side values are not supported in TypedDict
def f(): pass # E: Invalid statement in TypedDict definition; expected "field_name: field_type"
x = 5 # E: Invalid statement in TypedDict definition; expected "field_name: field_type"

p = Point(x=42, y=1337)
reveal_type(p) # E: Revealed type is 'TypedDict(x=builtins.int, y=builtins.int, _fallback=typing.Mapping[builtins.str, builtins.int])'
[builtins fixtures/dict.pyi]

[case testCannotCreateTypedDictWithClassUnderscores]
# flags: --python-version 3.6
from mypy_extensions import TypedDict

class Point(TypedDict):
x: int
_y: int # E: TypedDict field name cannot start with an underscore: _y

p: Point
reveal_type(p) # E: Revealed type is 'TypedDict(x=builtins.int, _y=builtins.int, _fallback=__main__.Point)'
[builtins fixtures/dict.pyi]

[case testCannotCreateTypedDictWithClassOverwriting]
# flags: --python-version 3.6
from mypy_extensions import TypedDict

class Point1(TypedDict):
x: int
class Point2(TypedDict):
x: float
class Bad(Point1, Point2): # E: Cannot overwrite TypedDict field x while merging
pass

b: Bad
reveal_type(b) # E: Revealed type is 'TypedDict(x=builtins.int, _fallback=__main__.Bad)'
[builtins fixtures/dict.pyi]

[case testCannotCreateTypedDictWithClassOverwriting2]
# flags: --python-version 3.6
from mypy_extensions import TypedDict

class Point1(TypedDict):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add test case for duplicate fields within a single definition. Example:

class Bad(TypedDict):
    x: int
    x: int

x: int
class Point2(Point1):
x: float # E: Cannot overwrite TypedDict field x while extending

p2: Point2
reveal_type(p2) # E: Revealed type is 'TypedDict(x=builtins.int, _fallback=__main__.Point2)'
[builtins fixtures/dict.pyi]


-- Subtyping

[case testCanConvertTypedDictToItself]
Expand Down