From a1643cfc658af18aadb401cb4632d70434f94da0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=B8=D1=80=D0=B8=D0=BB=D0=BB=20=D0=92=D0=B5=D1=82?= =?UTF-8?q?=D1=80=D0=BE=D0=B2?= Date: Sun, 19 Jul 2026 08:40:46 +0300 Subject: [PATCH 1/4] Fix TypedDict closed argument version check --- mypy/semanal_typeddict.py | 38 +++++++++++++-- test-data/unit/check-serialize.test | 5 +- test-data/unit/check-typeddict.test | 76 +++++++++++++++++++++++++++-- 3 files changed, 109 insertions(+), 10 deletions(-) diff --git a/mypy/semanal_typeddict.py b/mypy/semanal_typeddict.py index b1ba9f6c3abec..ee09314fd8a11 100644 --- a/mypy/semanal_typeddict.py +++ b/mypy/semanal_typeddict.py @@ -40,6 +40,7 @@ from mypy.semanal_shared import ( SemanticAnalyzerInterface, has_placeholder, + parse_bool, require_bool_literal_argument, ) from mypy.state import state @@ -111,10 +112,18 @@ def analyze_typeddict_classdef(self, defn: ClassDef) -> tuple[bool, TypeInfo | N if isinstance(defn.analyzed, TypedDictExpr): existing_info = defn.analyzed.info + base_fullname: str | None = None + if ( + len(defn.base_type_exprs) == 1 + and isinstance(defn.base_type_exprs[0], RefExpr) + and defn.base_type_exprs[0].fullname in TPDICT_NAMES + ): + base_fullname = defn.base_type_exprs[0].fullname + is_closed: bool | None = None if "closed" in defn.keywords: - is_closed = require_bool_literal_argument( - self.api, defn.keywords["closed"], "closed", False + is_closed = self.parse_typeddict_closed_argument( + defn.keywords["closed"], base_fullname ) if ( @@ -599,7 +608,7 @@ def check_typeddict( fullname = callee.fullname if fullname not in TPDICT_NAMES: return False, None, [] - res = self.parse_typeddict_args(call) + res = self.parse_typeddict_args(call, fullname) if res is None: # This is a valid typed dict, but some type is not ready. # The caller should defer this until next iteration. @@ -662,7 +671,7 @@ def check_typeddict( return True, info, tvar_defs def parse_typeddict_args( - self, call: CallExpr + self, call: CallExpr, fullname: str ) -> tuple[str, list[str], list[Type], bool, bool, list[TypeVarLikeType], bool] | None: """Parse typed dict call expression. @@ -702,7 +711,10 @@ def parse_typeddict_args( closed: bool = False for arg_name, arg in zip(call.arg_names[2:], call.args[2:]): assert arg_name - value = require_bool_literal_argument(self.api, arg, arg_name) + if arg_name == "closed": + value = self.parse_typeddict_closed_argument(arg, fullname) + else: + value = require_bool_literal_argument(self.api, arg, arg_name) if value is None: return "", [], [], True, False, [], False if arg_name == "closed": @@ -719,6 +731,22 @@ def parse_typeddict_args( assert total is not None return args[0].value, items, types, total, closed, tvar_defs, ok + def parse_typeddict_closed_argument( + self, arg: Expression, fullname: str | None + ) -> bool | None: + literal_value = parse_bool(arg) + value = require_bool_literal_argument(self.api, arg, "closed", False) + if ( + literal_value is not None + and fullname == "typing.TypedDict" + and self.options.python_version < (3, 15) + ): + self.fail( + '"closed" argument to TypedDict is only available in Python 3.15 and later', arg + ) + return None + return value + def parse_typeddict_fields_with_types( self, dict_items: list[tuple[Expression | None, Expression]] ) -> tuple[list[str], list[Type], bool] | None: diff --git a/test-data/unit/check-serialize.test b/test-data/unit/check-serialize.test index f504e00688cff..069b6e290190f 100644 --- a/test-data/unit/check-serialize.test +++ b/test-data/unit/check-serialize.test @@ -1089,6 +1089,7 @@ main:2: note: Revealed type is "TypedDict('m.D', {'x'?: builtins.int, 'y'?: buil main:2: note: Revealed type is "TypedDict('m.D', {'x'?: builtins.int, 'y'?: builtins.str})" [case testSerializeClosedTotalTypedDict] +# flags: --python-version 3.15 from m import d reveal_type(d) [file m.py] @@ -1098,9 +1099,9 @@ d: D [builtins fixtures/dict.pyi] [typing fixtures/typing-typeddict.pyi] [out1] -main:2: note: Revealed type is "TypedDict('m.D', {'x': builtins.int, 'y': builtins.str}, closed=True)" +main:3: note: Revealed type is "TypedDict('m.D', {'x': builtins.int, 'y': builtins.str}, closed=True)" [out2] -main:2: note: Revealed type is "TypedDict('m.D', {'x': builtins.int, 'y': builtins.str}, closed=True)" +main:3: note: Revealed type is "TypedDict('m.D', {'x': builtins.int, 'y': builtins.str}, closed=True)" -- -- Modules diff --git a/test-data/unit/check-typeddict.test b/test-data/unit/check-typeddict.test index 01414ee2f457a..aa65420107e0d 100644 --- a/test-data/unit/check-typeddict.test +++ b/test-data/unit/check-typeddict.test @@ -5203,6 +5203,7 @@ reveal_type(j(c, b)) # N: Revealed type is "TypedDict({'x'?=: builtins.int})" # See https://peps.python.org/pep-0728/ [case testTypedDictWithClosedFalse] +# flags: --python-version 3.15 from typing import TypedDict D = TypedDict('D', {'x': int, 'y': str}, closed=False) d: D @@ -5216,6 +5217,7 @@ reveal_type(e) # N: Revealed type is "TypedDict('__main__.E', {'x': builtins.in [typing fixtures/typing-typeddict.pyi] [case testTypedDictWithClosedTrue] +# flags: --python-version 3.15 from typing import TypedDict D = TypedDict('D', {'x': int, 'y': str}, closed=True) d: D @@ -5228,6 +5230,51 @@ reveal_type(e) # N: Revealed type is "TypedDict('__main__.E', {'x': builtins.in [builtins fixtures/dict.pyi] [typing fixtures/typing-typeddict.pyi] +[case testTypedDictClosedArgumentBeforePython315] +# flags: --python-version 3.14 +from typing import TypedDict + +class ClassClosed(TypedDict, closed=True): # E: "closed" argument to TypedDict is only available in Python 3.15 and later + x: int +class ClassOpen(TypedDict, closed=False): # E: "closed" argument to TypedDict is only available in Python 3.15 and later + x: int +FunctionalClosed = TypedDict('FunctionalClosed', {'x': int}, closed=True) # E: "closed" argument to TypedDict is only available in Python 3.15 and later +FunctionalOpen = TypedDict('FunctionalOpen', {'x': int}, closed=False) # E: "closed" argument to TypedDict is only available in Python 3.15 and later +class InvalidClosed(TypedDict, closed=0): # E: "closed" argument must be a True or False literal + x: int +class WithoutClosed(TypedDict): + x: int +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] + +[case testTypingExtensionsTypedDictClosedArgumentBeforePython315] +# flags: --python-version 3.14 +from typing_extensions import TypedDict + +class ClassClosed(TypedDict, closed=True): + x: int +class ClassOpen(TypedDict, closed=False): + x: int +FunctionalClosed = TypedDict('FunctionalClosed', {'x': int}, closed=True) +FunctionalOpen = TypedDict('FunctionalOpen', {'x': int}, closed=False) +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] +[file typing_extensions.pyi] +TypedDict = 0 + +[case testTypedDictClosedArgumentInPython315] +# flags: --python-version 3.15 +from typing import TypedDict + +class ClassClosed(TypedDict, closed=True): + x: int +class ClassOpen(TypedDict, closed=False): + x: int +FunctionalClosed = TypedDict('FunctionalClosed', {'x': int}, closed=True) +FunctionalOpen = TypedDict('FunctionalOpen', {'x': int}, closed=False) +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] + [case testTypedDictWithInvalidClosedArgument] from typing import TypedDict A = TypedDict('A', {'x': int}, closed=0) # E: "closed" argument must be a True or False literal @@ -5240,6 +5287,7 @@ class D(TypedDict, closed=bool): # E: "closed" argument must be a True or False [typing fixtures/typing-typeddict.pyi] [case testTypedDictWithClosedAndTotal] +# flags: --python-version 3.15 from typing import TypedDict A = TypedDict('A', {'x': int, 'y': str}, total=False, closed=True) B = TypedDict('B', {'x': int, 'y': str}, closed=True, total=False) @@ -5261,6 +5309,7 @@ B = TypedDict('B', {'x': int}, total=True, total=False) # E: Repeated keyword a [typing fixtures/typing-typeddict.pyi] [case testTypedDictSubclassingClosed] +# flags: --python-version 3.15 from typing import TypedDict class D(TypedDict, closed=True): x: int @@ -5308,6 +5357,7 @@ reveal_type(d8) # N: Revealed type is "TypedDict('__main__.D8', {'x': builtins. [typing fixtures/typing-typeddict.pyi] [case testTypedDictSubclassingOpenAndClosed] +# flags: --python-version 3.15 from typing import TypedDict class B1(TypedDict, closed=True): x: int @@ -5332,6 +5382,7 @@ reveal_type(d4) # N: Revealed type is "TypedDict('__main__.D4', {'x': builtins. [typing fixtures/typing-typeddict.pyi] [case testCanSubclassClosedTypedDictWithForwardDeclarations] +# flags: --python-version 3.15 from typing import TypedDict, final class D1(TypedDict, closed=True): @@ -5349,6 +5400,7 @@ reveal_type(d2) # N: Revealed type is "TypedDict('__main__.D2', {'forward_decla [typing fixtures/typing-full.pyi] [case testTypedDictSubtypingClosedMustRemainClosed] +# flags: --python-version 3.15 from typing import TypedDict A = TypedDict('A', {'x': int}, closed=True) B = TypedDict('B', {'x': int}, closed=True) @@ -5368,6 +5420,7 @@ g(b) [typing fixtures/typing-typeddict.pyi] [case testTypedDictSubtypingCannotAddKeysToAClosedSupertype] +# flags: --python-version 3.15 from typing import TypedDict A = TypedDict('A', {'x': int}, closed=True) B = TypedDict('B', {'x': int, 'y': int}, closed=True) @@ -5383,6 +5436,7 @@ g(d) [typing fixtures/typing-typeddict.pyi] [case testTypedDictSubtypingCanDropOptionalReadonlyKeysInAClosedSubtype] +# flags: --python-version 3.15 from typing import ReadOnly, TypedDict # Optional, readonly, closed: permitted A = TypedDict('A', {'x': int, 'y': ReadOnly[int]}, total=False, closed=True) @@ -5416,6 +5470,7 @@ f_g(h) # E: Argument 1 to "f_g" has incompatible type "H"; expected "G" [typing fixtures/typing-typeddict.pyi] [case testTypedDictSubtypingClosedTypeVarBound] +# flags: --python-version 3.15 from typing import TypedDict, TypeVar A = TypedDict('A', {'x': int}, closed=True) B = TypedDict('B', {'x': int}) @@ -5433,6 +5488,7 @@ fB(b) [typing fixtures/typing-typeddict.pyi] [case testTypedDictSubtypingClosedExplicitNever] +# flags: --python-version 3.15 from typing import NotRequired, ReadOnly, TypedDict from typing_extensions import Never A = TypedDict('A', {}, closed=True) @@ -5457,6 +5513,7 @@ f_b(c) # E: Argument 1 to "f_b" has incompatible type "C"; expected "B" [typing fixtures/typing-typeddict.pyi] [case testJoinOfClosedTypedDict] +# flags: --python-version 3.15 from typing import Any, TypedDict, TypeVar A = TypedDict('A', {'x': int}, closed=True) B = TypedDict('B', {'y': int}) @@ -5484,6 +5541,7 @@ reveal_type(j(e, b)) # N: Revealed type is "TypedDict({'y': Any})" [typing fixtures/typing-typeddict.pyi] [case testMeetOfClosedTypedDictsWithMatchingRequiredKeysIsNotAnonymous] +# flags: --python-version 3.15 from typing import TypedDict, TypeVar, Callable X = TypedDict('X', {'x': int, 'y': int}, closed=True) Y = TypedDict('Y', {'x': int, 'y': int}, closed=True) @@ -5502,6 +5560,7 @@ reveal_type(meet(fZX)) # N: Revealed type is "TypedDict('__main__.X', {'x': bui [typing fixtures/typing-typeddict.pyi] [case testMeetOfClosedTypedDictsWithDifferentKeys] +# flags: --python-version 3.15 from typing import TypedDict, TypeVar, Callable, ReadOnly T = TypeVar('T') def meet(x: Callable[[T, T], None]) -> T: pass @@ -5538,6 +5597,7 @@ if int(): [typing fixtures/typing-typeddict.pyi] [case testMeetOfTypedDictsExplicitAndImplicitClosedMissing] +# flags: --python-version 3.15 from typing import Any, NotRequired, ReadOnly, TypedDict, TypeVar, Callable from typing_extensions import Never A = TypedDict('A', {'x': ReadOnly[NotRequired[Never]]}, closed=True) @@ -5550,6 +5610,7 @@ reveal_type(f(g)) # N: Revealed type is "TypedDict({}, closed=True)" [typing fixtures/typing-typeddict.pyi] [case testMeetOfTypedDictClosedMissingAndAny] +# flags: --python-version 3.15 from typing import Any, TypedDict, TypeVar, Callable, ReadOnly T = TypeVar('T') def meet(x: Callable[[T, T], None]) -> T: pass @@ -5570,6 +5631,7 @@ reveal_type(meet(fCA)) # N: Revealed type is "TypedDict({}, closed=True)" [typing fixtures/typing-typeddict.pyi] [case testMeetOfTypedDictsClosedNotRequiredNeverAndAny] +# flags: --python-version 3.15 from typing import Any, NotRequired, TypedDict, TypeVar, Callable from typing_extensions import Never A = TypedDict('A', {'x': NotRequired[Any], 'y': NotRequired[Never]}, closed=True) @@ -5582,6 +5644,7 @@ reveal_type(f(g)) # N: Revealed type is "TypedDict({}, closed=True)" [typing fixtures/typing-typeddict.pyi] [case testMeetOfOpenAndClosedTypedDictsExtraKeyInClosed] +# flags: --python-version 3.15 from typing import TypedDict, TypeVar, Callable, ReadOnly T = TypeVar('T') def meet(x: Callable[[T, T], None]) -> T: pass @@ -5595,6 +5658,7 @@ reveal_type(meet(fAyBAx)) # N: Revealed type is "TypedDict({'a'=: builtins.int, [typing fixtures/typing-typeddict.pyi] [case testMeetOfOpenAndClosedTypedDictsExtraKeyInOpen] +# flags: --python-version 3.15 from typing import TypedDict, TypeVar, Callable, ReadOnly T = TypeVar('T') def meet(x: Callable[[T, T], None]) -> T: pass @@ -5629,6 +5693,7 @@ reveal_type(meet(fD4C)) # N: Revealed type is "TypedDict({'a'=: builtins.int}, [typing fixtures/typing-typeddict.pyi] [case testTypedDictGetMethodClosed] +# flags: --python-version 3.15 from typing import TypedDict, Literal class Unrelated: pass D = TypedDict('D', {'x': int, 'y': str}, closed=True) @@ -5666,6 +5731,7 @@ reveal_type(d.get(x_or_y_or_z, u)) # N: Revealed type is "builtins.int | builtin [typing fixtures/typing-typeddict.pyi] [case testOperatorContainsNarrowsTypedDicts_unionWithList_closed] +# flags: --python-version 3.15 from __future__ import annotations from typing import assert_type, TypedDict, Union @@ -5685,6 +5751,7 @@ else: [typing fixtures/typing-full.pyi] [case testOperatorContainsNarrowsTypedDicts_total_closed] +# flags: --python-version 3.15 from __future__ import annotations from typing import assert_type, Literal, TypedDict, TypeVar, Union @@ -5729,7 +5796,7 @@ def f(arg: TD) -> None: [typing fixtures/typing-full.pyi] [case testOperatorContainsNarrowsTypedDicts_closed] -# flags: --warn-unreachable +# flags: --python-version 3.15 --warn-unreachable from __future__ import annotations from typing import assert_type, TypedDict, Union @@ -5768,6 +5835,7 @@ else: [typing fixtures/typing-full.pyi] [case testOperatorContainsNarrowsTypedDicts_partialThroughTotalFalse_closed] +# flags: --python-version 3.15 from __future__ import annotations from typing import assert_type, Literal, TypedDict, Union @@ -5798,6 +5866,7 @@ else: [typing fixtures/typing-full.pyi] [case testOperatorContainsNarrowsTypedDicts_partialThroughNotRequired_closed] +# flags: --python-version 3.15 from __future__ import annotations from typing import assert_type, TypedDict, Union from typing_extensions import Required, NotRequired @@ -5825,6 +5894,7 @@ else: [typing fixtures/typing-full.pyi] [case testOperatorContainsNarrowsTypeVarWithClosedTypedDictBound] +# flags: --python-version 3.15 from typing import TypedDict, TypeVar from typing_extensions import Never, assert_type @@ -5840,7 +5910,7 @@ def func(t: T): [typing fixtures/typing-typeddict.pyi] [case testTypedDictUnpackFromClosedMissingKey] -# flags: --extra-checks +# flags: --python-version 3.15 --extra-checks from typing import TypedDict from typing_extensions import Never, NotRequired D1 = TypedDict("D1", {"a": int, "b": str}, closed=True) @@ -5853,7 +5923,7 @@ d3: D3 = {**d1} [typing fixtures/typing-typeddict.pyi] [case testTypedDictUnpackIntoClosed] -# flags: --extra-checks +# flags: --python-version 3.15 --extra-checks from typing import Any, Mapping, TypedDict, Union from typing_extensions import Never, NotRequired D1 = TypedDict("D1", {"a": int, "b": str}, closed=True) From 01135327469b5de5715023cd57e3b6d06383b58c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=B8=D1=80=D0=B8=D0=BB=D0=BB=20=D0=92=D0=B5=D1=82?= =?UTF-8?q?=D1=80=D0=BE=D0=B2?= Date: Sun, 19 Jul 2026 09:12:42 +0300 Subject: [PATCH 2/4] Test closed TypedDict fixtures on Python 3.15 --- test-data/unit/diff.test | 1 + test-data/unit/fine-grained.test | 2 ++ 2 files changed, 3 insertions(+) diff --git a/test-data/unit/diff.test b/test-data/unit/diff.test index c4bbf7d3f2283..c42826a53f3af 100644 --- a/test-data/unit/diff.test +++ b/test-data/unit/diff.test @@ -677,6 +677,7 @@ __main__.Point __main__.p [case testTypedDict5] +# flags: --python-version 3.15 from typing import TypedDict class Point(TypedDict): x: int diff --git a/test-data/unit/fine-grained.test b/test-data/unit/fine-grained.test index ef2e8c0b343bf..1c4c0f4091697 100644 --- a/test-data/unit/fine-grained.test +++ b/test-data/unit/fine-grained.test @@ -3746,6 +3746,7 @@ b.py:4: error: ReadOnly TypedDict key "y" TypedDict is mutated b.py:3: error: ReadOnly TypedDict key "x" TypedDict is mutated [case testTypedDictUpdateClosed] +# flags: --python-version 3.15 import b [file a.py] from typing import TypedDict, Union @@ -3789,6 +3790,7 @@ b.py:9: error: TypedDict "B" has no key "a" == [case testTypedDictUpdateClosedPassingToFailing] +# flags: --python-version 3.15 import b [file a.py] from typing import TypedDict, Union From 3aae5ec1ce67614e6f3da83880829ecbdb442517 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=B8=D1=80=D0=B8=D0=BB=D0=BB=20=D0=92=D0=B5=D1=82?= =?UTF-8?q?=D1=80=D0=BE=D0=B2?= Date: Mon, 20 Jul 2026 17:36:25 +0300 Subject: [PATCH 3/4] Allow closed TypedDict in typing-only contexts --- mypy/nodes.py | 4 ++ mypy/reachability.py | 13 ++++++ mypy/semanal.py | 2 +- mypy/semanal_typeddict.py | 65 +++++++++++++++-------------- mypy/treetransform.py | 2 + test-data/unit/check-typeddict.test | 31 ++++++++++++++ 6 files changed, 85 insertions(+), 32 deletions(-) diff --git a/mypy/nodes.py b/mypy/nodes.py index e2ea348d2df11..fe6cbb525ae2c 100644 --- a/mypy/nodes.py +++ b/mypy/nodes.py @@ -1664,6 +1664,7 @@ class ClassDef(Statement): "has_incompatible_baseclass", "docstring", "removed_statements", + "is_mypy_only", ) __match_args__ = ("name", "defs") @@ -1714,6 +1715,7 @@ def __init__( self.has_incompatible_baseclass = False self.docstring: str | None = None self.removed_statements = [] + self.is_mypy_only = False @property def fullname(self) -> str: @@ -1862,6 +1864,7 @@ class AssignmentStmt(Statement): "is_alias_def", "is_final_def", "invalid_recursive_alias", + "is_mypy_only", ) __match_args__ = ("lvalues", "rvalues", "type") @@ -1904,6 +1907,7 @@ def __init__( self.is_alias_def = False self.is_final_def = False self.invalid_recursive_alias = False + self.is_mypy_only = False def accept(self, visitor: StatementVisitor[T]) -> T: return visitor.visit_assignment_stmt(self) diff --git a/mypy/reachability.py b/mypy/reachability.py index 37c7a9715600a..d44f112f936a9 100644 --- a/mypy/reachability.py +++ b/mypy/reachability.py @@ -8,8 +8,10 @@ from mypy.nodes import ( LITERAL_YES, AssertStmt, + AssignmentStmt, Block, CallExpr, + ClassDef, ComparisonExpr, Expression, FuncDef, @@ -56,6 +58,8 @@ def infer_reachability_of_if_statement(s: IfStmt, options: Options) -> None: if result in (ALWAYS_FALSE, MYPY_FALSE): # The condition is considered always false, so we skip the if/elif body. mark_block_unreachable(s.body[i]) + if result == MYPY_FALSE and i == len(s.expr) - 1 and s.else_body: + mark_block_mypy_only(s.else_body) elif result in (ALWAYS_TRUE, MYPY_TRUE): # This condition is considered always true, so all of the remaining # elif/else bodies should not be checked. @@ -368,3 +372,12 @@ def visit_import_all(self, node: ImportAll) -> None: def visit_func_def(self, node: FuncDef) -> None: node.is_mypy_only = True + super().visit_func_def(node) + + def visit_class_def(self, node: ClassDef) -> None: + node.is_mypy_only = True + super().visit_class_def(node) + + def visit_assignment_stmt(self, node: AssignmentStmt) -> None: + node.is_mypy_only = True + super().visit_assignment_stmt(node) diff --git a/mypy/semanal.py b/mypy/semanal.py index e010273b0781f..8dc8dc94bc9ab 100644 --- a/mypy/semanal.py +++ b/mypy/semanal.py @@ -3676,7 +3676,7 @@ def analyze_typeddict_assign(self, s: AssignmentStmt) -> bool: namespace = self.qualified_name(name) with self.tvar_scope_frame(self.tvar_scope.class_frame(namespace)): is_typed_dict, info, tvar_defs = self.typed_dict_analyzer.check_typeddict( - s.rvalue, name + s.rvalue, name, s.is_mypy_only ) if not is_typed_dict: return False diff --git a/mypy/semanal_typeddict.py b/mypy/semanal_typeddict.py index ee09314fd8a11..3268d8f719802 100644 --- a/mypy/semanal_typeddict.py +++ b/mypy/semanal_typeddict.py @@ -112,18 +112,10 @@ def analyze_typeddict_classdef(self, defn: ClassDef) -> tuple[bool, TypeInfo | N if isinstance(defn.analyzed, TypedDictExpr): existing_info = defn.analyzed.info - base_fullname: str | None = None - if ( - len(defn.base_type_exprs) == 1 - and isinstance(defn.base_type_exprs[0], RefExpr) - and defn.base_type_exprs[0].fullname in TPDICT_NAMES - ): - base_fullname = defn.base_type_exprs[0].fullname - is_closed: bool | None = None if "closed" in defn.keywords: - is_closed = self.parse_typeddict_closed_argument( - defn.keywords["closed"], base_fullname + is_closed = require_bool_literal_argument( + self.api, defn.keywords["closed"], "closed", False ) if ( @@ -131,6 +123,19 @@ def analyze_typeddict_classdef(self, defn: ClassDef) -> tuple[bool, TypeInfo | N and isinstance(defn.base_type_exprs[0], RefExpr) and defn.base_type_exprs[0].fullname in TPDICT_NAMES ): + if ( + is_closed is not None + and parse_bool(defn.keywords["closed"]) is not None + and defn.base_type_exprs[0].fullname == "typing.TypedDict" + and self.options.python_version < (3, 15) + and not self.api.is_stub_file + and not defn.is_mypy_only + ): + self.fail( + '"closed" argument to TypedDict is only available in Python 3.15 and later', + defn.keywords["closed"], + ) + is_closed = None # Building a new TypedDict field_sources, statements = self.analyze_typeddict_classdef_fields(defn) if field_sources is None: @@ -157,7 +162,9 @@ def analyze_typeddict_classdef(self, defn: ClassDef) -> tuple[bool, TypeInfo | N typeddict_bases: list[Expression] = [] typeddict_bases_set = set() for i, expr in enumerate(defn.base_type_exprs): - ok, maybe_type_info, _ = self.check_typeddict(expr, inline_base(defn.name, i)) + ok, maybe_type_info, _ = self.check_typeddict( + expr, inline_base(defn.name, i), defn.is_mypy_only + ) if ok and maybe_type_info is not None: # expr is a CallExpr info = maybe_type_info @@ -585,7 +592,7 @@ def extract_meta_info( return typ, is_required, readonly def check_typeddict( - self, node: Expression, name: str + self, node: Expression, name: str, is_mypy_only: bool = False ) -> tuple[bool, TypeInfo | None, list[TypeVarLikeType]]: """Check if a call defines a TypedDict. @@ -608,7 +615,7 @@ def check_typeddict( fullname = callee.fullname if fullname not in TPDICT_NAMES: return False, None, [] - res = self.parse_typeddict_args(call, fullname) + res = self.parse_typeddict_args(call, fullname, is_mypy_only) if res is None: # This is a valid typed dict, but some type is not ready. # The caller should defer this until next iteration. @@ -671,7 +678,7 @@ def check_typeddict( return True, info, tvar_defs def parse_typeddict_args( - self, call: CallExpr, fullname: str + self, call: CallExpr, fullname: str, is_mypy_only: bool ) -> tuple[str, list[str], list[Type], bool, bool, list[TypeVarLikeType], bool] | None: """Parse typed dict call expression. @@ -712,7 +719,19 @@ def parse_typeddict_args( for arg_name, arg in zip(call.arg_names[2:], call.args[2:]): assert arg_name if arg_name == "closed": - value = self.parse_typeddict_closed_argument(arg, fullname) + value = require_bool_literal_argument(self.api, arg, "closed", False) + if ( + parse_bool(arg) is not None + and fullname == "typing.TypedDict" + and self.options.python_version < (3, 15) + and not self.api.is_stub_file + and not is_mypy_only + ): + self.fail( + '"closed" argument to TypedDict is only available in Python 3.15 and later', + arg, + ) + return "", [], [], True, False, [], False else: value = require_bool_literal_argument(self.api, arg, arg_name) if value is None: @@ -731,22 +750,6 @@ def parse_typeddict_args( assert total is not None return args[0].value, items, types, total, closed, tvar_defs, ok - def parse_typeddict_closed_argument( - self, arg: Expression, fullname: str | None - ) -> bool | None: - literal_value = parse_bool(arg) - value = require_bool_literal_argument(self.api, arg, "closed", False) - if ( - literal_value is not None - and fullname == "typing.TypedDict" - and self.options.python_version < (3, 15) - ): - self.fail( - '"closed" argument to TypedDict is only available in Python 3.15 and later', arg - ) - return None - return value - def parse_typeddict_fields_with_types( self, dict_items: list[tuple[Expression | None, Expression]] ) -> tuple[list[str], list[Type], bool] | None: diff --git a/mypy/treetransform.py b/mypy/treetransform.py index 25092de66a149..85b36e057ef9f 100644 --- a/mypy/treetransform.py +++ b/mypy/treetransform.py @@ -272,6 +272,7 @@ def visit_class_def(self, node: ClassDef) -> ClassDef: new.fullname = node.fullname new.info = node.info new.decorators = [self.expr(decorator) for decorator in node.decorators] + new.is_mypy_only = node.is_mypy_only return new def visit_global_decl(self, node: GlobalDecl) -> GlobalDecl: @@ -327,6 +328,7 @@ def duplicate_assignment(self, node: AssignmentStmt) -> AssignmentStmt: ) new.line = node.line new.is_final_def = node.is_final_def + new.is_mypy_only = node.is_mypy_only new.type = self.optional_type(node.type) return new diff --git a/test-data/unit/check-typeddict.test b/test-data/unit/check-typeddict.test index aa65420107e0d..7b3292d5117ed 100644 --- a/test-data/unit/check-typeddict.test +++ b/test-data/unit/check-typeddict.test @@ -5233,6 +5233,7 @@ reveal_type(e) # N: Revealed type is "TypedDict('__main__.E', {'x': builtins.in [case testTypedDictClosedArgumentBeforePython315] # flags: --python-version 3.14 from typing import TypedDict +from typing_extensions import TYPE_CHECKING class ClassClosed(TypedDict, closed=True): # E: "closed" argument to TypedDict is only available in Python 3.15 and later x: int @@ -5244,8 +5245,38 @@ class InvalidClosed(TypedDict, closed=0): # E: "closed" argument must be a True x: int class WithoutClosed(TypedDict): x: int + +if TYPE_CHECKING: + class TypeCheckingClassClosed(TypedDict, closed=True): + x: int + class TypeCheckingClassOpen(TypedDict, closed=False): + x: int + TypeCheckingFunctionalClosed = TypedDict('TypeCheckingFunctionalClosed', {'x': int}, closed=True) + TypeCheckingFunctionalOpen = TypedDict('TypeCheckingFunctionalOpen', {'x': int}, closed=False) + class TypeCheckingInvalidClosed(TypedDict, closed=0): # E: "closed" argument must be a True or False literal + x: int + def type_checking_function() -> None: + class NestedClassClosed(TypedDict, closed=True): + x: int + NestedFunctionalOpen = TypedDict('NestedFunctionalOpen', {'x': int}, closed=False) +if not TYPE_CHECKING: + pass +else: + class ElseClassClosed(TypedDict, closed=True): + x: int + ElseFunctionalOpen = TypedDict('ElseFunctionalOpen', {'x': int}, closed=False) +from stub import StubClassClosed, StubClassOpen, StubFunctionalClosed, StubFunctionalOpen [builtins fixtures/dict.pyi] [typing fixtures/typing-typeddict.pyi] +[file stub.pyi] +from typing import TypedDict + +class StubClassClosed(TypedDict, closed=True): + x: int +class StubClassOpen(TypedDict, closed=False): + x: int +StubFunctionalClosed = TypedDict('StubFunctionalClosed', {'x': int}, closed=True) +StubFunctionalOpen = TypedDict('StubFunctionalOpen', {'x': int}, closed=False) [case testTypingExtensionsTypedDictClosedArgumentBeforePython315] # flags: --python-version 3.14 From 26e5da374a5050f94fac371a8c821f5e7714db24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=B8=D1=80=D0=B8=D0=BB=D0=BB=20=D0=92=D0=B5=D1=82?= =?UTF-8?q?=D1=80=D0=BE=D0=B2?= Date: Mon, 20 Jul 2026 20:27:59 +0300 Subject: [PATCH 4/4] Resolve typing-only context after name binding --- mypy/nodes.py | 10 +- mypy/reachability.py | 103 ++++++++++++++++--- mypy/semanal.py | 18 +++- mypy/semanal_typeddict.py | 12 +-- mypy/treetransform.py | 4 +- test-data/unit/check-typeddict.test | 87 ++++++++++++++++ test-data/unit/fixtures/typing-typeddict.pyi | 1 + 7 files changed, 207 insertions(+), 28 deletions(-) diff --git a/mypy/nodes.py b/mypy/nodes.py index fe6cbb525ae2c..95f8778c4e6d9 100644 --- a/mypy/nodes.py +++ b/mypy/nodes.py @@ -1664,7 +1664,7 @@ class ClassDef(Statement): "has_incompatible_baseclass", "docstring", "removed_statements", - "is_mypy_only", + "is_runtime_unreachable", ) __match_args__ = ("name", "defs") @@ -1715,7 +1715,8 @@ def __init__( self.has_incompatible_baseclass = False self.docstring: str | None = None self.removed_statements = [] - self.is_mypy_only = False + # Set for definitions in an if branch known not to execute at runtime. + self.is_runtime_unreachable = False @property def fullname(self) -> str: @@ -1864,7 +1865,7 @@ class AssignmentStmt(Statement): "is_alias_def", "is_final_def", "invalid_recursive_alias", - "is_mypy_only", + "is_runtime_unreachable", ) __match_args__ = ("lvalues", "rvalues", "type") @@ -1907,7 +1908,8 @@ def __init__( self.is_alias_def = False self.is_final_def = False self.invalid_recursive_alias = False - self.is_mypy_only = False + # Set for definitions in an if branch known not to execute at runtime. + self.is_runtime_unreachable = False def accept(self, visitor: StatementVisitor[T]) -> T: return visitor.visit_assignment_stmt(self) diff --git a/mypy/reachability.py b/mypy/reachability.py index d44f112f936a9..6a3f37aea32d4 100644 --- a/mypy/reachability.py +++ b/mypy/reachability.py @@ -25,6 +25,7 @@ MemberExpr, NameExpr, OpExpr, + RefExpr, SliceExpr, StrExpr, TupleExpr, @@ -58,8 +59,6 @@ def infer_reachability_of_if_statement(s: IfStmt, options: Options) -> None: if result in (ALWAYS_FALSE, MYPY_FALSE): # The condition is considered always false, so we skip the if/elif body. mark_block_unreachable(s.body[i]) - if result == MYPY_FALSE and i == len(s.expr) - 1 and s.else_body: - mark_block_mypy_only(s.else_body) elif result in (ALWAYS_TRUE, MYPY_TRUE): # This condition is considered always true, so all of the remaining # elif/else bodies should not be checked. @@ -172,6 +171,63 @@ def infer_condition_value(expr: Expression, options: Options) -> int: return result +def infer_runtime_condition_value(expr: Expression, options: Options) -> int: + """Infer whether a condition is definitely true or false at runtime. + + Unlike ``infer_condition_value()``, this intentionally doesn't use the + ``MYPY_TRUE`` and ``MYPY_FALSE`` states. It only returns a definite result + when the expression's runtime value follows from the configured target. + """ + if isinstance(expr, UnaryExpr) and expr.op == "not": + positive = infer_runtime_condition_value(expr.expr, options) + return { + ALWAYS_TRUE: ALWAYS_FALSE, + ALWAYS_FALSE: ALWAYS_TRUE, + TRUTH_VALUE_UNKNOWN: TRUTH_VALUE_UNKNOWN, + }[positive] + + if isinstance(expr, OpExpr): + if expr.op not in ("or", "and"): + return TRUTH_VALUE_UNKNOWN + left = infer_runtime_condition_value(expr.left, options) + right = infer_runtime_condition_value(expr.right, options) + if expr.op == "or": + if ALWAYS_TRUE in (left, right): + return ALWAYS_TRUE + if left == right == ALWAYS_FALSE: + return ALWAYS_FALSE + else: + if ALWAYS_FALSE in (left, right): + return ALWAYS_FALSE + if left == right == ALWAYS_TRUE: + return ALWAYS_TRUE + return TRUTH_VALUE_UNKNOWN + + if isinstance(expr, RefExpr) and expr.fullname in ( + "typing.TYPE_CHECKING", + "typing_extensions.TYPE_CHECKING", + ): + return ALWAYS_FALSE + + if isinstance(expr, (NameExpr, MemberExpr)): + if expr.name == "MYPY": + return ALWAYS_FALSE + if expr.name == "PY2": + return ALWAYS_FALSE + if expr.name == "PY3": + return ALWAYS_TRUE + if expr.name in options.always_true: + return ALWAYS_TRUE + if expr.name in options.always_false: + return ALWAYS_FALSE + return TRUTH_VALUE_UNKNOWN + + result = consider_sys_version_info(expr, options.python_version, use_fullname=True) + if result == TRUTH_VALUE_UNKNOWN: + result = consider_sys_platform(expr, options.platform, use_fullname=True) + return result + + def infer_pattern_value(pattern: Pattern) -> int: if isinstance(pattern, AsPattern) and pattern.pattern is None: return ALWAYS_TRUE @@ -183,7 +239,9 @@ def infer_pattern_value(pattern: Pattern) -> int: return TRUTH_VALUE_UNKNOWN -def consider_sys_version_info(expr: Expression, pyversion: tuple[int, ...]) -> int: +def consider_sys_version_info( + expr: Expression, pyversion: tuple[int, ...], *, use_fullname: bool = False +) -> int: """Consider whether expr is a comparison involving sys.version_info. Return ALWAYS_TRUE, ALWAYS_FALSE, or TRUTH_VALUE_UNKNOWN. @@ -202,10 +260,10 @@ def consider_sys_version_info(expr: Expression, pyversion: tuple[int, ...]) -> i if op not in ("==", "!=", "<=", ">=", "<", ">"): return TRUTH_VALUE_UNKNOWN - index = contains_sys_version_info(expr.operands[0]) + index = contains_sys_version_info(expr.operands[0], use_fullname=use_fullname) thing = contains_int_or_tuple_of_ints(expr.operands[1]) if index is None or thing is None: - index = contains_sys_version_info(expr.operands[1]) + index = contains_sys_version_info(expr.operands[1], use_fullname=use_fullname) thing = contains_int_or_tuple_of_ints(expr.operands[0]) op = reverse_op[op] if isinstance(index, int) and isinstance(thing, int): @@ -227,7 +285,7 @@ def consider_sys_version_info(expr: Expression, pyversion: tuple[int, ...]) -> i return TRUTH_VALUE_UNKNOWN -def consider_sys_platform(expr: Expression, platform: str) -> int: +def consider_sys_platform(expr: Expression, platform: str, *, use_fullname: bool = False) -> int: """Consider whether expr is a comparison involving sys.platform. Return ALWAYS_TRUE, ALWAYS_FALSE, or TRUTH_VALUE_UNKNOWN. @@ -243,7 +301,7 @@ def consider_sys_platform(expr: Expression, platform: str) -> int: op = expr.operators[0] if op not in ("==", "!="): return TRUTH_VALUE_UNKNOWN - if not is_sys_attr(expr.operands[0], "platform"): + if not is_sys_attr(expr.operands[0], "platform", use_fullname=use_fullname): return TRUTH_VALUE_UNKNOWN right = expr.operands[1] if not isinstance(right, StrExpr): @@ -254,7 +312,7 @@ def consider_sys_platform(expr: Expression, platform: str) -> int: return TRUTH_VALUE_UNKNOWN if len(expr.args) != 1 or not isinstance(expr.args[0], StrExpr): return TRUTH_VALUE_UNKNOWN - if not is_sys_attr(expr.callee.expr, "platform"): + if not is_sys_attr(expr.callee.expr, "platform", use_fullname=use_fullname): return TRUTH_VALUE_UNKNOWN if expr.callee.name != "startswith": return TRUTH_VALUE_UNKNOWN @@ -300,10 +358,14 @@ def contains_int_or_tuple_of_ints(expr: Expression) -> None | int | tuple[int, . return None -def contains_sys_version_info(expr: Expression) -> None | int | tuple[int | None, int | None]: - if is_sys_attr(expr, "version_info"): +def contains_sys_version_info( + expr: Expression, *, use_fullname: bool = False +) -> None | int | tuple[int | None, int | None]: + if is_sys_attr(expr, "version_info", use_fullname=use_fullname): return (None, None) # Same as sys.version_info[:] - if isinstance(expr, IndexExpr) and is_sys_attr(expr.base, "version_info"): + if isinstance(expr, IndexExpr) and is_sys_attr( + expr.base, "version_info", use_fullname=use_fullname + ): index = expr.index if isinstance(index, IntExpr): return index.value @@ -324,11 +386,13 @@ def contains_sys_version_info(expr: Expression) -> None | int | tuple[int | None return None -def is_sys_attr(expr: Expression, name: str) -> bool: +def is_sys_attr(expr: Expression, name: str, *, use_fullname: bool = False) -> bool: # TODO: This currently doesn't work with code like this: # - import sys as _sys # - from sys import version_info if isinstance(expr, MemberExpr) and expr.name == name: + if use_fullname: + return expr.fullname == f"sys.{name}" if isinstance(expr.expr, NameExpr) and expr.expr.name == "sys": # TODO: Guard against a local named sys, etc. # (Though later passes will still do most checking.) @@ -358,6 +422,10 @@ def mark_block_mypy_only(block: Block) -> None: block.accept(MarkImportsMypyOnlyVisitor()) +def mark_block_runtime_unreachable(block: Block) -> None: + block.accept(MarkRuntimeUnreachableVisitor()) + + class MarkImportsMypyOnlyVisitor(TraverserVisitor): """Visitor that sets is_mypy_only (which affects priority).""" @@ -372,12 +440,17 @@ def visit_import_all(self, node: ImportAll) -> None: def visit_func_def(self, node: FuncDef) -> None: node.is_mypy_only = True + + +class MarkRuntimeUnreachableVisitor(TraverserVisitor): + """Mark definitions contained in code that cannot run at runtime.""" + + def visit_func_def(self, node: FuncDef) -> None: super().visit_func_def(node) def visit_class_def(self, node: ClassDef) -> None: - node.is_mypy_only = True + node.is_runtime_unreachable = True super().visit_class_def(node) def visit_assignment_stmt(self, node: AssignmentStmt) -> None: - node.is_mypy_only = True - super().visit_assignment_stmt(node) + node.is_runtime_unreachable = True diff --git a/mypy/semanal.py b/mypy/semanal.py index 8dc8dc94bc9ab..7e746d4088b9a 100644 --- a/mypy/semanal.py +++ b/mypy/semanal.py @@ -220,9 +220,12 @@ ALWAYS_TRUE, MYPY_FALSE, MYPY_TRUE, + TRUTH_VALUE_UNKNOWN, infer_condition_value, infer_reachability_of_if_statement, infer_reachability_of_match_statement, + infer_runtime_condition_value, + mark_block_runtime_unreachable, ) from mypy.scope import Scope from mypy.semanal_enum import EnumCallAnalyzer @@ -3676,7 +3679,7 @@ def analyze_typeddict_assign(self, s: AssignmentStmt) -> bool: namespace = self.qualified_name(name) with self.tvar_scope_frame(self.tvar_scope.class_frame(namespace)): is_typed_dict, info, tvar_defs = self.typed_dict_analyzer.check_typeddict( - s.rvalue, name, s.is_mypy_only + s.rvalue, name, s.is_runtime_unreachable ) if not is_typed_dict: return False @@ -5607,9 +5610,22 @@ def visit_continue_stmt(self, s: ContinueStmt) -> None: def visit_if_stmt(self, s: IfStmt) -> None: self.statement = s infer_reachability_of_if_statement(s, self.options) + remaining = ALWAYS_TRUE for i in range(len(s.expr)): s.expr[i].accept(self) + condition = infer_runtime_condition_value(s.expr[i], self.options) + if remaining == ALWAYS_FALSE or condition == ALWAYS_FALSE: + mark_block_runtime_unreachable(s.body[i]) + + if remaining == ALWAYS_FALSE or condition == ALWAYS_TRUE: + remaining = ALWAYS_FALSE + elif remaining == ALWAYS_TRUE and condition == ALWAYS_FALSE: + remaining = ALWAYS_TRUE + else: + remaining = TRUTH_VALUE_UNKNOWN self.visit_block(s.body[i]) + if s.else_body and remaining == ALWAYS_FALSE: + mark_block_runtime_unreachable(s.else_body) self.visit_block_maybe(s.else_body) def visit_try_stmt(self, s: TryStmt) -> None: diff --git a/mypy/semanal_typeddict.py b/mypy/semanal_typeddict.py index 3268d8f719802..5c0701ff6c8f3 100644 --- a/mypy/semanal_typeddict.py +++ b/mypy/semanal_typeddict.py @@ -129,7 +129,7 @@ def analyze_typeddict_classdef(self, defn: ClassDef) -> tuple[bool, TypeInfo | N and defn.base_type_exprs[0].fullname == "typing.TypedDict" and self.options.python_version < (3, 15) and not self.api.is_stub_file - and not defn.is_mypy_only + and not defn.is_runtime_unreachable ): self.fail( '"closed" argument to TypedDict is only available in Python 3.15 and later', @@ -163,7 +163,7 @@ def analyze_typeddict_classdef(self, defn: ClassDef) -> tuple[bool, TypeInfo | N typeddict_bases_set = set() for i, expr in enumerate(defn.base_type_exprs): ok, maybe_type_info, _ = self.check_typeddict( - expr, inline_base(defn.name, i), defn.is_mypy_only + expr, inline_base(defn.name, i), defn.is_runtime_unreachable ) if ok and maybe_type_info is not None: # expr is a CallExpr @@ -592,7 +592,7 @@ def extract_meta_info( return typ, is_required, readonly def check_typeddict( - self, node: Expression, name: str, is_mypy_only: bool = False + self, node: Expression, name: str, is_runtime_unreachable: bool = False ) -> tuple[bool, TypeInfo | None, list[TypeVarLikeType]]: """Check if a call defines a TypedDict. @@ -615,7 +615,7 @@ def check_typeddict( fullname = callee.fullname if fullname not in TPDICT_NAMES: return False, None, [] - res = self.parse_typeddict_args(call, fullname, is_mypy_only) + res = self.parse_typeddict_args(call, fullname, is_runtime_unreachable) if res is None: # This is a valid typed dict, but some type is not ready. # The caller should defer this until next iteration. @@ -678,7 +678,7 @@ def check_typeddict( return True, info, tvar_defs def parse_typeddict_args( - self, call: CallExpr, fullname: str, is_mypy_only: bool + self, call: CallExpr, fullname: str, is_runtime_unreachable: bool ) -> tuple[str, list[str], list[Type], bool, bool, list[TypeVarLikeType], bool] | None: """Parse typed dict call expression. @@ -725,7 +725,7 @@ def parse_typeddict_args( and fullname == "typing.TypedDict" and self.options.python_version < (3, 15) and not self.api.is_stub_file - and not is_mypy_only + and not is_runtime_unreachable ): self.fail( '"closed" argument to TypedDict is only available in Python 3.15 and later', diff --git a/mypy/treetransform.py b/mypy/treetransform.py index 85b36e057ef9f..ffc11ec7b8536 100644 --- a/mypy/treetransform.py +++ b/mypy/treetransform.py @@ -272,7 +272,7 @@ def visit_class_def(self, node: ClassDef) -> ClassDef: new.fullname = node.fullname new.info = node.info new.decorators = [self.expr(decorator) for decorator in node.decorators] - new.is_mypy_only = node.is_mypy_only + new.is_runtime_unreachable = node.is_runtime_unreachable return new def visit_global_decl(self, node: GlobalDecl) -> GlobalDecl: @@ -328,7 +328,7 @@ def duplicate_assignment(self, node: AssignmentStmt) -> AssignmentStmt: ) new.line = node.line new.is_final_def = node.is_final_def - new.is_mypy_only = node.is_mypy_only + new.is_runtime_unreachable = node.is_runtime_unreachable new.type = self.optional_type(node.type) return new diff --git a/test-data/unit/check-typeddict.test b/test-data/unit/check-typeddict.test index 7b3292d5117ed..665ff62c1f93d 100644 --- a/test-data/unit/check-typeddict.test +++ b/test-data/unit/check-typeddict.test @@ -5234,6 +5234,8 @@ reveal_type(e) # N: Revealed type is "TypedDict('__main__.E', {'x': builtins.in # flags: --python-version 3.14 from typing import TypedDict from typing_extensions import TYPE_CHECKING +MYPY = False +unknown: bool class ClassClosed(TypedDict, closed=True): # E: "closed" argument to TypedDict is only available in Python 3.15 and later x: int @@ -5259,12 +5261,37 @@ if TYPE_CHECKING: class NestedClassClosed(TypedDict, closed=True): x: int NestedFunctionalOpen = TypedDict('NestedFunctionalOpen', {'x': int}, closed=False) +if MYPY: + class MypyClassClosed(TypedDict, closed=True): + x: int + MypyFunctionalOpen = TypedDict('MypyFunctionalOpen', {'x': int}, closed=False) if not TYPE_CHECKING: pass else: class ElseClassClosed(TypedDict, closed=True): x: int ElseFunctionalOpen = TypedDict('ElseFunctionalOpen', {'x': int}, closed=False) +if not TYPE_CHECKING: + pass +elif unknown: + class ElifClassClosed(TypedDict, closed=True): + x: int + ElifFunctionalOpen = TypedDict('ElifFunctionalOpen', {'x': int}, closed=False) +runtime_flag: bool +if TYPE_CHECKING or runtime_flag: + class RuntimeOrClassClosed(TypedDict, closed=True): # E: "closed" argument to TypedDict is only available in Python 3.15 and later + x: int + RuntimeOrFunctionalOpen = TypedDict('RuntimeOrFunctionalOpen', {'x': int}, closed=False) # E: "closed" argument to TypedDict is only available in Python 3.15 and later +if not TYPE_CHECKING and runtime_flag: + pass +else: + class RuntimeElseClassClosed(TypedDict, closed=True): # E: "closed" argument to TypedDict is only available in Python 3.15 and later + x: int + RuntimeElseFunctionalOpen = TypedDict('RuntimeElseFunctionalOpen', {'x': int}, closed=False) # E: "closed" argument to TypedDict is only available in Python 3.15 and later +if TYPE_CHECKING and runtime_flag: + class RuntimeAndClassClosed(TypedDict, closed=True): + x: int + RuntimeAndFunctionalOpen = TypedDict('RuntimeAndFunctionalOpen', {'x': int}, closed=False) from stub import StubClassClosed, StubClassOpen, StubFunctionalClosed, StubFunctionalOpen [builtins fixtures/dict.pyi] [typing fixtures/typing-typeddict.pyi] @@ -5278,6 +5305,53 @@ class StubClassOpen(TypedDict, closed=False): StubFunctionalClosed = TypedDict('StubFunctionalClosed', {'x': int}, closed=True) StubFunctionalOpen = TypedDict('StubFunctionalOpen', {'x': int}, closed=False) +[case testTypedDictClosedArgumentInAliasedTypingOnlyContextBeforePython315] +# flags: --python-version 3.14 +import typing +from typing import TYPE_CHECKING as TC, TypedDict +from typing_extensions import TYPE_CHECKING as EXT_TC + +if TC: + class AliasedClassClosed(TypedDict, closed=True): + x: int + AliasedFunctionalOpen = TypedDict('AliasedFunctionalOpen', {'x': int}, closed=False) +if not not TC: + class DoubleNegatedClassClosed(TypedDict, closed=True): + x: int + DoubleNegatedFunctionalOpen = TypedDict( + 'DoubleNegatedFunctionalOpen', {'x': int}, closed=False + ) +if EXT_TC: + class ExtensionsAliasedClassClosed(TypedDict, closed=True): + x: int + ExtensionsAliasedFunctionalOpen = TypedDict( + 'ExtensionsAliasedFunctionalOpen', {'x': int}, closed=False + ) +if typing.TYPE_CHECKING: + class QualifiedClassClosed(TypedDict, closed=True): + x: int + QualifiedFunctionalOpen = TypedDict('QualifiedFunctionalOpen', {'x': int}, closed=False) +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] + +[case testTypedDictClosedArgumentWithShadowedTypeCheckingBeforePython315] +# flags: --python-version 3.14 +from typing import TypedDict + +TYPE_CHECKING = True +if TYPE_CHECKING: + class ShadowedClassClosed(TypedDict, closed=True): # E: "closed" argument to TypedDict is only available in Python 3.15 and later + x: int + ShadowedFunctionalOpen = TypedDict('ShadowedFunctionalOpen', {'x': int}, closed=False) # E: "closed" argument to TypedDict is only available in Python 3.15 and later +def shadowed_locally() -> None: + TYPE_CHECKING = True + if TYPE_CHECKING: + class LocallyShadowedClassClosed(TypedDict, closed=True): # E: "closed" argument to TypedDict is only available in Python 3.15 and later + x: int + LocallyShadowedFunctionalOpen = TypedDict('LocallyShadowedFunctionalOpen', {'x': int}, closed=False) # E: "closed" argument to TypedDict is only available in Python 3.15 and later +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] + [case testTypingExtensionsTypedDictClosedArgumentBeforePython315] # flags: --python-version 3.14 from typing_extensions import TypedDict @@ -5293,6 +5367,19 @@ FunctionalOpen = TypedDict('FunctionalOpen', {'x': int}, closed=False) [file typing_extensions.pyi] TypedDict = 0 +[case testMypyExtensionsTypedDictClosedArgumentBeforePython315] +# flags: --python-version 3.14 +from mypy_extensions import TypedDict + +class ClassClosed(TypedDict, closed=True): + x: int +class ClassOpen(TypedDict, closed=False): + x: int +FunctionalClosed = TypedDict('FunctionalClosed', {'x': int}, closed=True) +FunctionalOpen = TypedDict('FunctionalOpen', {'x': int}, closed=False) +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] + [case testTypedDictClosedArgumentInPython315] # flags: --python-version 3.15 from typing import TypedDict diff --git a/test-data/unit/fixtures/typing-typeddict.pyi b/test-data/unit/fixtures/typing-typeddict.pyi index 0bc5637b32708..90c5a71b8d037 100644 --- a/test-data/unit/fixtures/typing-typeddict.pyi +++ b/test-data/unit/fixtures/typing-typeddict.pyi @@ -30,6 +30,7 @@ NotRequired = 0 ReadOnly = 0 Self = 0 ClassVar = 0 +TYPE_CHECKING = False T = TypeVar('T') T_co = TypeVar('T_co', covariant=True)