From 30c4a0bf08bbe2ffca1711520026ab038c857625 Mon Sep 17 00:00:00 2001 From: David Foster Date: Fri, 5 Jun 2026 14:28:54 -0400 Subject: [PATCH 01/10] TypeForm: Identifier-strings: Early-reject Var-with-concrete-Instance-type in try_parse_as_type_expression() Co-Authored-By: Claude Opus 4.8 --- mypy/semanal.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/mypy/semanal.py b/mypy/semanal.py index 7f961687a8aee..5db1373a7b66b 100644 --- a/mypy/semanal.py +++ b/mypy/semanal.py @@ -8161,6 +8161,16 @@ def try_parse_as_type_expression(self, maybe_type_expr: Expression) -> None: # 2. unbound_paramspec: f'ParamSpec "{name}" is unbound' [codes.VALID_TYPE] maybe_type_expr.as_type = None return + if ( + isinstance(node, Var) + and isinstance(get_proper_type(node.type), Instance) + and not self.var_is_typing_special_form(node) + ): + # Var whose declared type is a concrete instance: it is + # a value (local, parameter, module-level constant), + # not a type expression. + maybe_type_expr.as_type = None + return else: # does not look like an identifier if '"' in str_value or "'" in str_value: # Only valid inside a Literal[...] or Annotated[..., ...] type @@ -8255,6 +8265,8 @@ def var_is_typing_special_form(var: Var) -> bool: "typing.Literal", "typing_extensions.Literal", "typing.Optional", + "typing.Self", + "typing_extensions.Self", "typing.TypeGuard", "typing_extensions.TypeGuard", "typing.TypeIs", From a60977603d422cc0c600c91a6029a5c294d4939d Mon Sep 17 00:00:00 2001 From: David Foster Date: Fri, 5 Jun 2026 15:17:06 -0400 Subject: [PATCH 02/10] TypeForm: Identifier-strings: Reorder early-reject checks by rejection frequency Co-Authored-By: Claude Opus 4.8 --- mypy/semanal.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/mypy/semanal.py b/mypy/semanal.py index 5db1373a7b66b..090a5678055e5 100644 --- a/mypy/semanal.py +++ b/mypy/semanal.py @@ -8145,12 +8145,13 @@ def try_parse_as_type_expression(self, maybe_type_expr: Expression) -> None: return else: # sym is not None node = sym.node # cache - if isinstance(node, PlaceholderNode) and not node.becomes_typeinfo: - # Either: - # 1. f'Cannot resolve name "{t.name}" (possible cyclic definition)' - # 2. Reference to an unknown placeholder node. - maybe_type_expr.as_type = None - return + # The following early-reject checks are mutually exclusive + # (a node is at most one of an unbound type variable, a value + # Var, or a placeholder), so their order never affects which + # expressions are rejected. They are ordered by descending + # rejection frequency (measured on mypy's self-check) so the + # commonest rejections exit first: unbound type variables + # (~951) >> value Vars (~157) > placeholders (~23). unbound_tvar_or_paramspec = ( isinstance(node, (TypeVarExpr, TypeVarTupleExpr, ParamSpecExpr)) and self.tvar_scope.get_binding(sym) is None @@ -8171,6 +8172,12 @@ def try_parse_as_type_expression(self, maybe_type_expr: Expression) -> None: # not a type expression. maybe_type_expr.as_type = None return + if isinstance(node, PlaceholderNode) and not node.becomes_typeinfo: + # Either: + # 1. f'Cannot resolve name "{t.name}" (possible cyclic definition)' + # 2. Reference to an unknown placeholder node. + maybe_type_expr.as_type = None + return else: # does not look like an identifier if '"' in str_value or "'" in str_value: # Only valid inside a Literal[...] or Annotated[..., ...] type From 8aea7afba1bbeeae2f3ccbab69ed9f6fc8874587 Mon Sep 17 00:00:00 2001 From: David Foster Date: Fri, 5 Jun 2026 15:18:22 -0400 Subject: [PATCH 03/10] TypeForm: Identifier-strings: Early-reject functions and modules in try_parse_as_type_expression() Co-Authored-By: Claude Opus 4.8 --- mypy/semanal.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/mypy/semanal.py b/mypy/semanal.py index 090a5678055e5..0b67d5f84197a 100644 --- a/mypy/semanal.py +++ b/mypy/semanal.py @@ -8145,13 +8145,13 @@ def try_parse_as_type_expression(self, maybe_type_expr: Expression) -> None: return else: # sym is not None node = sym.node # cache - # The following early-reject checks are mutually exclusive - # (a node is at most one of an unbound type variable, a value - # Var, or a placeholder), so their order never affects which - # expressions are rejected. They are ordered by descending - # rejection frequency (measured on mypy's self-check) so the - # commonest rejections exit first: unbound type variables - # (~951) >> value Vars (~157) > placeholders (~23). + # The following early-reject checks are mutually exclusive, + # ordered by decreasing rejection frequency (measured on + # mypy's self-check) so the commonest rejections exit first. + # - TypeVarExpr, TypeVarTupleExpr, ParamSpecExpr (~951) + # - Var (~157) + # - FuncDef, OverloadedFuncDef, MypyFile (~48) + # - PlaceholderNode (~23) unbound_tvar_or_paramspec = ( isinstance(node, (TypeVarExpr, TypeVarTupleExpr, ParamSpecExpr)) and self.tvar_scope.get_binding(sym) is None @@ -8172,6 +8172,10 @@ def try_parse_as_type_expression(self, maybe_type_expr: Expression) -> None: # not a type expression. maybe_type_expr.as_type = None return + if isinstance(node, (FuncDef, OverloadedFuncDef, MypyFile)): + # Functions and modules are never type expressions. + maybe_type_expr.as_type = None + return if isinstance(node, PlaceholderNode) and not node.becomes_typeinfo: # Either: # 1. f'Cannot resolve name "{t.name}" (possible cyclic definition)' From 159885c617148dba47c3687a4c158c00757f0d06 Mon Sep 17 00:00:00 2001 From: David Foster Date: Sun, 9 Aug 2026 22:54:07 -0400 Subject: [PATCH 04/10] TypeForm: Other-strings: Early-reject non-type characters in try_parse_as_type_expression() Implemented with plain string operations rather than a regular expression. Co-Authored-By: Claude Opus 5 --- mypy/semanal.py | 59 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/mypy/semanal.py b/mypy/semanal.py index 0b67d5f84197a..1afc04de05e26 100644 --- a/mypy/semanal.py +++ b/mypy/semanal.py @@ -368,6 +368,13 @@ # string literal as a type expression. _MULTIPLE_WORDS_NONTYPE_RE = re.compile(r'\s*[^\s.\'"|\[]+\s+[^\s.\'"|\[]') +# Characters which never appear in a valid type expression. +# NOTE: Allows '*' for (PEP 646 Unpack) and '+' for (Literal[+N]) +# NOTE: Stored as a tuple of single-character strings (rather than a str) +# so that iterating it does not allocate a new string per character +# when compiled with mypyc. +_NONTYPE_CHARS: Final = tuple("!:/<>@%$^?;&~`\\") + class SemanticAnalyzer( NodeVisitor[None], SemanticAnalyzerInterface, SemanticAnalyzerPluginInterface, SplittingVisitor @@ -8201,6 +8208,24 @@ def try_parse_as_type_expression(self, maybe_type_expr: Expression) -> None: # But cannot be a type expression. maybe_type_expr.as_type = None return + # Skip some checks when a non-zero even number of single or double quotes + # signals a possible Literal[...] component, whose quoted content + # could contain anything: symbols or identifiers that would be + # incorrectly processed by some checks. + sq = str_value.count("'") + dq = str_value.count('"') + if not ((sq > 0 and sq % 2 == 0) or (dq > 0 and dq % 2 == 0)): + # Filter out string literals containing characters or boundary + # patterns that never appear in valid type expressions: + # - Leading '.' (incomplete dotted name, file extension, etc) + # - Trailing '.' (incomplete dotted name, file extension, etc) + # - Characters never valid in a type expression (e.g. '/', ':', '<', '>', '@') + # - '-' not directly preceded by '[' (which can occur in Literal[-N]) + # NOTE: str_value is never empty here. Branches above return + # for every string shorter than 2 characters. + if str_value[0] == "." or str_value[-1] == "." or has_nontype_char(str_value): + maybe_type_expr.as_type = None + return elif isinstance(maybe_type_expr, IndexExpr): if isinstance(maybe_type_expr.base, NameExpr): if isinstance( @@ -8541,3 +8566,37 @@ def erase_func_annotations(func: FuncDef) -> None: arg.variable.type = None func.type = None func.unanalyzed_type = None + + +def has_nontype_char(s: str) -> bool: + """Whether s contains a character that cannot appear in a type expression. + + Callers must exclude strings that may contain a quoted Literal[...] + component first, since quoted content can hold arbitrary characters. + """ + # Look for _NONTYPE_CHARS + # NOTE: Iterating over s first (instead of _NONTYPE_CHARS) is NOT faster. + # NOTE: set.isdisjoint() is faster in interpreted-mypy (-22% runtime) + # but slower in compiled-mypy (+16% runtime) + for ch in _NONTYPE_CHARS: + if ch in s: + return True + + # Look for "-". + # It is valid only as a unary minus introducing a Literal[...] element, + # which is to say only where the preceding non-space character is a "[" + # (as in Literal[-1]) or a "," (as in Literal[-1, -2]). + # A "-" anywhere else means s cannot be a type. + i = s.find("-") + while i != -1: + # Look for a preceding "[" or "," (skipping whitespace) + j = i - 1 + while j >= 0 and s[j] == " ": + j -= 1 + if j < 0 or (s[j] != "[" and s[j] != ","): + return True + + # Continue to the next "-" + i = s.find("-", i + 1) + + return False From b2b43fefaa6347edda3b750dacaa565675611e3b Mon Sep 17 00:00:00 2001 From: David Foster Date: Mon, 10 Aug 2026 06:44:17 -0400 Subject: [PATCH 05/10] TypeForm: Dotted-identifier-strings: Early-reject non-type leftmost component in try_parse_as_type_expression() Implemented with plain string operations rather than a regular expression. Co-Authored-By: Claude Opus 5 --- mypy/semanal.py | 54 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/mypy/semanal.py b/mypy/semanal.py index 1afc04de05e26..d2a419fe1729d 100644 --- a/mypy/semanal.py +++ b/mypy/semanal.py @@ -8189,7 +8189,29 @@ def try_parse_as_type_expression(self, maybe_type_expr: Expression) -> None: # 2. Reference to an unknown placeholder node. maybe_type_expr.as_type = None return - else: # does not look like an identifier + elif (leftmost_name := dotted_identifier_leftmost(str_value)) is not None: + # Dotted-name string (e.g. "builtins.tuple", "typing.Mapping"). + # Look up the leftmost component; if it cannot be a type prefix + # then the whole dotted name cannot spell a type. Mirrors the + # IndexExpr-with-MemberExpr-base filter logic below. + sym = self.lookup(leftmost_name, UnboundType(leftmost_name), suppress_errors=True) + if sym is None: + # Leftmost component does not refer to anything in scope + maybe_type_expr.as_type = None + return + node = sym.node # cache + if isinstance(node, PlaceholderNode) and not node.becomes_typeinfo: + # Either: + # 1. f'Cannot resolve name "{t.name}" (possible cyclic definition)' + # 2. Reference to an unknown placeholder node. + maybe_type_expr.as_type = None + return + if isinstance(node, Var) and not self.var_is_typing_special_form(node): + # Leftmost component is a Var: it is a value, so it cannot be + # the module or class prefix of a dotted type name. + maybe_type_expr.as_type = None + return + else: # does not look like an identifier or dotted identifier if '"' in str_value or "'" in str_value: # Only valid inside a Literal[...] or Annotated[..., ...] type if "[" not in str_value: @@ -8568,6 +8590,36 @@ def erase_func_annotations(func: FuncDef) -> None: func.unanalyzed_type = None +def dotted_identifier_leftmost(s: str) -> str | None: + """The leftmost component of s, if s is a dotted identifier, else None. + + A dotted identifier is two or more identifiers joined by ".", such as + "builtins.tuple" or "typing.Mapping". A bare identifier is not a dotted + identifier: callers are expected to handle that case separately. + + Returns the leftmost component (which is never empty) so that callers + need not split s a second time to obtain it. + """ + # NOTE: Scanning with find() rather than s.split(".") avoids allocating a + # list, since only the leftmost component is ever needed. + dot = s.find(".") + if dot == -1: + return None + leftmost = s[:dot] + if not leftmost.isidentifier(): + return None + start = dot + 1 + while True: + dot = s.find(".", start) + if dot == -1: + if not s[start:].isidentifier(): + return None + return leftmost + if not s[start:dot].isidentifier(): + return None + start = dot + 1 + + def has_nontype_char(s: str) -> bool: """Whether s contains a character that cannot appear in a type expression. From b7b23722998bebfd1dd9750e9347a8566b0276bb Mon Sep 17 00:00:00 2001 From: David Foster Date: Tue, 15 Sep 2026 22:01:07 -0400 Subject: [PATCH 06/10] fixup! TypeForm: Dotted-identifier-strings: Early-reject non-type leftmost component in try_parse_as_type_expression() Drop unreachable condition `self.var_is_typing_special_form(node)`. A dotted name like `Self.foo`, where the leftmost part is a special form, is never a valid type. Verified as unreachable empirically on codebases {mypy, sphinx, scrapy, pylint}. --- mypy/semanal.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mypy/semanal.py b/mypy/semanal.py index d2a419fe1729d..626f52ab837f8 100644 --- a/mypy/semanal.py +++ b/mypy/semanal.py @@ -8206,7 +8206,7 @@ def try_parse_as_type_expression(self, maybe_type_expr: Expression) -> None: # 2. Reference to an unknown placeholder node. maybe_type_expr.as_type = None return - if isinstance(node, Var) and not self.var_is_typing_special_form(node): + if isinstance(node, Var): # Leftmost component is a Var: it is a value, so it cannot be # the module or class prefix of a dotted type name. maybe_type_expr.as_type = None From eb7349b458c7e8a713a1863e7cbb078ba7af25ee Mon Sep 17 00:00:00 2001 From: David Foster Date: Tue, 15 Sep 2026 22:01:12 -0400 Subject: [PATCH 07/10] TypeForm: Drop unreachable special-form guard on IndexExpr MemberExpr leftmost The leftmost component of a dotted IndexExpr base is a module or class prefix, so it can never itself be a typing special form. Measured over {mypy, sphinx, scrapy, pylint}: 1,912 Var lookups at this site, 0 of which var_is_typing_special_form() rescued. --- mypy/semanal.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/mypy/semanal.py b/mypy/semanal.py index 626f52ab837f8..ed3a1fe21cdb6 100644 --- a/mypy/semanal.py +++ b/mypy/semanal.py @@ -8264,9 +8264,7 @@ def try_parse_as_type_expression(self, maybe_type_expr: Expression) -> None: break next_leftmost = leftmost if isinstance(leftmost, NameExpr): - if isinstance(leftmost.node, Var) and not self.var_is_typing_special_form( - leftmost.node - ): + if isinstance(leftmost.node, Var): # Leftmost part of IndexExpr refers to a Var. Not a valid type. maybe_type_expr.as_type = None return From c444abd984a3d012898fc393dbf82fb266aaccd4 Mon Sep 17 00:00:00 2001 From: David Foster Date: Wed, 16 Sep 2026 08:39:42 -0400 Subject: [PATCH 08/10] TypeForm: Fix recognition of additional single-identifier special forms Previously the following assignments were disallowed improperly on this branch (but not on main): typx: TypeForm typx = 'Never' typx = 'NoReturn' --- mypy/semanal.py | 10 ++++++++++ test-data/unit/check-typeform.test | 19 +++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/mypy/semanal.py b/mypy/semanal.py index ed3a1fe21cdb6..cc589ca0da279 100644 --- a/mypy/semanal.py +++ b/mypy/semanal.py @@ -8317,12 +8317,22 @@ def var_is_typing_special_form(var: Var) -> bool: return var.fullname.startswith("typing") and var.fullname in [ "typing.Annotated", "typing_extensions.Annotated", + "typing.Any", "typing.Callable", + "typing.ClassVar", "typing.Literal", "typing_extensions.Literal", + "typing.Never", + "typing_extensions.Never", + "typing.NoReturn", "typing.Optional", "typing.Self", "typing_extensions.Self", + "typing.Tuple", + "typing.Type", + "typing.TypeAlias", + "typing.TypeForm", + "typing_extensions.TypeForm", "typing.TypeGuard", "typing_extensions.TypeGuard", "typing.TypeIs", diff --git a/test-data/unit/check-typeform.test b/test-data/unit/check-typeform.test index 220f31b45b512..f2e8b266c4b38 100644 --- a/test-data/unit/check-typeform.test +++ b/test-data/unit/check-typeform.test @@ -730,6 +730,25 @@ typx = 'int | str' [builtins fixtures/primitives.pyi] [typing fixtures/typing-full.pyi] +-- TODO: Replace the enumerated allowlist in var_is_typing_special_form() +-- with a new kind of general check, so new typing special forms are +-- recognized without being added to it by hand. +[case testEveryKindOfSingleIdentifierSpecialFormInStringAnnotationIsRecognized] +from typing import Any, Callable, Dict, List, NoReturn, Tuple, Type +from typing_extensions import LiteralString, Never, TypeForm +typx: TypeForm +typx = 'Any' +typx = 'Callable' +typx = 'Dict' +typx = 'List' +typx = 'LiteralString' +typx = 'Never' +typx = 'NoReturn' +typx = 'Tuple' +typx = 'Type' +[builtins fixtures/primitives.pyi] +[typing fixtures/typing-full.pyi] + -- Misc From c43ce15bf9c33cc93bb5dc45960e5b37a70b659f Mon Sep 17 00:00:00 2001 From: David Foster Date: Wed, 16 Sep 2026 10:50:27 -0400 Subject: [PATCH 09/10] TypeForm: Alter recognition of special forms to not depend on fragile allowlist --- mypy/semanal.py | 36 ++++++++---------------------------- 1 file changed, 8 insertions(+), 28 deletions(-) diff --git a/mypy/semanal.py b/mypy/semanal.py index cc589ca0da279..6d3dd34af8015 100644 --- a/mypy/semanal.py +++ b/mypy/semanal.py @@ -8172,7 +8172,7 @@ def try_parse_as_type_expression(self, maybe_type_expr: Expression) -> None: if ( isinstance(node, Var) and isinstance(get_proper_type(node.type), Instance) - and not self.var_is_typing_special_form(node) + and not self.var_could_be_typing_special_form(node) ): # Var whose declared type is a concrete instance: it is # a value (local, parameter, module-level constant), @@ -8252,7 +8252,7 @@ def try_parse_as_type_expression(self, maybe_type_expr: Expression) -> None: if isinstance(maybe_type_expr.base, NameExpr): if isinstance( maybe_type_expr.base.node, Var - ) and not self.var_is_typing_special_form(maybe_type_expr.base.node): + ) and not self.var_could_be_typing_special_form(maybe_type_expr.base.node): # Leftmost part of IndexExpr refers to a Var. Not a valid type. maybe_type_expr.as_type = None return @@ -8313,32 +8313,12 @@ def try_parse_as_type_expression(self, maybe_type_expr: Expression) -> None: maybe_type_expr.as_type = t @staticmethod - def var_is_typing_special_form(var: Var) -> bool: - return var.fullname.startswith("typing") and var.fullname in [ - "typing.Annotated", - "typing_extensions.Annotated", - "typing.Any", - "typing.Callable", - "typing.ClassVar", - "typing.Literal", - "typing_extensions.Literal", - "typing.Never", - "typing_extensions.Never", - "typing.NoReturn", - "typing.Optional", - "typing.Self", - "typing_extensions.Self", - "typing.Tuple", - "typing.Type", - "typing.TypeAlias", - "typing.TypeForm", - "typing_extensions.TypeForm", - "typing.TypeGuard", - "typing_extensions.TypeGuard", - "typing.TypeIs", - "typing_extensions.TypeIs", - "typing.Union", - ] + def var_could_be_typing_special_form(var: Var) -> bool: + return ( + var.fullname.startswith("typing.") + or var.fullname.startswith("typing_extensions.") + or var.fullname.startswith("mypy_extensions.") + ) @contextmanager def isolated_error_analysis(self) -> Iterator[None]: From 95ecb9ee0309d6c6c37d0c7cddf0b2dade27c91d Mon Sep 17 00:00:00 2001 From: David Foster Date: Wed, 16 Sep 2026 13:26:46 -0400 Subject: [PATCH 10/10] TypeForm: Early-reject variable references that are known to hold some kind of value ...and not some kind of type expression --- mypy/semanal.py | 14 ++++++++++---- test-data/unit/check-typeform.test | 8 ++++++++ 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/mypy/semanal.py b/mypy/semanal.py index 6d3dd34af8015..e3965e27f6708 100644 --- a/mypy/semanal.py +++ b/mypy/semanal.py @@ -8171,12 +8171,11 @@ def try_parse_as_type_expression(self, maybe_type_expr: Expression) -> None: return if ( isinstance(node, Var) - and isinstance(get_proper_type(node.type), Instance) + and self.var_type_is_known(node) and not self.var_could_be_typing_special_form(node) ): - # Var whose declared type is a concrete instance: it is - # a value (local, parameter, module-level constant), - # not a type expression. + # Var whose type is known and is not a special form. + # It is a value, not a type expression. maybe_type_expr.as_type = None return if isinstance(node, (FuncDef, OverloadedFuncDef, MypyFile)): @@ -8320,6 +8319,13 @@ def var_could_be_typing_special_form(var: Var) -> bool: or var.fullname.startswith("mypy_extensions.") ) + @staticmethod + def var_type_is_known(var: Var) -> bool: + return not ( + (var_typ_p := get_proper_type(var.type)) is None + or isinstance(var_typ_p, (AnyType, PlaceholderType, UnboundType)) + ) + @contextmanager def isolated_error_analysis(self) -> Iterator[None]: """ diff --git a/test-data/unit/check-typeform.test b/test-data/unit/check-typeform.test index f2e8b266c4b38..9e10be268ba89 100644 --- a/test-data/unit/check-typeform.test +++ b/test-data/unit/check-typeform.test @@ -749,6 +749,14 @@ typx = 'Type' [builtins fixtures/primitives.pyi] [typing fixtures/typing-full.pyi] +[case testVarWithUnknownTypeInStringAnnotationIsNotRejected] +from typing import Any +from typing_extensions import TypeForm +foo: Any +typx: TypeForm = 'foo' +reveal_type(typx) # N: Revealed type is "TypeForm[Any]" +[builtins fixtures/primitives.pyi] +[typing fixtures/typing-full.pyi] -- Misc