Skip to content

Commit 3aae5ec

Browse files
committed
Allow closed TypedDict in typing-only contexts
1 parent 0113532 commit 3aae5ec

6 files changed

Lines changed: 85 additions & 32 deletions

File tree

mypy/nodes.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1664,6 +1664,7 @@ class ClassDef(Statement):
16641664
"has_incompatible_baseclass",
16651665
"docstring",
16661666
"removed_statements",
1667+
"is_mypy_only",
16671668
)
16681669

16691670
__match_args__ = ("name", "defs")
@@ -1714,6 +1715,7 @@ def __init__(
17141715
self.has_incompatible_baseclass = False
17151716
self.docstring: str | None = None
17161717
self.removed_statements = []
1718+
self.is_mypy_only = False
17171719

17181720
@property
17191721
def fullname(self) -> str:
@@ -1862,6 +1864,7 @@ class AssignmentStmt(Statement):
18621864
"is_alias_def",
18631865
"is_final_def",
18641866
"invalid_recursive_alias",
1867+
"is_mypy_only",
18651868
)
18661869

18671870
__match_args__ = ("lvalues", "rvalues", "type")
@@ -1904,6 +1907,7 @@ def __init__(
19041907
self.is_alias_def = False
19051908
self.is_final_def = False
19061909
self.invalid_recursive_alias = False
1910+
self.is_mypy_only = False
19071911

19081912
def accept(self, visitor: StatementVisitor[T]) -> T:
19091913
return visitor.visit_assignment_stmt(self)

mypy/reachability.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@
88
from mypy.nodes import (
99
LITERAL_YES,
1010
AssertStmt,
11+
AssignmentStmt,
1112
Block,
1213
CallExpr,
14+
ClassDef,
1315
ComparisonExpr,
1416
Expression,
1517
FuncDef,
@@ -56,6 +58,8 @@ def infer_reachability_of_if_statement(s: IfStmt, options: Options) -> None:
5658
if result in (ALWAYS_FALSE, MYPY_FALSE):
5759
# The condition is considered always false, so we skip the if/elif body.
5860
mark_block_unreachable(s.body[i])
61+
if result == MYPY_FALSE and i == len(s.expr) - 1 and s.else_body:
62+
mark_block_mypy_only(s.else_body)
5963
elif result in (ALWAYS_TRUE, MYPY_TRUE):
6064
# This condition is considered always true, so all of the remaining
6165
# elif/else bodies should not be checked.
@@ -368,3 +372,12 @@ def visit_import_all(self, node: ImportAll) -> None:
368372

369373
def visit_func_def(self, node: FuncDef) -> None:
370374
node.is_mypy_only = True
375+
super().visit_func_def(node)
376+
377+
def visit_class_def(self, node: ClassDef) -> None:
378+
node.is_mypy_only = True
379+
super().visit_class_def(node)
380+
381+
def visit_assignment_stmt(self, node: AssignmentStmt) -> None:
382+
node.is_mypy_only = True
383+
super().visit_assignment_stmt(node)

mypy/semanal.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3676,7 +3676,7 @@ def analyze_typeddict_assign(self, s: AssignmentStmt) -> bool:
36763676
namespace = self.qualified_name(name)
36773677
with self.tvar_scope_frame(self.tvar_scope.class_frame(namespace)):
36783678
is_typed_dict, info, tvar_defs = self.typed_dict_analyzer.check_typeddict(
3679-
s.rvalue, name
3679+
s.rvalue, name, s.is_mypy_only
36803680
)
36813681
if not is_typed_dict:
36823682
return False

mypy/semanal_typeddict.py

Lines changed: 34 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -112,25 +112,30 @@ def analyze_typeddict_classdef(self, defn: ClassDef) -> tuple[bool, TypeInfo | N
112112
if isinstance(defn.analyzed, TypedDictExpr):
113113
existing_info = defn.analyzed.info
114114

115-
base_fullname: str | None = None
116-
if (
117-
len(defn.base_type_exprs) == 1
118-
and isinstance(defn.base_type_exprs[0], RefExpr)
119-
and defn.base_type_exprs[0].fullname in TPDICT_NAMES
120-
):
121-
base_fullname = defn.base_type_exprs[0].fullname
122-
123115
is_closed: bool | None = None
124116
if "closed" in defn.keywords:
125-
is_closed = self.parse_typeddict_closed_argument(
126-
defn.keywords["closed"], base_fullname
117+
is_closed = require_bool_literal_argument(
118+
self.api, defn.keywords["closed"], "closed", False
127119
)
128120

129121
if (
130122
len(defn.base_type_exprs) == 1
131123
and isinstance(defn.base_type_exprs[0], RefExpr)
132124
and defn.base_type_exprs[0].fullname in TPDICT_NAMES
133125
):
126+
if (
127+
is_closed is not None
128+
and parse_bool(defn.keywords["closed"]) is not None
129+
and defn.base_type_exprs[0].fullname == "typing.TypedDict"
130+
and self.options.python_version < (3, 15)
131+
and not self.api.is_stub_file
132+
and not defn.is_mypy_only
133+
):
134+
self.fail(
135+
'"closed" argument to TypedDict is only available in Python 3.15 and later',
136+
defn.keywords["closed"],
137+
)
138+
is_closed = None
134139
# Building a new TypedDict
135140
field_sources, statements = self.analyze_typeddict_classdef_fields(defn)
136141
if field_sources is None:
@@ -157,7 +162,9 @@ def analyze_typeddict_classdef(self, defn: ClassDef) -> tuple[bool, TypeInfo | N
157162
typeddict_bases: list[Expression] = []
158163
typeddict_bases_set = set()
159164
for i, expr in enumerate(defn.base_type_exprs):
160-
ok, maybe_type_info, _ = self.check_typeddict(expr, inline_base(defn.name, i))
165+
ok, maybe_type_info, _ = self.check_typeddict(
166+
expr, inline_base(defn.name, i), defn.is_mypy_only
167+
)
161168
if ok and maybe_type_info is not None:
162169
# expr is a CallExpr
163170
info = maybe_type_info
@@ -585,7 +592,7 @@ def extract_meta_info(
585592
return typ, is_required, readonly
586593

587594
def check_typeddict(
588-
self, node: Expression, name: str
595+
self, node: Expression, name: str, is_mypy_only: bool = False
589596
) -> tuple[bool, TypeInfo | None, list[TypeVarLikeType]]:
590597
"""Check if a call defines a TypedDict.
591598
@@ -608,7 +615,7 @@ def check_typeddict(
608615
fullname = callee.fullname
609616
if fullname not in TPDICT_NAMES:
610617
return False, None, []
611-
res = self.parse_typeddict_args(call, fullname)
618+
res = self.parse_typeddict_args(call, fullname, is_mypy_only)
612619
if res is None:
613620
# This is a valid typed dict, but some type is not ready.
614621
# The caller should defer this until next iteration.
@@ -671,7 +678,7 @@ def check_typeddict(
671678
return True, info, tvar_defs
672679

673680
def parse_typeddict_args(
674-
self, call: CallExpr, fullname: str
681+
self, call: CallExpr, fullname: str, is_mypy_only: bool
675682
) -> tuple[str, list[str], list[Type], bool, bool, list[TypeVarLikeType], bool] | None:
676683
"""Parse typed dict call expression.
677684
@@ -712,7 +719,19 @@ def parse_typeddict_args(
712719
for arg_name, arg in zip(call.arg_names[2:], call.args[2:]):
713720
assert arg_name
714721
if arg_name == "closed":
715-
value = self.parse_typeddict_closed_argument(arg, fullname)
722+
value = require_bool_literal_argument(self.api, arg, "closed", False)
723+
if (
724+
parse_bool(arg) is not None
725+
and fullname == "typing.TypedDict"
726+
and self.options.python_version < (3, 15)
727+
and not self.api.is_stub_file
728+
and not is_mypy_only
729+
):
730+
self.fail(
731+
'"closed" argument to TypedDict is only available in Python 3.15 and later',
732+
arg,
733+
)
734+
return "", [], [], True, False, [], False
716735
else:
717736
value = require_bool_literal_argument(self.api, arg, arg_name)
718737
if value is None:
@@ -731,22 +750,6 @@ def parse_typeddict_args(
731750
assert total is not None
732751
return args[0].value, items, types, total, closed, tvar_defs, ok
733752

734-
def parse_typeddict_closed_argument(
735-
self, arg: Expression, fullname: str | None
736-
) -> bool | None:
737-
literal_value = parse_bool(arg)
738-
value = require_bool_literal_argument(self.api, arg, "closed", False)
739-
if (
740-
literal_value is not None
741-
and fullname == "typing.TypedDict"
742-
and self.options.python_version < (3, 15)
743-
):
744-
self.fail(
745-
'"closed" argument to TypedDict is only available in Python 3.15 and later', arg
746-
)
747-
return None
748-
return value
749-
750753
def parse_typeddict_fields_with_types(
751754
self, dict_items: list[tuple[Expression | None, Expression]]
752755
) -> tuple[list[str], list[Type], bool] | None:

mypy/treetransform.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,7 @@ def visit_class_def(self, node: ClassDef) -> ClassDef:
272272
new.fullname = node.fullname
273273
new.info = node.info
274274
new.decorators = [self.expr(decorator) for decorator in node.decorators]
275+
new.is_mypy_only = node.is_mypy_only
275276
return new
276277

277278
def visit_global_decl(self, node: GlobalDecl) -> GlobalDecl:
@@ -327,6 +328,7 @@ def duplicate_assignment(self, node: AssignmentStmt) -> AssignmentStmt:
327328
)
328329
new.line = node.line
329330
new.is_final_def = node.is_final_def
331+
new.is_mypy_only = node.is_mypy_only
330332
new.type = self.optional_type(node.type)
331333
return new
332334

test-data/unit/check-typeddict.test

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5233,6 +5233,7 @@ reveal_type(e) # N: Revealed type is "TypedDict('__main__.E', {'x': builtins.in
52335233
[case testTypedDictClosedArgumentBeforePython315]
52345234
# flags: --python-version 3.14
52355235
from typing import TypedDict
5236+
from typing_extensions import TYPE_CHECKING
52365237

52375238
class ClassClosed(TypedDict, closed=True): # E: "closed" argument to TypedDict is only available in Python 3.15 and later
52385239
x: int
@@ -5244,8 +5245,38 @@ class InvalidClosed(TypedDict, closed=0): # E: "closed" argument must be a True
52445245
x: int
52455246
class WithoutClosed(TypedDict):
52465247
x: int
5248+
5249+
if TYPE_CHECKING:
5250+
class TypeCheckingClassClosed(TypedDict, closed=True):
5251+
x: int
5252+
class TypeCheckingClassOpen(TypedDict, closed=False):
5253+
x: int
5254+
TypeCheckingFunctionalClosed = TypedDict('TypeCheckingFunctionalClosed', {'x': int}, closed=True)
5255+
TypeCheckingFunctionalOpen = TypedDict('TypeCheckingFunctionalOpen', {'x': int}, closed=False)
5256+
class TypeCheckingInvalidClosed(TypedDict, closed=0): # E: "closed" argument must be a True or False literal
5257+
x: int
5258+
def type_checking_function() -> None:
5259+
class NestedClassClosed(TypedDict, closed=True):
5260+
x: int
5261+
NestedFunctionalOpen = TypedDict('NestedFunctionalOpen', {'x': int}, closed=False)
5262+
if not TYPE_CHECKING:
5263+
pass
5264+
else:
5265+
class ElseClassClosed(TypedDict, closed=True):
5266+
x: int
5267+
ElseFunctionalOpen = TypedDict('ElseFunctionalOpen', {'x': int}, closed=False)
5268+
from stub import StubClassClosed, StubClassOpen, StubFunctionalClosed, StubFunctionalOpen
52475269
[builtins fixtures/dict.pyi]
52485270
[typing fixtures/typing-typeddict.pyi]
5271+
[file stub.pyi]
5272+
from typing import TypedDict
5273+
5274+
class StubClassClosed(TypedDict, closed=True):
5275+
x: int
5276+
class StubClassOpen(TypedDict, closed=False):
5277+
x: int
5278+
StubFunctionalClosed = TypedDict('StubFunctionalClosed', {'x': int}, closed=True)
5279+
StubFunctionalOpen = TypedDict('StubFunctionalOpen', {'x': int}, closed=False)
52495280

52505281
[case testTypingExtensionsTypedDictClosedArgumentBeforePython315]
52515282
# flags: --python-version 3.14

0 commit comments

Comments
 (0)