Skip to content

Experimental: allow inline/anonymous TypedDicts #17457

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

Merged
merged 4 commits into from
Jul 7, 2024
Merged
Show file tree
Hide file tree
Changes from 3 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
6 changes: 4 additions & 2 deletions mypy/checker.py
Original file line number Diff line number Diff line change
Expand Up @@ -2971,7 +2971,8 @@ def visit_assignment_stmt(self, s: AssignmentStmt) -> None:
self.msg.annotation_in_unchecked_function(context=s)

def check_type_alias_rvalue(self, s: AssignmentStmt) -> None:
alias_type = self.expr_checker.accept(s.rvalue)
with self.msg.filter_errors():
alias_type = self.expr_checker.accept(s.rvalue)
self.store_type(s.lvalues[-1], alias_type)

def check_assignment(
Expand Down Expand Up @@ -5311,7 +5312,8 @@ def remove_capture_conflicts(self, type_map: TypeMap, inferred_types: dict[Var,
del type_map[expr]

def visit_type_alias_stmt(self, o: TypeAliasStmt) -> None:
self.expr_checker.accept(o.value)
with self.msg.filter_errors():
self.expr_checker.accept(o.value)

def make_fake_typeinfo(
self,
Expand Down
27 changes: 27 additions & 0 deletions mypy/exprtotype.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@

from mypy.fastparse import parse_type_string
from mypy.nodes import (
MISSING_FALLBACK,
BytesExpr,
CallExpr,
ComplexExpr,
DictExpr,
EllipsisExpr,
Expression,
FloatExpr,
Expand All @@ -29,9 +31,11 @@
AnyType,
CallableArgument,
EllipsisType,
Instance,
ProperType,
RawExpressionType,
Type,
TypedDictType,
TypeList,
TypeOfAny,
UnboundType,
Expand Down Expand Up @@ -67,6 +71,8 @@ def expr_to_unanalyzed_type(

If allow_new_syntax is True, allow all type syntax independent of the target
Python version (used in stubs).

# TODO: a lot of code here is duplicated in fastparse.py, refactor this.
"""
# The `parent` parameter is used in recursive calls to provide context for
# understanding whether an CallableArgument is ok.
Expand Down Expand Up @@ -206,5 +212,26 @@ def expr_to_unanalyzed_type(
return UnpackType(
expr_to_unanalyzed_type(expr.expr, options, allow_new_syntax), from_star_syntax=True
)
elif isinstance(expr, DictExpr):
if not expr.items:
raise TypeTranslationError()
items: dict[str, Type] = {}
extra_items_from = []
for item_name, value in expr.items:
if not isinstance(item_name, StrExpr):
if item_name is None:
extra_items_from.append(
expr_to_unanalyzed_type(value, options, allow_new_syntax, expr)
)
continue
raise TypeTranslationError()
items[item_name.value] = expr_to_unanalyzed_type(
value, options, allow_new_syntax, expr
)
result = TypedDictType(
items, set(), Instance(MISSING_FALLBACK, ()), expr.line, expr.column
)
result.extra_items_from = extra_items_from
return result
else:
raise TypeTranslationError()
20 changes: 18 additions & 2 deletions mypy/fastparse.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
ARG_POS,
ARG_STAR,
ARG_STAR2,
MISSING_FALLBACK,
PARAM_SPEC_KIND,
TYPE_VAR_KIND,
TYPE_VAR_TUPLE_KIND,
Expand All @@ -42,7 +43,6 @@
EllipsisExpr,
Expression,
ExpressionStmt,
FakeInfo,
FloatExpr,
ForStmt,
FuncDef,
Expand Down Expand Up @@ -116,6 +116,7 @@
RawExpressionType,
TupleType,
Type,
TypedDictType,
TypeList,
TypeOfAny,
UnboundType,
Expand Down Expand Up @@ -190,7 +191,6 @@ def ast3_parse(

# There is no way to create reasonable fallbacks at this stage,
# they must be patched later.
MISSING_FALLBACK: Final = FakeInfo("fallback can't be filled out until semanal")
_dummy_fallback: Final = Instance(MISSING_FALLBACK, [], -1)

TYPE_IGNORE_PATTERN: Final = re.compile(r"[^#]*#\s*type:\s*ignore\s*(.*)")
Expand Down Expand Up @@ -2096,6 +2096,22 @@ def visit_Tuple(self, n: ast3.Tuple) -> Type:
column=self.convert_column(n.col_offset),
)

def visit_Dict(self, n: ast3.Dict) -> Type:
if not n.keys:
return self.invalid_type(n)
items: dict[str, Type] = {}
extra_items_from = []
for item_name, value in zip(n.keys, n.values):
if not isinstance(item_name, ast3.Constant) or not isinstance(item_name.value, str):
if item_name is None:
extra_items_from.append(self.visit(value))
continue
return self.invalid_type(n)
items[item_name.value] = self.visit(value)
result = TypedDictType(items, set(), _dummy_fallback, n.lineno, n.col_offset)
result.extra_items_from = extra_items_from
return result

# Attribute(expr value, identifier attr, expr_context ctx)
def visit_Attribute(self, n: Attribute) -> Type:
before_dot = self.visit(n.value)
Expand Down
1 change: 1 addition & 0 deletions mypy/message_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ def with_additional_msg(self, info: str) -> ErrorMessage:
TYPEDDICT_KEY_MUST_BE_STRING_LITERAL: Final = ErrorMessage(
"Expected TypedDict key to be string literal"
)
TYPEDDICT_OVERRIDE_MERGE: Final = 'Overwriting TypedDict field "{}" while merging'
MALFORMED_ASSERT: Final = ErrorMessage("Assertion is always true, perhaps remove parentheses?")
DUPLICATE_TYPE_SIGNATURES: Final = ErrorMessage("Function has duplicate type signatures")
DESCRIPTOR_SET_NOT_CALLABLE: Final = ErrorMessage("{}.__set__ is not callable")
Expand Down
1 change: 1 addition & 0 deletions mypy/nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -3480,6 +3480,7 @@ def __getattribute__(self, attr: str) -> type:
VAR_NO_INFO: Final[TypeInfo] = FakeInfo("Var is lacking info")
CLASSDEF_NO_INFO: Final[TypeInfo] = FakeInfo("ClassDef is lacking info")
FUNC_NO_INFO: Final[TypeInfo] = FakeInfo("FuncBase for non-methods lack info")
MISSING_FALLBACK: Final = FakeInfo("fallback can't be filled out until semanal")


class TypeAlias(SymbolNode):
Expand Down
15 changes: 3 additions & 12 deletions mypy/semanal_typeddict.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from mypy.errorcodes import ErrorCode
from mypy.expandtype import expand_type
from mypy.exprtotype import TypeTranslationError, expr_to_unanalyzed_type
from mypy.message_registry import TYPEDDICT_OVERRIDE_MERGE
from mypy.messages import MessageBuilder
from mypy.nodes import (
ARG_NAMED,
Expand Down Expand Up @@ -216,7 +217,7 @@ def add_keys_and_types_from_base(
valid_items = self.map_items_to_base(valid_items, tvars, base_args)
for key in base_items:
if key in keys:
self.fail(f'Overwriting TypedDict field "{key}" while merging', ctx)
self.fail(TYPEDDICT_OVERRIDE_MERGE.format(key), ctx)
keys.extend(valid_items.keys())
types.extend(valid_items.values())
required_keys.update(base_typed_dict.required_keys)
Expand Down Expand Up @@ -505,17 +506,7 @@ def parse_typeddict_fields_with_types(
field_type_expr, self.options, self.api.is_stub_file
)
except TypeTranslationError:
if (
isinstance(field_type_expr, CallExpr)
and isinstance(field_type_expr.callee, RefExpr)
and field_type_expr.callee.fullname in TPDICT_NAMES
):
self.fail_typeddict_arg(
"Inline TypedDict types not supported; use assignment to define TypedDict",
field_type_expr,
)
else:
self.fail_typeddict_arg("Invalid field type", field_type_expr)
self.fail_typeddict_arg("Use dict literal for nested TypedDict", field_type_expr)
return [], [], False
analyzed = self.api.anal_type(
type,
Expand Down
47 changes: 41 additions & 6 deletions mypy/typeanal.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@
from mypy import errorcodes as codes, message_registry, nodes
from mypy.errorcodes import ErrorCode
from mypy.expandtype import expand_type
from mypy.message_registry import INVALID_PARAM_SPEC_LOCATION, INVALID_PARAM_SPEC_LOCATION_NOTE
from mypy.message_registry import (
INVALID_PARAM_SPEC_LOCATION,
INVALID_PARAM_SPEC_LOCATION_NOTE,
TYPEDDICT_OVERRIDE_MERGE,
)
from mypy.messages import (
MessageBuilder,
format_type,
Expand All @@ -25,6 +29,7 @@
ARG_POS,
ARG_STAR,
ARG_STAR2,
MISSING_FALLBACK,
SYMBOL_FUNCBASE_TYPES,
ArgKind,
Context,
Expand Down Expand Up @@ -1220,10 +1225,39 @@ def visit_tuple_type(self, t: TupleType) -> Type:
return TupleType(self.anal_array(t.items, allow_unpack=True), fallback, t.line)

def visit_typeddict_type(self, t: TypedDictType) -> Type:
items = {
item_name: self.anal_type(item_type) for (item_name, item_type) in t.items.items()
}
return TypedDictType(items, set(t.required_keys), t.fallback)
req_keys = set()
items = {}
for item_name, item_type in t.items.items():
analyzed = self.anal_type(item_type, allow_required=True)
if isinstance(analyzed, RequiredType):
if analyzed.required:
req_keys.add(item_name)
analyzed = analyzed.item
else:
# Keys are required by default.
req_keys.add(item_name)
items[item_name] = analyzed
if t.fallback.type is MISSING_FALLBACK: # anonymous/inline TypedDict
required_keys = req_keys
fallback = self.named_type("typing._TypedDict")
for typ in t.extra_items_from:
analyzed = self.analyze_type(typ)
p_analyzed = get_proper_type(analyzed)
if not isinstance(p_analyzed, TypedDictType):
if not isinstance(p_analyzed, (AnyType, PlaceholderType)):
self.fail("Can only merge-in other TypedDict", t, code=codes.VALID_TYPE)
continue
for sub_item_name, sub_item_type in p_analyzed.items.items():
if sub_item_name in items:
self.fail(TYPEDDICT_OVERRIDE_MERGE.format(sub_item_name), t)
continue
items[sub_item_name] = sub_item_type
if sub_item_name in p_analyzed.required_keys:
req_keys.add(sub_item_name)
else:
required_keys = t.required_keys
fallback = t.fallback
return TypedDictType(items, required_keys, fallback, t.line, t.column)

def visit_raw_expression_type(self, t: RawExpressionType) -> Type:
# We should never see a bare Literal. We synthesize these raw literals
Expand Down Expand Up @@ -1761,11 +1795,12 @@ def anal_type(
allow_param_spec: bool = False,
allow_unpack: bool = False,
allow_ellipsis: bool = False,
allow_required: bool = False,
) -> Type:
if nested:
self.nesting_level += 1
old_allow_required = self.allow_required
self.allow_required = False
self.allow_required = allow_required
old_allow_ellipsis = self.allow_ellipsis
self.allow_ellipsis = allow_ellipsis
old_allow_unpack = self.allow_unpack
Expand Down
4 changes: 3 additions & 1 deletion mypy/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -2519,11 +2519,12 @@ class TypedDictType(ProperType):
TODO: The fallback structure is perhaps overly complicated.
"""

__slots__ = ("items", "required_keys", "fallback")
__slots__ = ("items", "required_keys", "fallback", "extra_items_from")

items: dict[str, Type] # item_name -> item_type
required_keys: set[str]
fallback: Instance
extra_items_from: list[ProperType] # only used during semantic analysis

def __init__(
self,
Expand All @@ -2539,6 +2540,7 @@ def __init__(
self.fallback = fallback
self.can_be_true = len(self.items) > 0
self.can_be_false = len(self.required_keys) == 0
self.extra_items_from = []

def accept(self, visitor: TypeVisitor[T]) -> T:
return visitor.visit_typeddict_type(self)
Expand Down
18 changes: 8 additions & 10 deletions test-data/unit/check-literal.test
Original file line number Diff line number Diff line change
Expand Up @@ -608,36 +608,34 @@ e: Literal[dummy()] # E: Invalid type: Literal[...] cannot contain a

[case testLiteralDisallowCollections]
from typing_extensions import Literal
a: Literal[{"a": 1, "b": 2}] # E: Invalid type: Literal[...] cannot contain arbitrary expressions
a: Literal[{"a": 1, "b": 2}] # E: Parameter 1 of Literal[...] is invalid
b: Literal[{1, 2, 3}] # E: Invalid type: Literal[...] cannot contain arbitrary expressions
c: {"a": 1, "b": 2} # E: Invalid type comment or annotation
c: {"a": 1, "b": 2} # E: Invalid type: try using Literal[1] instead? \
# E: Invalid type: try using Literal[2] instead?
d: {1, 2, 3} # E: Invalid type comment or annotation
[builtins fixtures/tuple.pyi]
[typing fixtures/typing-full.pyi]

[case testLiteralDisallowCollections2]

from typing_extensions import Literal
a: (1, 2, 3) # E: Syntax error in type annotation \
# N: Suggestion: Use Tuple[T1, ..., Tn] instead of (T1, ..., Tn)
b: Literal[[1, 2, 3]] # E: Parameter 1 of Literal[...] is invalid
c: [1, 2, 3] # E: Bracketed expression "[...]" is not valid as a type
[builtins fixtures/tuple.pyi]
[out]

[case testLiteralDisallowCollectionsTypeAlias]

from typing_extensions import Literal
at = Literal[{"a": 1, "b": 2}] # E: Invalid type alias: expression is not a valid type
at = Literal[{"a": 1, "b": 2}] # E: Parameter 1 of Literal[...] is invalid
bt = {"a": 1, "b": 2}
a: at # E: Variable "__main__.at" is not valid as a type \
# N: See https://mypy.readthedocs.io/en/stable/common_issues.html#variables-vs-type-aliases
a: at
reveal_type(a) # N: Revealed type is "Any"
b: bt # E: Variable "__main__.bt" is not valid as a type \
# N: See https://mypy.readthedocs.io/en/stable/common_issues.html#variables-vs-type-aliases
[builtins fixtures/dict.pyi]
[out]
[typing fixtures/typing-typeddict.pyi]

[case testLiteralDisallowCollectionsTypeAlias2]

from typing_extensions import Literal
at = Literal[{1, 2, 3}] # E: Invalid type alias: expression is not a valid type
bt = {1, 2, 3}
Expand Down
15 changes: 13 additions & 2 deletions test-data/unit/check-python312.test
Original file line number Diff line number Diff line change
Expand Up @@ -585,8 +585,7 @@ reveal_type(a) # N: Revealed type is "Any"

[case testPEP695TypeAliasInvalidType]
# flags: --enable-incomplete-feature=NewGenericSyntax
type A = int | 1 # E: Invalid type: try using Literal[1] instead? \
# E: Unsupported operand types for | ("Type[int]" and "int")
type A = int | 1 # E: Invalid type: try using Literal[1] instead?

a: A
reveal_type(a) # N: Revealed type is "Union[builtins.int, Any]"
Expand Down Expand Up @@ -1656,3 +1655,15 @@ type I2 = C[Any] | None
type I3 = None | C[TD]
[builtins fixtures/type.pyi]
[typing fixtures/typing-full.pyi]

[case testTypedDictInlineYesNewStyleAlias]
# flags: --enable-incomplete-feature=NewGenericSyntax
type X[T] = {"item": T, "other": X[T] | None}
x: X[str]
reveal_type(x) # N: Revealed type is "TypedDict({'item': builtins.str, 'other': Union[..., None]})"
if x["other"] is not None:
reveal_type(x["other"]["item"]) # N: Revealed type is "builtins.str"

type Y[T] = {"item": T, **Y[T]} # E: Overwriting TypedDict field "item" while merging
[builtins fixtures/dict.pyi]
[typing fixtures/typing-full.pyi]
Loading