Skip to content

[PEP 747] Recognize TypeForm[T] type and values (#9773) #18690

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

Open
wants to merge 24 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
ca4c79f
[PEP 747] Recognize TypeForm[T] type and values (#9773)
davidfstr Sep 29, 2024
9dcfc23
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Feb 16, 2025
5cb1da5
Eliminate use of Type Parameter Syntax, which only works on Python >=…
davidfstr Feb 19, 2025
3ece208
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Feb 19, 2025
799bc48
Remove INPUTS directory
davidfstr Feb 19, 2025
6bda848
WIP: Don't declare attributes on Expression to avoid confusing mypyc
davidfstr Feb 20, 2025
9df0e6b
SQ -> WIP: Don't declare attributes on Expression to avoid confusing …
davidfstr Feb 21, 2025
a2c318b
SQ -> WIP: Don't declare attributes on Expression to avoid confusing …
davidfstr Feb 21, 2025
f398374
Recognize non-string type expressions everywhere lazily. Optimize eag…
davidfstr Feb 25, 2025
bd5911f
NOMERGE: Disable test that is already failing on master branch
davidfstr Mar 4, 2025
3801bcc
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Mar 4, 2025
c4db784
Fix mypyc errors: Replace EllipsisType with NotParsed type
davidfstr Mar 4, 2025
29fe65a
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Mar 4, 2025
4134250
mypy_primer: Enable TypeForm feature when checking effects on open so…
davidfstr Mar 5, 2025
5fb5bd8
Revert "mypy_primer: Enable TypeForm feature when checking effects on…
davidfstr Mar 8, 2025
54cd64d
NOMERGE: mypy_primer: Enable --enable-incomplete-feature=TypeForm whe…
davidfstr Mar 8, 2025
075980b
Ignore warnings like: <type_comment>:1: SyntaxWarning: invalid escape…
davidfstr Mar 14, 2025
6797cac
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Mar 14, 2025
4162a35
Rerun CI
davidfstr Mar 17, 2025
d1aafcd
Improve warning message when string annotation used in TypeForm context
davidfstr Mar 18, 2025
1d9620d
Print error code for the MAYBE_UNRECOGNIZED_STR_TYPEFORM note
davidfstr Mar 26, 2025
76cae61
Document the [maybe-unrecognized-str-typeform] error code
davidfstr Mar 26, 2025
170a5e7
Fix doc generation warning
davidfstr Mar 26, 2025
84c06a6
Merge branch 'master' into f/typeform3
davidfstr Apr 28, 2025
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
114 changes: 112 additions & 2 deletions mypy/checker.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@
from mypy.scope import Scope
from mypy.semanal import is_trivial_body, refers_to_fullname, set_callable_name
from mypy.semanal_enum import ENUM_BASES, ENUM_SPECIAL_PROPS
from mypy.semanal_shared import SemanticAnalyzerCoreInterface
from mypy.sharedparse import BINARY_MAGIC_METHODS
from mypy.state import state
from mypy.subtypes import (
Expand Down Expand Up @@ -307,6 +308,8 @@ class TypeChecker(NodeVisitor[None], CheckerPluginInterface):

tscope: Scope
scope: CheckerScope
# Innermost enclosing type
type: TypeInfo | None
# Stack of function return types
return_types: list[Type]
# Flags; true for dynamically typed functions
Expand Down Expand Up @@ -378,6 +381,7 @@ def __init__(
self.scope = CheckerScope(tree)
self.binder = ConditionalTypeBinder()
self.globals = tree.names
self.type = None
self.return_types = []
self.dynamic_funcs = []
self.partial_types = []
Expand Down Expand Up @@ -2556,7 +2560,11 @@ def visit_class_def(self, defn: ClassDef) -> None:
for base in typ.mro[1:]:
if base.is_final:
self.fail(message_registry.CANNOT_INHERIT_FROM_FINAL.format(base.name), defn)
with self.tscope.class_scope(defn.info), self.enter_partial_types(is_class=True):
with (
self.tscope.class_scope(defn.info),
self.enter_partial_types(is_class=True),
self.enter_class(defn.info),
):
old_binder = self.binder
self.binder = ConditionalTypeBinder()
with self.binder.top_frame_context():
Expand Down Expand Up @@ -2624,6 +2632,15 @@ def visit_class_def(self, defn: ClassDef) -> None:
self.check_enum(defn)
infer_class_variances(defn.info)

@contextmanager
def enter_class(self, type: TypeInfo) -> Iterator[None]:
original_type = self.type
self.type = type
try:
yield
finally:
self.type = original_type

def check_final_deletable(self, typ: TypeInfo) -> None:
# These checks are only for mypyc. Only perform some checks that are easier
# to implement here than in mypyc.
Expand Down Expand Up @@ -7786,7 +7803,9 @@ def add_any_attribute_to_type(self, typ: Type, name: str) -> Type:
fallback = typ.fallback.copy_with_extra_attr(name, any_type)
return typ.copy_modified(fallback=fallback)
if isinstance(typ, TypeType) and isinstance(typ.item, Instance):
return TypeType.make_normalized(self.add_any_attribute_to_type(typ.item, name))
return TypeType.make_normalized(
self.add_any_attribute_to_type(typ.item, name), is_type_form=typ.is_type_form
)
if isinstance(typ, TypeVarType):
return typ.copy_modified(
upper_bound=self.add_any_attribute_to_type(typ.upper_bound, name),
Expand Down Expand Up @@ -7921,6 +7940,97 @@ def visit_global_decl(self, o: GlobalDecl, /) -> None:
return None


class TypeCheckerAsSemanticAnalyzer(SemanticAnalyzerCoreInterface):
"""
Adapts TypeChecker to the SemanticAnalyzerCoreInterface,
allowing most type expressions to be parsed during the TypeChecker pass.

See ExpressionChecker.try_parse_as_type_expression() to understand how this
class is used.
"""

_chk: TypeChecker
_names: dict[str, SymbolTableNode]
did_fail: bool

def __init__(self, chk: TypeChecker, names: dict[str, SymbolTableNode]) -> None:
self._chk = chk
self._names = names
self.did_fail = False

def lookup_qualified(
self, name: str, ctx: Context, suppress_errors: bool = False
) -> SymbolTableNode | None:
sym = self._names.get(name)
# All names being looked up should have been previously gathered,
# even if the related SymbolTableNode does not refer to a valid SymbolNode
assert sym is not None, name
return sym

def lookup_fully_qualified(self, fullname: str, /) -> SymbolTableNode:
ret = self.lookup_fully_qualified_or_none(fullname)
assert ret is not None, fullname
return ret

def lookup_fully_qualified_or_none(self, fullname: str, /) -> SymbolTableNode | None:
try:
return self._chk.lookup_qualified(fullname)
except KeyError:
return None

def fail(
self,
msg: str,
ctx: Context,
serious: bool = False,
*,
blocker: bool = False,
code: ErrorCode | None = None,
) -> None:
self.did_fail = True

def note(self, msg: str, ctx: Context, *, code: ErrorCode | None = None) -> None:
pass

def incomplete_feature_enabled(self, feature: str, ctx: Context) -> bool:
if feature not in self._chk.options.enable_incomplete_feature:
self.fail("__ignored__", ctx)
return False
return True

def record_incomplete_ref(self) -> None:
pass

def defer(self, debug_context: Context | None = None, force_progress: bool = False) -> None:
pass

def is_incomplete_namespace(self, fullname: str) -> bool:
return False

@property
def final_iteration(self) -> bool:
return True

def is_future_flag_set(self, flag: str) -> bool:
return self._chk.tree.is_future_flag_set(flag)

@property
def is_stub_file(self) -> bool:
return self._chk.tree.is_stub

def is_func_scope(self) -> bool:
# Return arbitrary value.
#
# This method is currently only used to decide whether to pair
# a fail() message with a note() message or not. Both of those
# message types are ignored.
return False

@property
def type(self) -> TypeInfo | None:
return self._chk.type


class CollectArgTypeVarTypes(TypeTraverserVisitor):
"""Collects the non-nested argument types in a set."""

Expand Down
97 changes: 96 additions & 1 deletion mypy/checkexpr.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
freshen_all_functions_type_vars,
freshen_function_type_vars,
)
from mypy.exprtotype import TypeTranslationError, expr_to_unanalyzed_type
from mypy.infer import ArgumentInferContext, infer_function_type_arguments, infer_type_arguments
from mypy.literals import literal
from mypy.maptype import map_instance_to_supertype
Expand All @@ -46,6 +47,7 @@
LITERAL_TYPE,
REVEAL_LOCALS,
REVEAL_TYPE,
UNBOUND_IMPORTED,
ArgKind,
AssertTypeExpr,
AssignmentExpr,
Expand All @@ -71,11 +73,13 @@
LambdaExpr,
ListComprehension,
ListExpr,
MaybeTypeExpression,
MemberExpr,
MypyFile,
NamedTupleExpr,
NameExpr,
NewTypeExpr,
NotParsed,
OpExpr,
OverloadedFuncDef,
ParamSpecExpr,
Expand All @@ -90,19 +94,22 @@
StrExpr,
SuperExpr,
SymbolNode,
SymbolTableNode,
TempNode,
TupleExpr,
TypeAlias,
TypeAliasExpr,
TypeApplication,
TypedDictExpr,
TypeFormExpr,
TypeInfo,
TypeVarExpr,
TypeVarTupleExpr,
UnaryExpr,
Var,
YieldExpr,
YieldFromExpr,
get_member_expr_fullname,
)
from mypy.options import PRECISE_TUPLE_TYPES
from mypy.plugin import (
Expand All @@ -121,8 +128,14 @@
is_subtype,
non_method_protocol_members,
)
from mypy.traverser import has_await_expression
from mypy.traverser import (
all_name_and_member_expressions,
has_await_expression,
has_str_expression,
)
from mypy.tvar_scope import TypeVarLikeScope
from mypy.typeanal import (
TypeAnalyser,
check_for_explicit_any,
fix_instance,
has_any_from_unimported_type,
Expand Down Expand Up @@ -4688,6 +4701,10 @@ def visit_cast_expr(self, expr: CastExpr) -> Type:
)
return target_type

def visit_type_form_expr(self, expr: TypeFormExpr) -> Type:
typ = expr.type
return TypeType.make_normalized(typ, line=typ.line, column=typ.column, is_type_form=True)

def visit_assert_type_expr(self, expr: AssertTypeExpr) -> Type:
source_type = self.accept(
expr.expr,
Expand Down Expand Up @@ -5932,6 +5949,7 @@ def accept(
old_is_callee = self.is_callee
self.is_callee = is_callee
try:
p_type_context = get_proper_type(type_context)
if allow_none_return and isinstance(node, CallExpr):
typ = self.visit_call_expr(node, allow_none_return=True)
elif allow_none_return and isinstance(node, YieldFromExpr):
Expand All @@ -5940,6 +5958,17 @@ def accept(
typ = self.visit_conditional_expr(node, allow_none_return=True)
elif allow_none_return and isinstance(node, AwaitExpr):
typ = self.visit_await_expr(node, allow_none_return=True)
elif (
isinstance(p_type_context, TypeType)
and p_type_context.is_type_form
and (node_as_type := self.try_parse_as_type_expression(node)) is not None
):
typ = TypeType.make_normalized(
node_as_type,
line=node_as_type.line,
column=node_as_type.column,
is_type_form=True,
)
else:
typ = node.accept(self)
except Exception as err:
Expand Down Expand Up @@ -6294,6 +6323,72 @@ def has_abstract_type(self, caller_type: ProperType, callee_type: ProperType) ->
and not self.chk.allow_abstract_call
)

def try_parse_as_type_expression(self, maybe_type_expr: Expression) -> Type | None:
"""Try to parse a value Expression as a type expression.
If success then return the type that it spells.
If fails then return None.

A value expression that is parsable as a type expression may be used
where a TypeForm is expected to represent the spelled type.

Unlike SemanticAnalyzer.try_parse_as_type_expression()
(used in the earlier SemanticAnalyzer pass), this function can only
recognize type expressions which contain no string annotations."""
if not isinstance(maybe_type_expr, MaybeTypeExpression):
return None

# Check whether has already been parsed as a type expression
# by SemanticAnalyzer.try_parse_as_type_expression(),
# perhaps containing a string annotation
if (
isinstance(maybe_type_expr, (StrExpr, IndexExpr, OpExpr))
and maybe_type_expr.as_type != NotParsed.VALUE
):
return maybe_type_expr.as_type

# If is potentially a type expression containing a string annotation,
# don't try to parse it because there isn't enough information
# available to the TypeChecker pass to resolve string annotations
if has_str_expression(maybe_type_expr):
self.chk.note(
"TypeForm containing a string annotation cannot be recognized here. "
"Surround with TypeForm(...) to recognize.",
maybe_type_expr,
code=codes.MAYBE_UNRECOGNIZED_STR_TYPEFORM,
)
return None

# Collect symbols targeted by NameExprs and MemberExprs,
# to be looked up by TypeAnalyser when binding the
# UnboundTypes corresponding to those expressions.
(name_exprs, member_exprs) = all_name_and_member_expressions(maybe_type_expr)
sym_for_name = {e.name: SymbolTableNode(UNBOUND_IMPORTED, e.node) for e in name_exprs} | {
e_name: SymbolTableNode(UNBOUND_IMPORTED, e.node)
for e in member_exprs
if (e_name := get_member_expr_fullname(e)) is not None
}

chk_sem = mypy.checker.TypeCheckerAsSemanticAnalyzer(self.chk, sym_for_name)
tpan = TypeAnalyser(
chk_sem,
TypeVarLikeScope(), # empty scope
self.plugin,
self.chk.options,
self.chk.tree,
self.chk.is_typeshed_stub,
)

try:
typ1 = expr_to_unanalyzed_type(
maybe_type_expr, self.chk.options, self.chk.is_typeshed_stub
)
typ2 = typ1.accept(tpan)
if chk_sem.did_fail:
return None
return typ2
except TypeTranslationError:
return None


def has_any_type(t: Type, ignore_in_type_obj: bool = False) -> bool:
"""Whether t contains an Any type"""
Expand Down
2 changes: 1 addition & 1 deletion mypy/copytype.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ def visit_overloaded(self, t: Overloaded) -> ProperType:

def visit_type_type(self, t: TypeType) -> ProperType:
# Use cast since the type annotations in TypeType are imprecise.
return self.copy_common(t, TypeType(cast(Any, t.item)))
return self.copy_common(t, TypeType(cast(Any, t.item), is_type_form=t.is_type_form))

def visit_type_alias_type(self, t: TypeAliasType) -> ProperType:
assert False, "only ProperTypes supported"
Expand Down
4 changes: 3 additions & 1 deletion mypy/erasetype.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,9 @@ def visit_union_type(self, t: UnionType) -> ProperType:
return make_simplified_union(erased_items)

def visit_type_type(self, t: TypeType) -> ProperType:
return TypeType.make_normalized(t.item.accept(self), line=t.line)
return TypeType.make_normalized(
t.item.accept(self), line=t.line, is_type_form=t.is_type_form
)

def visit_type_alias_type(self, t: TypeAliasType) -> ProperType:
raise RuntimeError("Type aliases should be expanded before accepting this visitor")
Expand Down
5 changes: 5 additions & 0 deletions mypy/errorcodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,11 @@ def __hash__(self) -> int:
"General",
default_enabled=False,
)
MAYBE_UNRECOGNIZED_STR_TYPEFORM: Final[ErrorCode] = ErrorCode(
"maybe-unrecognized-str-typeform",
"Warn when a string is used where a TypeForm is expected but a string annotation cannot be recognized",
"General",
)

# Syntax errors are often blocking.
SYNTAX: Final[ErrorCode] = ErrorCode("syntax", "Report syntax errors", "General")
Expand Down
3 changes: 3 additions & 0 deletions mypy/evalexpr.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,9 @@ def visit_comparison_expr(self, o: mypy.nodes.ComparisonExpr) -> object:
def visit_cast_expr(self, o: mypy.nodes.CastExpr) -> object:
return o.expr.accept(self)

def visit_type_form_expr(self, o: mypy.nodes.TypeFormExpr) -> object:
return UNKNOWN

def visit_assert_type_expr(self, o: mypy.nodes.AssertTypeExpr) -> object:
return o.expr.accept(self)

Expand Down
2 changes: 1 addition & 1 deletion mypy/expandtype.py
Original file line number Diff line number Diff line change
Expand Up @@ -505,7 +505,7 @@ def visit_type_type(self, t: TypeType) -> Type:
# union of instances or Any). Sadly we can't report errors
# here yet.
item = t.item.accept(self)
return TypeType.make_normalized(item)
return TypeType.make_normalized(item, is_type_form=t.is_type_form)

def visit_type_alias_type(self, t: TypeAliasType) -> Type:
# Target of the type alias cannot contain type variables (not bound by the type
Expand Down
Loading
Loading