From ca4c79f1476d9a4c7c007ffaba5f579e58bf63bf Mon Sep 17 00:00:00 2001 From: David Foster Date: Sun, 29 Sep 2024 14:11:00 -0400 Subject: [PATCH 01/23] [PEP 747] Recognize TypeForm[T] type and values (#9773) User must opt-in to use TypeForm with --enable-incomplete-feature=TypeForm In particular: * Recognize TypeForm[T] as a kind of type that can be used in a type expression * Recognize a type expression literal as a TypeForm value in: - assignments - function calls - return statements * Define the following relationships between TypeForm values: - is_subtype - join_types - meet_types * Recognize the TypeForm(...) expression * Alter isinstance(typx, type) to narrow TypeForm[T] to Type[T] --- mypy/checker.py | 5 +- mypy/checkexpr.py | 17 + mypy/copytype.py | 2 +- mypy/erasetype.py | 2 +- mypy/evalexpr.py | 3 + mypy/expandtype.py | 2 +- mypy/join.py | 6 +- mypy/literals.py | 4 + mypy/meet.py | 23 +- mypy/messages.py | 5 +- mypy/mixedtraverser.py | 5 + mypy/nodes.py | 31 +- mypy/options.py | 3 +- mypy/semanal.py | 66 ++- mypy/server/astdiff.py | 2 +- mypy/server/astmerge.py | 5 + mypy/server/deps.py | 5 + mypy/server/subexpr.py | 5 + mypy/strconv.py | 3 + mypy/subtypes.py | 61 ++- mypy/traverser.py | 9 + mypy/treetransform.py | 4 + mypy/type_visitor.py | 7 +- mypy/typeanal.py | 34 +- mypy/typeops.py | 2 +- mypy/types.py | 51 +- mypy/typeshed/stdlib/typing.pyi | 1 + mypy/typeshed/stdlib/typing_extensions.pyi | 1 + mypy/visitor.py | 7 + mypyc/irbuild/visitor.py | 4 + test-data/unit/check-typeform.test | 577 +++++++++++++++++++++ test-data/unit/check-union-or-syntax.test | 3 +- test-data/unit/fixtures/dict.pyi | 4 +- test-data/unit/fixtures/tuple.pyi | 2 + test-data/unit/fixtures/typing-full.pyi | 1 + 35 files changed, 900 insertions(+), 62 deletions(-) create mode 100644 test-data/unit/check-typeform.test diff --git a/mypy/checker.py b/mypy/checker.py index 04a286beef5e..9ca7d85f37a4 100644 --- a/mypy/checker.py +++ b/mypy/checker.py @@ -7786,7 +7786,10 @@ 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), diff --git a/mypy/checkexpr.py b/mypy/checkexpr.py index 4078d447dab8..d6ab0142c59a 100644 --- a/mypy/checkexpr.py +++ b/mypy/checkexpr.py @@ -96,6 +96,7 @@ TypeAliasExpr, TypeApplication, TypedDictExpr, + TypeFormExpr, TypeInfo, TypeVarExpr, TypeVarTupleExpr, @@ -4688,6 +4689,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, @@ -5932,6 +5937,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): @@ -5940,6 +5946,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 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: diff --git a/mypy/copytype.py b/mypy/copytype.py index ecb1a89759b6..a890431a1772 100644 --- a/mypy/copytype.py +++ b/mypy/copytype.py @@ -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" diff --git a/mypy/erasetype.py b/mypy/erasetype.py index 6c47670d6687..57d4e9f120a9 100644 --- a/mypy/erasetype.py +++ b/mypy/erasetype.py @@ -134,7 +134,7 @@ 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") diff --git a/mypy/evalexpr.py b/mypy/evalexpr.py index e39c5840d47a..218d50e37ec3 100644 --- a/mypy/evalexpr.py +++ b/mypy/evalexpr.py @@ -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) diff --git a/mypy/expandtype.py b/mypy/expandtype.py index 031f86e7dfff..d54d8800c3ee 100644 --- a/mypy/expandtype.py +++ b/mypy/expandtype.py @@ -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 diff --git a/mypy/join.py b/mypy/join.py index ac01d11d11d6..1bb9e9d02417 100644 --- a/mypy/join.py +++ b/mypy/join.py @@ -622,7 +622,11 @@ def visit_partial_type(self, t: PartialType) -> ProperType: def visit_type_type(self, t: TypeType) -> ProperType: if isinstance(self.s, TypeType): - return TypeType.make_normalized(join_types(t.item, self.s.item), line=t.line) + return TypeType.make_normalized( + join_types(t.item, self.s.item), + line=t.line, + is_type_form=self.s.is_type_form or t.is_type_form, + ) elif isinstance(self.s, Instance) and self.s.type.fullname == "builtins.type": return self.s else: diff --git a/mypy/literals.py b/mypy/literals.py index 5b0c46f4bee8..fd17e0471440 100644 --- a/mypy/literals.py +++ b/mypy/literals.py @@ -48,6 +48,7 @@ TypeAliasExpr, TypeApplication, TypedDictExpr, + TypeFormExpr, TypeVarExpr, TypeVarTupleExpr, UnaryExpr, @@ -244,6 +245,9 @@ def visit_slice_expr(self, e: SliceExpr) -> None: def visit_cast_expr(self, e: CastExpr) -> None: return None + def visit_type_form_expr(self, e: TypeFormExpr) -> None: + return None + def visit_assert_type_expr(self, e: AssertTypeExpr) -> None: return None diff --git a/mypy/meet.py b/mypy/meet.py index b5262f87c0bd..843451ba6584 100644 --- a/mypy/meet.py +++ b/mypy/meet.py @@ -158,12 +158,27 @@ def narrow_declared_type(declared: Type, narrowed: Type) -> Type: elif isinstance(narrowed, TypeVarType) and is_subtype(narrowed.upper_bound, declared): return narrowed elif isinstance(declared, TypeType) and isinstance(narrowed, TypeType): - return TypeType.make_normalized(narrow_declared_type(declared.item, narrowed.item)) + return TypeType.make_normalized( + narrow_declared_type(declared.item, narrowed.item), + is_type_form=declared.is_type_form and narrowed.is_type_form, + ) elif ( isinstance(declared, TypeType) and isinstance(narrowed, Instance) and narrowed.type.is_metaclass() ): + if declared.is_type_form: + # The declared TypeForm[T] after narrowing must be a kind of + # type object at least as narrow as Type[T] + return narrow_declared_type( + TypeType.make_normalized( + declared.item, + line=declared.line, + column=declared.column, + is_type_form=False, + ), + original_narrowed, + ) # We'd need intersection types, so give up. return original_declared elif isinstance(declared, Instance): @@ -1074,7 +1089,11 @@ def visit_type_type(self, t: TypeType) -> ProperType: if isinstance(self.s, TypeType): typ = self.meet(t.item, self.s.item) if not isinstance(typ, NoneType): - typ = TypeType.make_normalized(typ, line=t.line) + typ = TypeType.make_normalized( + typ, + line=t.line, + is_type_form=self.s.is_type_form and t.is_type_form, + ) return typ elif isinstance(self.s, Instance) and self.s.type.fullname == "builtins.type": return t diff --git a/mypy/messages.py b/mypy/messages.py index 25c4ed68ccb5..e23d23384801 100644 --- a/mypy/messages.py +++ b/mypy/messages.py @@ -2721,7 +2721,10 @@ def format_literal_value(typ: LiteralType) -> str: elif isinstance(typ, UninhabitedType): return "Never" elif isinstance(typ, TypeType): - type_name = "type" if options.use_lowercase_names() else "Type" + if typ.is_type_form: + type_name = "TypeForm" + else: + type_name = "type" if options.use_lowercase_names() else "Type" return f"{type_name}[{format(typ.item)}]" elif isinstance(typ, FunctionLike): func = typ diff --git a/mypy/mixedtraverser.py b/mypy/mixedtraverser.py index 324e8a87c1bd..a4804e680e68 100644 --- a/mypy/mixedtraverser.py +++ b/mypy/mixedtraverser.py @@ -15,6 +15,7 @@ TypeAliasStmt, TypeApplication, TypedDictExpr, + TypeFormExpr, TypeVarExpr, Var, WithStmt, @@ -107,6 +108,10 @@ def visit_cast_expr(self, o: CastExpr, /) -> None: super().visit_cast_expr(o) o.type.accept(self) + def visit_type_form_expr(self, o: TypeFormExpr, /) -> None: + super().visit_type_form_expr(o) + o.type.accept(self) + def visit_assert_type_expr(self, o: AssertTypeExpr, /) -> None: super().visit_assert_type_expr(o) o.type.accept(self) diff --git a/mypy/nodes.py b/mypy/nodes.py index 6487ee4b745c..90c99152321b 100644 --- a/mypy/nodes.py +++ b/mypy/nodes.py @@ -201,7 +201,19 @@ def accept(self, visitor: StatementVisitor[T]) -> T: class Expression(Node): """An expression node.""" - __slots__ = () + # NOTE: Cannot use __slots__ because some subclasses also inherit from + # a different superclass with its own __slots__. A subclass in + # Python is not allowed to have multiple superclasses that define + # __slots__. + #__slots__ = ('as_type',) + + # If this value expression can also be parsed as a valid type expression, + # represents the type denoted by the type expression. + as_type: mypy.types.Type | None + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self.as_type = None def accept(self, visitor: ExpressionVisitor[T]) -> T: raise RuntimeError("Not implemented", type(self)) @@ -2207,6 +2219,23 @@ def accept(self, visitor: ExpressionVisitor[T]) -> T: return visitor.visit_cast_expr(self) +class TypeFormExpr(Expression): + """TypeForm(type) expression.""" + + __slots__ = ("type",) + + __match_args__ = ("type",) + + type: mypy.types.Type + + def __init__(self, typ: mypy.types.Type) -> None: + super().__init__() + self.type = typ + + def accept(self, visitor: ExpressionVisitor[T]) -> T: + return visitor.visit_type_form_expr(self) + + class AssertTypeExpr(Expression): """Represents a typing.assert_type(expr, type) call.""" diff --git a/mypy/options.py b/mypy/options.py index d40a08107a7a..5bfc9b591834 100644 --- a/mypy/options.py +++ b/mypy/options.py @@ -79,7 +79,8 @@ class BuildType: PRECISE_TUPLE_TYPES: Final = "PreciseTupleTypes" NEW_GENERIC_SYNTAX: Final = "NewGenericSyntax" INLINE_TYPEDDICT: Final = "InlineTypedDict" -INCOMPLETE_FEATURES: Final = frozenset((PRECISE_TUPLE_TYPES, INLINE_TYPEDDICT)) +TYPE_FORM: Final = "TypeForm" +INCOMPLETE_FEATURES: Final = frozenset((PRECISE_TUPLE_TYPES, INLINE_TYPEDDICT, TYPE_FORM)) COMPLETE_FEATURES: Final = frozenset((TYPE_VAR_TUPLE, UNPACK, NEW_GENERIC_SYNTAX)) diff --git a/mypy/semanal.py b/mypy/semanal.py index 1a64731057e2..ebea25cdd10b 100644 --- a/mypy/semanal.py +++ b/mypy/semanal.py @@ -172,6 +172,7 @@ TypeAliasStmt, TypeApplication, TypedDictExpr, + TypeFormExpr, TypeInfo, TypeParam, TypeVarExpr, @@ -191,7 +192,7 @@ type_aliases_source_versions, typing_extensions_aliases, ) -from mypy.options import Options +from mypy.options import Options, TYPE_FORM from mypy.patterns import ( AsPattern, ClassPattern, @@ -3209,6 +3210,7 @@ def visit_assignment_stmt(self, s: AssignmentStmt) -> None: self.store_final_status(s) self.check_classvar(s) self.process_type_annotation(s) + self.analyze_rvalue_as_type_form(s) self.apply_dynamic_class_hook(s) if not s.type: self.process_module_assignment(s.lvalues, s.rvalue, s) @@ -3537,6 +3539,10 @@ def analyze_lvalues(self, s: AssignmentStmt) -> None: has_explicit_value=has_explicit_value, ) + def analyze_rvalue_as_type_form(self, s: AssignmentStmt) -> None: + if TYPE_FORM in self.options.enable_incomplete_feature: + s.rvalue.as_type = self.try_parse_as_type_expression(s.rvalue) + def apply_dynamic_class_hook(self, s: AssignmentStmt) -> None: if not isinstance(s.rvalue, CallExpr): return @@ -5271,6 +5277,8 @@ def visit_return_stmt(self, s: ReturnStmt) -> None: self.fail('"return" not allowed in except* block', s, serious=True) if s.expr: s.expr.accept(self) + if TYPE_FORM in self.options.enable_incomplete_feature: + s.expr.as_type = self.try_parse_as_type_expression(s.expr) def visit_raise_stmt(self, s: RaiseStmt) -> None: self.statement = s @@ -5791,10 +5799,33 @@ def visit_call_expr(self, expr: CallExpr) -> None: with self.allow_unbound_tvars_set(): for a in expr.args: a.accept(self) + elif refers_to_fullname( + expr.callee, ("typing.TypeForm", "typing_extensions.TypeForm") + ): + # Special form TypeForm(...). + if not self.check_fixed_args(expr, 1, "TypeForm"): + return + # Translate first argument to an unanalyzed type. + try: + typ = self.expr_to_unanalyzed_type(expr.args[0]) + except TypeTranslationError: + self.fail("TypeForm argument is not a type", expr) + # Suppress future error: "" not callable + expr.analyzed = CastExpr(expr.args[0], AnyType(TypeOfAny.from_error)) + return + # Piggyback TypeFormExpr object to the CallExpr object; it takes + # precedence over the CallExpr semantics. + expr.analyzed = TypeFormExpr(typ) + expr.analyzed.line = expr.line + expr.analyzed.column = expr.column + expr.analyzed.accept(self) else: # Normal call expression. + calculate_type_forms = TYPE_FORM in self.options.enable_incomplete_feature for a in expr.args: a.accept(self) + if calculate_type_forms: + a.as_type = self.try_parse_as_type_expression(a) if ( isinstance(expr.callee, MemberExpr) @@ -6063,6 +6094,11 @@ def visit_cast_expr(self, expr: CastExpr) -> None: if analyzed is not None: expr.type = analyzed + def visit_type_form_expr(self, expr: TypeFormExpr) -> None: + analyzed = self.anal_type(expr.type) + if analyzed is not None: + expr.type = analyzed + def visit_assert_type_expr(self, expr: AssertTypeExpr) -> None: expr.expr.accept(self) analyzed = self.anal_type(expr.type) @@ -7584,6 +7620,34 @@ def visit_pass_stmt(self, o: PassStmt, /) -> None: def visit_singleton_pattern(self, o: SingletonPattern, /) -> None: return None + def try_parse_as_type_expression(self, maybe_type_expr: Expression) -> Type|None: + """Try to parse maybe_type_expr as a type expression. + If parsing fails return None and emit no errors.""" + # Save SemanticAnalyzer state + original_errors = self.errors # altered by fail() + original_num_incomplete_refs = self.num_incomplete_refs # altered by record_incomplete_ref() + original_progress = self.progress # altered by defer() + original_deferred = self.deferred # altered by defer() + original_deferral_debug_context_len = len(self.deferral_debug_context) # altered by defer() + + self.errors = Errors(Options()) + try: + t = self.expr_to_analyzed_type(maybe_type_expr) + if self.errors.is_errors(): + raise TypeTranslationError + if isinstance(t, (UnboundType, PlaceholderType)): # type: ignore[misc] + raise TypeTranslationError + except TypeTranslationError: + # Not a type expression. It must be a value expression. + t = None + finally: + # Restore SemanticAnalyzer state + self.errors = original_errors + self.num_incomplete_refs = original_num_incomplete_refs + self.progress = original_progress + self.deferred = original_deferred + del self.deferral_debug_context[original_deferral_debug_context_len:] + return t def replace_implicit_first_type(sig: FunctionLike, new: Type) -> FunctionLike: if isinstance(sig, CallableType): diff --git a/mypy/server/astdiff.py b/mypy/server/astdiff.py index 1b0cc218ed16..f35cf4f94610 100644 --- a/mypy/server/astdiff.py +++ b/mypy/server/astdiff.py @@ -508,7 +508,7 @@ def visit_partial_type(self, typ: PartialType) -> SnapshotItem: raise RuntimeError def visit_type_type(self, typ: TypeType) -> SnapshotItem: - return ("TypeType", snapshot_type(typ.item)) + return ("TypeType", snapshot_type(typ.item), typ.is_type_form) def visit_type_alias_type(self, typ: TypeAliasType) -> SnapshotItem: assert typ.alias is not None diff --git a/mypy/server/astmerge.py b/mypy/server/astmerge.py index 8cd574628bb8..dc972c22e110 100644 --- a/mypy/server/astmerge.py +++ b/mypy/server/astmerge.py @@ -75,6 +75,7 @@ SymbolTable, TypeAlias, TypedDictExpr, + TypeFormExpr, TypeInfo, Var, ) @@ -291,6 +292,10 @@ def visit_cast_expr(self, node: CastExpr) -> None: super().visit_cast_expr(node) self.fixup_type(node.type) + def visit_type_form_expr(self, node: TypeFormExpr) -> None: + super().visit_type_form_expr(node) + self.fixup_type(node.type) + def visit_assert_type_expr(self, node: AssertTypeExpr) -> None: super().visit_assert_type_expr(node) self.fixup_type(node.type) diff --git a/mypy/server/deps.py b/mypy/server/deps.py index b994a214f67a..0191a477d346 100644 --- a/mypy/server/deps.py +++ b/mypy/server/deps.py @@ -125,6 +125,7 @@ class 'mod.Cls'. This can also refer to an attribute inherited from a TypeAliasExpr, TypeApplication, TypedDictExpr, + TypeFormExpr, TypeInfo, TypeVarExpr, UnaryExpr, @@ -763,6 +764,10 @@ def visit_cast_expr(self, e: CastExpr) -> None: super().visit_cast_expr(e) self.add_type_dependencies(e.type) + def visit_type_form_expr(self, e: TypeFormExpr) -> None: + super().visit_type_form_expr(e) + self.add_type_dependencies(e.type) + def visit_assert_type_expr(self, e: AssertTypeExpr) -> None: super().visit_assert_type_expr(e) self.add_type_dependencies(e.type) diff --git a/mypy/server/subexpr.py b/mypy/server/subexpr.py index c94db44445dc..013b936e8b7c 100644 --- a/mypy/server/subexpr.py +++ b/mypy/server/subexpr.py @@ -28,6 +28,7 @@ StarExpr, TupleExpr, TypeApplication, + TypeFormExpr, UnaryExpr, YieldExpr, YieldFromExpr, @@ -122,6 +123,10 @@ def visit_cast_expr(self, e: CastExpr) -> None: self.add(e) super().visit_cast_expr(e) + def visit_type_form_expr(self, e: TypeFormExpr) -> None: + self.add(e) + super().visit_type_form_expr(e) + def visit_assert_type_expr(self, e: AssertTypeExpr) -> None: self.add(e) super().visit_assert_type_expr(e) diff --git a/mypy/strconv.py b/mypy/strconv.py index 3e9d37586f72..128de0561856 100644 --- a/mypy/strconv.py +++ b/mypy/strconv.py @@ -464,6 +464,9 @@ def visit_comparison_expr(self, o: mypy.nodes.ComparisonExpr) -> str: def visit_cast_expr(self, o: mypy.nodes.CastExpr) -> str: return self.dump([o.expr, o.type], o) + def visit_type_form_expr(self, o: mypy.nodes.TypeFormExpr) -> str: + return self.dump([o.type], o) + def visit_assert_type_expr(self, o: mypy.nodes.AssertTypeExpr) -> str: return self.dump([o.expr, o.type], o) diff --git a/mypy/subtypes.py b/mypy/subtypes.py index 41bb4601e23f..dedde85fec37 100644 --- a/mypy/subtypes.py +++ b/mypy/subtypes.py @@ -1086,33 +1086,44 @@ def visit_partial_type(self, left: PartialType) -> bool: def visit_type_type(self, left: TypeType) -> bool: right = self.right - if isinstance(right, TypeType): - return self._is_subtype(left.item, right.item) - if isinstance(right, CallableType): - if self.proper_subtype and not right.is_type_obj(): - # We can't accept `Type[X]` as a *proper* subtype of Callable[P, X] - # since this will break transitivity of subtyping. + if left.is_type_form: + if isinstance(right, TypeType): + if not right.is_type_form: + return False + return self._is_subtype(left.item, right.item) + if isinstance(right, Instance): + if right.type.fullname == "builtins.object": + return True return False - # This is unsound, we don't check the __init__ signature. - return self._is_subtype(left.item, right.ret_type) - if isinstance(right, Instance): - if right.type.fullname in ["builtins.object", "builtins.type"]: - # TODO: Strictly speaking, the type builtins.type is considered equivalent to - # Type[Any]. However, this would break the is_proper_subtype check in - # conditional_types for cases like isinstance(x, type) when the type - # of x is Type[int]. It's unclear what's the right way to address this. - return True - item = left.item - if isinstance(item, TypeVarType): - item = get_proper_type(item.upper_bound) - if isinstance(item, Instance): - if right.type.is_protocol and is_protocol_implementation( - item, right, proper_subtype=self.proper_subtype, class_obj=True - ): + return False + else: # not left.is_type_form + if isinstance(right, TypeType): + return self._is_subtype(left.item, right.item) + if isinstance(right, CallableType): + if self.proper_subtype and not right.is_type_obj(): + # We can't accept `Type[X]` as a *proper* subtype of Callable[P, X] + # since this will break transitivity of subtyping. + return False + # This is unsound, we don't check the __init__ signature. + return self._is_subtype(left.item, right.ret_type) + if isinstance(right, Instance): + if right.type.fullname in ["builtins.object", "builtins.type"]: + # TODO: Strictly speaking, the type builtins.type is considered equivalent to + # Type[Any]. However, this would break the is_proper_subtype check in + # conditional_types for cases like isinstance(x, type) when the type + # of x is Type[int]. It's unclear what's the right way to address this. return True - metaclass = item.type.metaclass_type - return metaclass is not None and self._is_subtype(metaclass, right) - return False + item = left.item + if isinstance(item, TypeVarType): + item = get_proper_type(item.upper_bound) + if isinstance(item, Instance): + if right.type.is_protocol and is_protocol_implementation( + item, right, proper_subtype=self.proper_subtype, class_obj=True + ): + return True + metaclass = item.type.metaclass_type + return metaclass is not None and self._is_subtype(metaclass, right) + return False def visit_type_alias_type(self, left: TypeAliasType) -> bool: assert False, f"This should be never called, got {left}" diff --git a/mypy/traverser.py b/mypy/traverser.py index 7d7794822396..54325b740e95 100644 --- a/mypy/traverser.py +++ b/mypy/traverser.py @@ -76,6 +76,7 @@ TypeAliasStmt, TypeApplication, TypedDictExpr, + TypeFormExpr, TypeVarExpr, TypeVarTupleExpr, UnaryExpr, @@ -289,6 +290,9 @@ def visit_slice_expr(self, o: SliceExpr, /) -> None: def visit_cast_expr(self, o: CastExpr, /) -> None: o.expr.accept(self) + def visit_type_form_expr(self, o: TypeFormExpr, /) -> None: + pass + def visit_assert_type_expr(self, o: AssertTypeExpr, /) -> None: o.expr.accept(self) @@ -737,6 +741,11 @@ def visit_cast_expr(self, o: CastExpr, /) -> None: return super().visit_cast_expr(o) + def visit_type_form_expr(self, o: TypeFormExpr, /) -> None: + if not self.visit(o): + return + super().visit_type_form_expr(o) + def visit_assert_type_expr(self, o: AssertTypeExpr, /) -> None: if not self.visit(o): return diff --git a/mypy/treetransform.py b/mypy/treetransform.py index 3e5a7ef3f2ca..ea20823a55ea 100644 --- a/mypy/treetransform.py +++ b/mypy/treetransform.py @@ -83,6 +83,7 @@ TypeAliasExpr, TypeApplication, TypedDictExpr, + TypeFormExpr, TypeVarExpr, TypeVarTupleExpr, UnaryExpr, @@ -540,6 +541,9 @@ def visit_comparison_expr(self, node: ComparisonExpr) -> ComparisonExpr: def visit_cast_expr(self, node: CastExpr) -> CastExpr: return CastExpr(self.expr(node.expr), self.type(node.type)) + def visit_type_form_expr(self, node: TypeFormExpr) -> TypeFormExpr: + return TypeFormExpr(self.type(node.type)) + def visit_assert_type_expr(self, node: AssertTypeExpr) -> AssertTypeExpr: return AssertTypeExpr(self.expr(node.expr), self.type(node.type)) diff --git a/mypy/type_visitor.py b/mypy/type_visitor.py index ab1ec8b46fdd..4d62b52df41a 100644 --- a/mypy/type_visitor.py +++ b/mypy/type_visitor.py @@ -325,7 +325,12 @@ def visit_overloaded(self, t: Overloaded, /) -> Type: return Overloaded(items=items) def visit_type_type(self, t: TypeType, /) -> Type: - return TypeType.make_normalized(t.item.accept(self), line=t.line, column=t.column) + return TypeType.make_normalized( + t.item.accept(self), + line=t.line, + column=t.column, + is_type_form=t.is_type_form, + ) @abstractmethod def visit_type_alias_type(self, t: TypeAliasType, /) -> Type: diff --git a/mypy/typeanal.py b/mypy/typeanal.py index 9208630937e7..48132d2988a6 100644 --- a/mypy/typeanal.py +++ b/mypy/typeanal.py @@ -49,7 +49,7 @@ check_arg_names, get_nongen_builtins, ) -from mypy.options import INLINE_TYPEDDICT, Options +from mypy.options import INLINE_TYPEDDICT, Options, TYPE_FORM from mypy.plugin import AnalyzeTypeContext, Plugin, TypeAnalyzerPluginInterface from mypy.semanal_shared import ( SemanticAnalyzerCoreInterface, @@ -694,6 +694,23 @@ def try_analyze_special_unbound_type(self, t: UnboundType, fullname: str) -> Typ self.fail(f'{type_str} can\'t contain "{bad_item_name}"', t, code=codes.VALID_TYPE) item = AnyType(TypeOfAny.from_error) return TypeType.make_normalized(item, line=t.line, column=t.column) + elif fullname in ("typing_extensions.TypeForm", "typing.TypeForm"): + if TYPE_FORM not in self.options.enable_incomplete_feature: + self.fail( + "TypeForm is experimental," + " must be enabled with --enable-incomplete-feature=TypeForm", + t, + ) + if len(t.args) == 0: + any_type = self.get_omitted_any(t) + return TypeType(any_type, line=t.line, column=t.column, is_type_form=True) + if len(t.args) != 1: + type_str = "TypeForm[...]" + self.fail( + type_str + " must have exactly one type argument", t, code=codes.VALID_TYPE + ) + item = self.anal_type(t.args[0]) + return TypeType.make_normalized(item, line=t.line, column=t.column, is_type_form=True) elif fullname == "typing.ClassVar": if self.nesting_level > 0: self.fail( @@ -1343,7 +1360,18 @@ def visit_typeddict_type(self, t: TypedDictType) -> Type: t, ) required_keys = req_keys - fallback = self.named_type("typing._TypedDict") + try: + fallback = self.named_type("typing._TypedDict") + except AssertionError as e: + if str(e) == 'typing._TypedDict': + # Can happen when running mypy tests, typing._TypedDict + # is not defined by typing.pyi stubs, and + # try_parse_as_type_expression() is called on an dict + # expression that looks like an inline TypedDict type. + self.fail("Internal error: typing._TypedDict not found", t) + fallback = self.named_type("builtins.object") + else: + raise for typ in t.extra_items_from: analyzed = self.analyze_type(typ) p_analyzed = get_proper_type(analyzed) @@ -1425,7 +1453,7 @@ def visit_ellipsis_type(self, t: EllipsisType) -> Type: return AnyType(TypeOfAny.from_error) def visit_type_type(self, t: TypeType) -> Type: - return TypeType.make_normalized(self.anal_type(t.item), line=t.line) + return TypeType.make_normalized(self.anal_type(t.item), line=t.line, is_type_form=t.is_type_form) def visit_placeholder_type(self, t: PlaceholderType) -> Type: n = ( diff --git a/mypy/typeops.py b/mypy/typeops.py index ac0695a096a6..f2c00854f040 100644 --- a/mypy/typeops.py +++ b/mypy/typeops.py @@ -442,7 +442,7 @@ def erase_to_bound(t: Type) -> Type: return t.upper_bound if isinstance(t, TypeType): if isinstance(t.item, TypeVarType): - return TypeType.make_normalized(t.item.upper_bound) + return TypeType.make_normalized(t.item.upper_bound, is_type_form=t.is_type_form) return t diff --git a/mypy/types.py b/mypy/types.py index f9749945d9e9..77914a08b92a 100644 --- a/mypy/types.py +++ b/mypy/types.py @@ -3059,11 +3059,14 @@ def serialize(self) -> JsonDict: class TypeType(ProperType): - """For types like Type[User]. + """For types like Type[User] or TypeForm[User | None]. - This annotates variables that are class objects, constrained by + Type[C] annotates variables that are class objects, constrained by the type argument. See PEP 484 for more details. + TypeForm[T] annotates variables that hold the result of evaluating + a type expression. See PEP 747 for more details. + We may encounter expressions whose values are specific classes; those are represented as callables (possibly overloaded) corresponding to the class's constructor's signature and returning @@ -3086,35 +3089,45 @@ class TypeType(ProperType): assumption). """ - __slots__ = ("item",) + __slots__ = ("item", "is_type_form",) # This can't be everything, but it can be a class reference, # a generic class instance, a union, Any, a type variable... item: ProperType + # If True then this TypeType represents a TypeForm[T]. + # If False then this TypeType represents a Type[C]. + is_type_form: bool + def __init__( self, item: Bogus[Instance | AnyType | TypeVarType | TupleType | NoneType | CallableType], *, line: int = -1, column: int = -1, + is_type_form: bool = False, ) -> None: """To ensure Type[Union[A, B]] is always represented as Union[Type[A], Type[B]], item of type UnionType must be handled through make_normalized static method. """ super().__init__(line, column) self.item = item + self.is_type_form = is_type_form @staticmethod - def make_normalized(item: Type, *, line: int = -1, column: int = -1) -> ProperType: + def make_normalized(item: Type, *, line: int = -1, column: int = -1, is_type_form: bool = False) -> ProperType: item = get_proper_type(item) - if isinstance(item, UnionType): - return UnionType.make_union( - [TypeType.make_normalized(union_item) for union_item in item.items], - line=line, - column=column, - ) - return TypeType(item, line=line, column=column) # type: ignore[arg-type] + if is_type_form: + # Don't convert TypeForm[X | Y] to (TypeForm[X] | TypeForm[Y]) + pass + else: + if isinstance(item, UnionType): + return UnionType.make_union( + [TypeType.make_normalized(union_item) for union_item in item.items], + line=line, + column=column, + ) + return TypeType(item, line=line, column=column, is_type_form=is_type_form) # type: ignore[arg-type] def accept(self, visitor: TypeVisitor[T]) -> T: return visitor.visit_type_type(self) @@ -3128,12 +3141,12 @@ def __eq__(self, other: object) -> bool: return self.item == other.item def serialize(self) -> JsonDict: - return {".class": "TypeType", "item": self.item.serialize()} + return {".class": "TypeType", "item": self.item.serialize(), "is_type_form": self.is_type_form} @classmethod def deserialize(cls, data: JsonDict) -> Type: assert data[".class"] == "TypeType" - return TypeType.make_normalized(deserialize_type(data["item"])) + return TypeType.make_normalized(deserialize_type(data["item"]), is_type_form=data["is_type_form"]) class PlaceholderType(ProperType): @@ -3502,11 +3515,15 @@ def visit_ellipsis_type(self, t: EllipsisType, /) -> str: return "..." def visit_type_type(self, t: TypeType, /) -> str: - if self.options.use_lowercase_names(): - type_name = "type" + if t.is_type_form: + type_name = "TypeForm" + return f"{type_name}[{t.item.accept(self)}]" else: - type_name = "Type" - return f"{type_name}[{t.item.accept(self)}]" + if self.options.use_lowercase_names(): + type_name = "type" + else: + type_name = "Type" + return f"{type_name}[{t.item.accept(self)}]" def visit_placeholder_type(self, t: PlaceholderType, /) -> str: return f"" diff --git a/mypy/typeshed/stdlib/typing.pyi b/mypy/typeshed/stdlib/typing.pyi index 7c1b171a730b..f705435b76eb 100644 --- a/mypy/typeshed/stdlib/typing.pyi +++ b/mypy/typeshed/stdlib/typing.pyi @@ -208,6 +208,7 @@ Generic: _SpecialForm Protocol: _SpecialForm Callable: _SpecialForm Type: _SpecialForm +TypeForm: _SpecialForm NoReturn: _SpecialForm ClassVar: _SpecialForm diff --git a/mypy/typeshed/stdlib/typing_extensions.pyi b/mypy/typeshed/stdlib/typing_extensions.pyi index 33af1a388aa5..575fb656d12d 100644 --- a/mypy/typeshed/stdlib/typing_extensions.pyi +++ b/mypy/typeshed/stdlib/typing_extensions.pyi @@ -55,6 +55,7 @@ from typing import ( # noqa: Y022,Y037,Y038,Y039 Tuple as Tuple, Type as Type, TypedDict as TypedDict, + TypeForm as TypeForm, Union as Union, ValuesView as ValuesView, _Alias, diff --git a/mypy/visitor.py b/mypy/visitor.py index d1b2ca416410..e150788ec3c1 100644 --- a/mypy/visitor.py +++ b/mypy/visitor.py @@ -79,6 +79,10 @@ def visit_comparison_expr(self, o: mypy.nodes.ComparisonExpr, /) -> T: def visit_cast_expr(self, o: mypy.nodes.CastExpr, /) -> T: pass + @abstractmethod + def visit_type_form_expr(self, o: mypy.nodes.TypeFormExpr, /) -> T: + pass + @abstractmethod def visit_assert_type_expr(self, o: mypy.nodes.AssertTypeExpr, /) -> T: pass @@ -511,6 +515,9 @@ def visit_comparison_expr(self, o: mypy.nodes.ComparisonExpr, /) -> T: def visit_cast_expr(self, o: mypy.nodes.CastExpr, /) -> T: raise NotImplementedError() + def visit_type_form_expr(self, o: mypy.nodes.TypeFormExpr, /) -> T: + raise NotImplementedError() + def visit_assert_type_expr(self, o: mypy.nodes.AssertTypeExpr, /) -> T: raise NotImplementedError() diff --git a/mypyc/irbuild/visitor.py b/mypyc/irbuild/visitor.py index 05a033c3e6ad..dc81e95a2980 100644 --- a/mypyc/irbuild/visitor.py +++ b/mypyc/irbuild/visitor.py @@ -73,6 +73,7 @@ TypeAliasStmt, TypeApplication, TypedDictExpr, + TypeFormExpr, TypeVarExpr, TypeVarTupleExpr, UnaryExpr, @@ -387,6 +388,9 @@ def visit_var(self, o: Var) -> None: def visit_cast_expr(self, o: CastExpr) -> Value: assert False, "CastExpr should have been handled in CallExpr" + def visit_type_form_expr(self, o: TypeFormExpr) -> Value: + assert False, "TypeFormExpr should have been handled in CallExpr" + def visit_assert_type_expr(self, o: AssertTypeExpr) -> Value: assert False, "AssertTypeExpr should have been handled in CallExpr" diff --git a/test-data/unit/check-typeform.test b/test-data/unit/check-typeform.test new file mode 100644 index 000000000000..f57f3832dd2f --- /dev/null +++ b/test-data/unit/check-typeform.test @@ -0,0 +1,577 @@ +-- TypeForm Type + +[case testRecognizesUnparameterizedTypeFormInAnnotation] +# flags: --python-version 3.14 --enable-incomplete-feature=TypeForm +from typing import TypeForm +typx: TypeForm = str +reveal_type(typx) # N: Revealed type is "TypeForm[Any]" +[builtins fixtures/tuple.pyi] +[typing fixtures/typing-full.pyi] + +[case testRecognizesParameterizedTypeFormInAnnotation] +# flags: --python-version 3.14 --enable-incomplete-feature=TypeForm +from typing import TypeForm +typx: TypeForm[str] = str +reveal_type(typx) # N: Revealed type is "TypeForm[builtins.str]" +[builtins fixtures/tuple.pyi] +[typing fixtures/typing-full.pyi] + + +-- Type Expression Location: Assignment + +[case testCanAssignTypeExpressionToUnionTypeFormVariable] +# flags: --python-version 3.14 --enable-incomplete-feature=TypeForm +from typing import TypeForm +typx: TypeForm[str | None] = str | None +[builtins fixtures/tuple.pyi] +[typing fixtures/typing-full.pyi] + +[case testCannotAssignTypeExpressionToTypeFormVariableWithIncompatibleItemType] +# flags: --python-version 3.14 --enable-incomplete-feature=TypeForm +from typing import TypeForm +typx: TypeForm[str] = int # E: Incompatible types in assignment (expression has type "TypeForm[int]", variable has type "TypeForm[str]") +[builtins fixtures/tuple.pyi] +[typing fixtures/typing-full.pyi] + +[case testCanAssignValueExpressionToTypeFormVariableIfValueIsATypeForm1] +# flags: --python-version 3.14 --enable-incomplete-feature=TypeForm +from typing import TypeForm +typx1: TypeForm = str +typx2: TypeForm = typx1 # looks like a type expression: name +[builtins fixtures/tuple.pyi] +[typing fixtures/typing-full.pyi] + +[case testCanAssignValueExpressionToTypeFormVariableIfValueIsATypeForm2] +# flags: --python-version 3.14 --enable-incomplete-feature=TypeForm +from typing import TypeForm +def identity_tf(x: TypeForm) -> TypeForm: + return x +typx1: TypeForm = str +typx2: TypeForm = identity_tf(typx1) # does not look like a type expression +[builtins fixtures/tuple.pyi] +[typing fixtures/typing-full.pyi] + +[case testCannotAssignValueExpressionToTypeFormVariableIfValueIsNotATypeForm] +# flags: --python-version 3.14 --enable-incomplete-feature=TypeForm +from typing import TypeForm +val: int = 42 +typx: TypeForm = val # E: Incompatible types in assignment (expression has type "int", variable has type "TypeForm[Any]") +[builtins fixtures/tuple.pyi] +[typing fixtures/typing-full.pyi] + +[case testCanAssignNoneTypeExpressionToTypeFormVariable] +# flags: --python-version 3.14 --enable-incomplete-feature=TypeForm +from typing import TypeForm +typx: TypeForm = None +reveal_type(typx) # N: Revealed type is "TypeForm[Any]" +[builtins fixtures/tuple.pyi] +[typing fixtures/typing-full.pyi] + +[case testCanAssignTypeExpressionToTypeFormVariableDeclaredEarlier] +# flags: --python-version 3.14 --enable-incomplete-feature=TypeForm +from typing import Type, TypeForm +typ: Type +typ = int | None # E: Incompatible types in assignment (expression has type "object", variable has type "Type[Any]") +typx: TypeForm +typx = int | None +[builtins fixtures/tuple.pyi] +[typing fixtures/typing-full.pyi] + + +-- Type Expression Location: Function Parameter + +[case testCanPassTypeExpressionToTypeFormParameterInFunction] +# flags: --python-version 3.14 --enable-incomplete-feature=TypeForm +from typing import TypeForm +def is_type(typx: TypeForm) -> bool: + return isinstance(typx, type) +is_type(int | None) +[builtins fixtures/tuple.pyi] +[typing fixtures/typing-full.pyi] + +[case testCannotPassTypeExpressionToTypeParameter] +# flags: --python-version 3.14 --enable-incomplete-feature=TypeForm +def is_type(typ: type) -> bool: + return isinstance(typ, type) +is_type(int | None) # E: Argument 1 to "is_type" has incompatible type "object"; expected "type" +[builtins fixtures/tuple.pyi] +[typing fixtures/typing-full.pyi] + +[case testCanPassTypeExpressionToTypeFormParameterInMethod] +# flags: --python-version 3.14 --enable-incomplete-feature=TypeForm +from typing import TypeForm +class C: + def is_type(self, typx: TypeForm) -> bool: + return isinstance(typx, type) +C().is_type(int | None) +[builtins fixtures/tuple.pyi] +[typing fixtures/typing-full.pyi] + +[case testCanPassTypeExpressionToTypeFormParameterInOverload] +# flags: --python-version 3.14 --enable-incomplete-feature=TypeForm +from typing import overload, TypeForm +@overload +def is_type(typx: TypeForm) -> bool: ... +@overload +def is_type(typx: type) -> bool: ... +def is_type(typx): + return isinstance(typx, type) +is_type(int | None) +[builtins fixtures/tuple.pyi] +[typing fixtures/typing-full.pyi] + +[case testCanPassTypeExpressionToTypeFormParameterInDecorator] +# flags: --python-version 3.14 --enable-incomplete-feature=TypeForm +from typing import Callable, TypeForm, TypeVar +P = TypeVar('P') +R = TypeVar('R') +def expects_type(typx: TypeForm) -> Callable[[Callable[[P], R]], Callable[[P], R]]: + def wrap(func: Callable[[P], R]) -> Callable[[P], R]: + func.expected_type = typx # type: ignore[attr-defined] + return func + return wrap +@expects_type(int | None) +def sum_ints(x: int | None) -> int: + return (x or 0) +[builtins fixtures/tuple.pyi] +[typing fixtures/typing-full.pyi] + +[case testCanPassTypeExpressionToTypeFormVarargsParameter] +# flags: --python-version 3.14 --enable-incomplete-feature=TypeForm +from typing import Callable, ParamSpec, TypeForm, TypeVar +P = ParamSpec('P') +R = TypeVar('R') +def expects_types(*typxs: TypeForm) -> Callable[[Callable[P, R]], Callable[P, R]]: + def wrap(func: Callable[P, R]) -> Callable[P, R]: + func.expected_types = typxs # type: ignore[attr-defined] + return func + return wrap +@expects_types(int | None, int) +def sum_ints(x: int | None, y: int) -> tuple[int, int]: + return ((x or 0), y) +[builtins fixtures/tuple.pyi] +[typing fixtures/typing-full.pyi] + + +-- Type Expression Location: Return Statement + +[case testCanReturnTypeExpressionInFunctionWithTypeFormReturnType] +# flags: --python-version 3.14 --enable-incomplete-feature=TypeForm +from typing import TypeForm +def maybe_int_type() -> TypeForm: + return int | None +reveal_type(maybe_int_type()) # N: Revealed type is "TypeForm[Any]" +[builtins fixtures/tuple.pyi] +[typing fixtures/typing-full.pyi] + + +-- Type Expression Location: Other + +[case testTypeExpressionNotCurrentlyRecognizedInAllPossibleSyntacticLocations] +# ...but may be recognized in these or other locations in the future +# flags: --python-version 3.14 --enable-incomplete-feature=TypeForm +from typing import Dict, TypeForm +dict_with_typx_keys: Dict[TypeForm, int] = { + int | str: 1, # E: Dict entry 0 has incompatible type "object": "int"; expected "TypeForm[Any]": "int" + str | None: 2, # E: Dict entry 1 has incompatible type "object": "int"; expected "TypeForm[Any]": "int" +} +dict_with_typx_keys[int | str] += 1 # E: Invalid index type "object" for "Dict[TypeForm[Any], int]"; expected type "TypeForm[Any]" +[builtins fixtures/dict.pyi] +[typing fixtures/typing-full.pyi] + + +-- Assignability (is_subtype) + +[case testTypeFormToTypeFormAssignability] +# flags: --python-version 3.14 --enable-incomplete-feature=TypeForm +# - TypeForm[T1] is assignable to TypeForm[T2] iff T1 is assignable to T2. +# - In particular TypeForm[Any] is assignable to TypeForm[Any]. +from typing import TypeForm +INT_OR_STR_TF: TypeForm[int | str] = int | str +INT_TF: TypeForm[int] = int +STR_TF: TypeForm[str] = str +OBJECT_TF: TypeForm[object] = object +ANY_TF: TypeForm = object +reveal_type(ANY_TF) # N: Revealed type is "TypeForm[Any]" +typx1: TypeForm[int | str] = INT_OR_STR_TF +typx2: TypeForm[int | str] = INT_TF +typx3: TypeForm[int | str] = STR_TF +typx4: TypeForm[int | str] = OBJECT_TF # E: Incompatible types in assignment (expression has type "TypeForm[object]", variable has type "TypeForm[Union[int, str]]") +typx5: TypeForm[int | str] = ANY_TF # no error +typx6: TypeForm[int] = INT_OR_STR_TF # E: Incompatible types in assignment (expression has type "TypeForm[Union[int, str]]", variable has type "TypeForm[int]") +typx7: TypeForm[int] = INT_TF +typx8: TypeForm[int] = STR_TF # E: Incompatible types in assignment (expression has type "TypeForm[str]", variable has type "TypeForm[int]") +typx9: TypeForm[int] = OBJECT_TF # E: Incompatible types in assignment (expression has type "TypeForm[object]", variable has type "TypeForm[int]") +typx10: TypeForm[int] = ANY_TF # no error +[builtins fixtures/tuple.pyi] +[typing fixtures/typing-full.pyi] + +[case testTypeToTypeFormAssignability] +# flags: --python-version 3.14 --enable-incomplete-feature=TypeForm +# - Type[C] is assignable to TypeForm[T] iff C is assignable to T. +# - In particular Type[Any] is assignable to TypeForm[Any]. +from typing import Type, TypeForm +INT_T: Type[int] = int +STR_T: Type[str] = str +OBJECT_T: Type[object] = object +ANY_T: Type = object +reveal_type(ANY_T) # N: Revealed type is "Type[Any]" +typx1: TypeForm[int | str] = INT_T +typx2: TypeForm[int | str] = STR_T +typx3: TypeForm[int | str] = OBJECT_T # E: Incompatible types in assignment (expression has type "Type[object]", variable has type "TypeForm[Union[int, str]]") +typx4: TypeForm[int | str] = ANY_T # no error +typx5: TypeForm[int] = INT_T +typx6: TypeForm[int] = STR_T # E: Incompatible types in assignment (expression has type "Type[str]", variable has type "TypeForm[int]") +typx7: TypeForm[int] = OBJECT_T # E: Incompatible types in assignment (expression has type "Type[object]", variable has type "TypeForm[int]") +typx8: TypeForm[int] = ANY_T # no error +[builtins fixtures/tuple.pyi] +[typing fixtures/typing-full.pyi] + +[case testTypeFormToTypeAssignability] +# flags: --python-version 3.14 --enable-incomplete-feature=TypeForm +# - TypeForm[T] is NOT assignable to Type[C]. +# - In particular TypeForm[Any] is NOT assignable to Type[Any]. +from typing import Type, TypeForm +INT_OR_STR_TF: TypeForm[int | str] = int | str +INT_TF: TypeForm[int] = int +STR_TF: TypeForm[str] = str +OBJECT_TF: TypeForm[object] = object +ANY_TF: TypeForm = object +reveal_type(ANY_TF) # N: Revealed type is "TypeForm[Any]" +typ1: Type[int] = INT_OR_STR_TF # E: Incompatible types in assignment (expression has type "TypeForm[Union[int, str]]", variable has type "Type[int]") +typ2: Type[int] = INT_TF # E: Incompatible types in assignment (expression has type "TypeForm[int]", variable has type "Type[int]") +typ3: Type[int] = STR_TF # E: Incompatible types in assignment (expression has type "TypeForm[str]", variable has type "Type[int]") +typ4: Type[int] = OBJECT_TF # E: Incompatible types in assignment (expression has type "TypeForm[object]", variable has type "Type[int]") +typ5: Type[int] = ANY_TF # E: Incompatible types in assignment (expression has type "TypeForm[Any]", variable has type "Type[int]") +typ6: Type[object] = INT_OR_STR_TF # E: Incompatible types in assignment (expression has type "TypeForm[Union[int, str]]", variable has type "Type[object]") +typ7: Type[object] = INT_TF # E: Incompatible types in assignment (expression has type "TypeForm[int]", variable has type "Type[object]") +typ8: Type[object] = STR_TF # E: Incompatible types in assignment (expression has type "TypeForm[str]", variable has type "Type[object]") +typ9: Type[object] = OBJECT_TF # E: Incompatible types in assignment (expression has type "TypeForm[object]", variable has type "Type[object]") +typ10: Type[object] = ANY_TF # E: Incompatible types in assignment (expression has type "TypeForm[Any]", variable has type "Type[object]") +[builtins fixtures/tuple.pyi] +[typing fixtures/typing-full.pyi] + +# NOTE: This test doesn't involve TypeForm at all, but is still illustrative +# when compared with similarly structured TypeForm-related tests above. +[case testTypeToTypeAssignability] +# flags: --python-version 3.14 --enable-incomplete-feature=TypeForm +# - Type[C1] is assignable to Type[C2] iff C1 is assignable to C2. +# - In particular Type[Any] is assignable to Type[Any]. +from typing import Type +INT_T: Type[int] = int +STR_T: Type[str] = str +OBJECT_T: Type[object] = object +ANY_T: Type = object +reveal_type(ANY_T) # N: Revealed type is "Type[Any]" +typ1: Type[int] = INT_T +typ2: Type[int] = STR_T # E: Incompatible types in assignment (expression has type "Type[str]", variable has type "Type[int]") +typ3: Type[int] = OBJECT_T # E: Incompatible types in assignment (expression has type "Type[object]", variable has type "Type[int]") +typ4: Type[int] = ANY_T # no error +typ5: Type[object] = INT_T +typ6: Type[object] = STR_T +typ7: Type[object] = OBJECT_T +typ8: Type[object] = ANY_T # no error +[builtins fixtures/tuple.pyi] +[typing fixtures/typing-full.pyi] + +[case testTypeFormToObjectAssignability] +# flags: --python-version 3.14 --enable-incomplete-feature=TypeForm +# - TypeForm[T] is assignable to object and Any. +from typing import Any, TypeForm +INT_TF: TypeForm[int] = int +OBJECT_TF: TypeForm[object] = object +ANY_TF: TypeForm = object +reveal_type(ANY_TF) # N: Revealed type is "TypeForm[Any]" +obj1: object = INT_TF +obj2: object = OBJECT_TF +obj3: object = ANY_TF +any1: Any = INT_TF +any2: Any = OBJECT_TF +any3: Any = ANY_TF +[builtins fixtures/tuple.pyi] +[typing fixtures/typing-full.pyi] + + +-- Join (join_types) + +[case testTypeFormToTypeFormJoin] +# flags: --python-version 3.14 --enable-incomplete-feature=TypeForm +# - TypeForm[T1] join TypeForm[T2] == TypeForm[T1 join T2] +from typing import TypeForm +class AB: + pass +class A(AB): + pass +class B(AB): + pass +A_TF: TypeForm[A] = A +B_TF: TypeForm[B] = B +reveal_type([A_TF, B_TF][0]) # N: Revealed type is "TypeForm[__main__.AB]" +[builtins fixtures/tuple.pyi] +[typing fixtures/typing-full.pyi] + +[case testTypeToTypeFormJoin] +# flags: --python-version 3.14 --enable-incomplete-feature=TypeForm +# - TypeForm[T1] join Type[T2] == TypeForm[T1 join T2] +from typing import Type, TypeForm +class AB: + pass +class A(AB): + pass +class B(AB): + pass +A_T: Type[A] = A +B_TF: TypeForm[B] = B +reveal_type([A_T, B_TF][0]) # N: Revealed type is "TypeForm[__main__.AB]" +[builtins fixtures/tuple.pyi] +[typing fixtures/typing-full.pyi] + +[case testTypeFormToTypeJoin] +# flags: --python-version 3.14 --enable-incomplete-feature=TypeForm +# - TypeForm[T1] join Type[T2] == TypeForm[T1 join T2] +from typing import Type, TypeForm +class AB: + pass +class A(AB): + pass +class B(AB): + pass +A_TF: TypeForm[A] = A +B_T: Type[B] = B +reveal_type([A_TF, B_T][0]) # N: Revealed type is "TypeForm[__main__.AB]" +[builtins fixtures/tuple.pyi] +[typing fixtures/typing-full.pyi] + +# NOTE: This test doesn't involve TypeForm at all, but is still illustrative +# when compared with similarly structured TypeForm-related tests above. +[case testTypeToTypeJoin] +# flags: --python-version 3.14 --enable-incomplete-feature=TypeForm +# - Type[T1] join Type[T2] == Type[T1 join T2] +from typing import Type, TypeForm +class AB: + pass +class A(AB): + pass +class B(AB): + pass +A_T: Type[A] = A +B_T: Type[B] = B +reveal_type([A_T, B_T][0]) # N: Revealed type is "Type[__main__.AB]" +[builtins fixtures/tuple.pyi] +[typing fixtures/typing-full.pyi] + + +-- Meet (meet_types) + +[case testTypeFormToTypeFormMeet] +# flags: --python-version 3.14 --enable-incomplete-feature=TypeForm +# - TypeForm[T1] meet TypeForm[T2] == TypeForm[T1 meet T2] +from typing import Callable, TypeForm, TypeVar +class AB: + pass +class A(AB): + pass +class B(AB): + pass +class C(AB): + pass +T = TypeVar('T') +def f(x: Callable[[T, T], None]) -> T: pass # type: ignore[empty-body] +def g(x: TypeForm[A | B], y: TypeForm[B | C]) -> None: pass +reveal_type(f(g)) # N: Revealed type is "TypeForm[__main__.B]" +[builtins fixtures/tuple.pyi] +[typing fixtures/typing-full.pyi] + +[case testTypeToTypeFormMeet] +# flags: --python-version 3.14 --enable-incomplete-feature=TypeForm +# - TypeForm[T1] meet Type[T2] == Type[T1 meet T2] +from typing import Callable, Type, TypeForm, TypeVar +class AB: + pass +class A(AB): + pass +class B(AB): + pass +class C(AB): + pass +T = TypeVar('T') +def f(x: Callable[[T, T], None]) -> T: pass # type: ignore[empty-body] +def g(x: Type[B], y: TypeForm[B | C]) -> None: pass +reveal_type(f(g)) # N: Revealed type is "Type[__main__.B]" +[builtins fixtures/tuple.pyi] +[typing fixtures/typing-full.pyi] + +[case testTypeFormToTypeMeet] +# flags: --python-version 3.14 --enable-incomplete-feature=TypeForm +# - TypeForm[T1] meet Type[T2] == Type[T1 meet T2] +from typing import Callable, Type, TypeForm, TypeVar +class AB: + pass +class A(AB): + pass +class B(AB): + pass +class C(AB): + pass +T = TypeVar('T') +def f(x: Callable[[T, T], None]) -> T: pass # type: ignore[empty-body] +def g(x: TypeForm[A | B], y: Type[B]) -> None: pass +reveal_type(f(g)) # N: Revealed type is "Type[__main__.B]" +[builtins fixtures/tuple.pyi] +[typing fixtures/typing-full.pyi] + +# NOTE: This test doesn't involve TypeForm at all, but is still illustrative +# when compared with similarly structured TypeForm-related tests above. +[case testTypeToTypeMeet] +# flags: --python-version 3.14 --enable-incomplete-feature=TypeForm +# - Type[T1] meet Type[T2] == Type[T1 meet T2] +from typing import Callable, Type, TypedDict, TypeForm, TypeVar +class AB(TypedDict): + a: str + b: str +class BC(TypedDict): + b: str + c: str +T = TypeVar('T') +def f(x: Callable[[T, T], None]) -> T: pass # type: ignore[empty-body] +def g(x: Type[AB], y: Type[BC]) -> None: pass +reveal_type(f(g)) # N: Revealed type is "Type[TypedDict({'b': builtins.str, 'c': builtins.str, 'a': builtins.str})]" +[builtins fixtures/tuple.pyi] +[typing fixtures/typing-full.pyi] + + +-- TypeForm(...) Expression + +[case testTypeFormExpression] +# flags: --python-version 3.14 --enable-incomplete-feature=TypeForm +from typing import TypeForm +tf1 = TypeForm(int | str) +reveal_type(tf1) # N: Revealed type is "TypeForm[Union[builtins.int, builtins.str]]" +tf2 = TypeForm('int | str') +reveal_type(tf2) # N: Revealed type is "TypeForm[Union[builtins.int, builtins.str]]" +tf3: TypeForm = TypeForm(int | str) +reveal_type(tf3) # N: Revealed type is "TypeForm[Any]" +tf4: TypeForm = TypeForm(1) # E: Invalid type: try using Literal[1] instead? +tf5: TypeForm = TypeForm(int) | TypeForm(str) # E: Invalid self argument "TypeForm[int]" to attribute function "__or__" with type "Callable[[type, object], object]" \ + # E: Incompatible types in assignment (expression has type "object", variable has type "TypeForm[Any]") +tf6: TypeForm = TypeForm(TypeForm(int) | TypeForm(str)) # E: TypeForm argument is not a type +[builtins fixtures/tuple.pyi] +[typing fixtures/typing-full.pyi] + + +-- isinstance + +[case testTypeFormAndTypeIsinstance] +# flags: --python-version 3.14 --enable-incomplete-feature=TypeForm +from typing import TypeForm +typx: TypeForm[str] = str +if isinstance(typx, type): + reveal_type(typx) # N: Revealed type is "Type[builtins.str]" +else: + reveal_type(typx) # N: Revealed type is "TypeForm[builtins.str]" +[builtins fixtures/tuple.pyi] +[typing fixtures/typing-full.pyi] + + +-- Type Variables + +[case testLinkTypeFormToTypeFormWithTypeVariable] +# flags: --python-version 3.14 --enable-incomplete-feature=TypeForm +from typing import TypeForm, TypeVar +T = TypeVar('T') +def as_typeform(typx: TypeForm[T]) -> TypeForm[T]: + return typx +reveal_type(as_typeform(int | str)) # N: Revealed type is "TypeForm[Union[builtins.int, builtins.str]]" +[builtins fixtures/tuple.pyi] +[typing fixtures/typing-full.pyi] + +[case testLinkTypeFormToTypeWithTypeVariable] +# flags: --python-version 3.14 --enable-incomplete-feature=TypeForm +from typing import Type, TypeForm, TypeVar +T = TypeVar('T') +def as_type(typx: TypeForm[T]) -> Type[T] | None: + if isinstance(typx, type): + return typx + else: + return None +reveal_type(as_type(int | str)) # N: Revealed type is "Union[Type[builtins.int], Type[builtins.str], None]" +reveal_type(as_type(int)) # N: Revealed type is "Union[Type[builtins.int], None]" +[builtins fixtures/tuple.pyi] +[typing fixtures/typing-full.pyi] + +[case testLinkTypeFormToInstanceWithTypeVariable] +# flags: --python-version 3.14 --enable-incomplete-feature=TypeForm +from typing import TypeForm, TypeVar +T = TypeVar('T') +def as_instance(typx: TypeForm[T]) -> T | None: + if isinstance(typx, type): + return typx() + else: + return None +reveal_type(as_instance(int | str)) # N: Revealed type is "Union[builtins.int, builtins.str, None]" +reveal_type(as_instance(int)) # N: Revealed type is "Union[builtins.int, None]" +[builtins fixtures/tuple.pyi] +[typing fixtures/typing-full.pyi] + +[case testLinkTypeFormToTypeIsWithTypeVariable] +# flags: --python-version 3.14 --enable-incomplete-feature=TypeForm +from typing import TypeForm +from typing_extensions import TypeIs +def isassignable[T](value: object, typx: TypeForm[T]) -> TypeIs[T]: + raise BaseException() +count: int | str = 1 +if isassignable(count, int): + reveal_type(count) # N: Revealed type is "builtins.int" +else: + reveal_type(count) # N: Revealed type is "builtins.str" +[builtins fixtures/tuple.pyi] +[typing fixtures/typing-full.pyi] + +[case testLinkTypeFormToTypeGuardWithTypeVariable] +# flags: --python-version 3.14 --enable-incomplete-feature=TypeForm +from typing import TypeForm +from typing_extensions import TypeGuard +def isassignable[T](value: object, typx: TypeForm[T]) -> TypeGuard[T]: + raise BaseException() +count: int | str = 1 +if isassignable(count, int): + reveal_type(count) # N: Revealed type is "builtins.int" +else: + reveal_type(count) # N: Revealed type is "Union[builtins.int, builtins.str]" +[builtins fixtures/tuple.pyi] +[typing fixtures/typing-full.pyi] + + +-- Misc + +[case testTypeFormHasAllObjectAttributesAndMethods] +# flags: --python-version 3.14 --enable-incomplete-feature=TypeForm +from typing import TypeForm +typx: TypeForm[int | str] = int | str +print(typx.__class__) # OK +print(typx.__hash__()) # OK +obj: object = typx +[file builtins.py] +class object: + def __init__(self) -> None: pass + __class__: None + def __hash__(self) -> int: pass +def print(x): + raise BaseException() +class int: pass +class dict: pass +class str: pass +class type: pass +class tuple: pass +class ellipsis: pass +class BaseException: pass +class float: pass +[typing fixtures/typing-full.pyi] + +[case testQuotedTypeFormsAreRecognized] +# flags: --python-version 3.14 --enable-incomplete-feature=TypeForm +from typing import TypeForm +typx1: TypeForm[int | str] = 'int | str' # OK +typx2: TypeForm[int] = 'str' # E: Incompatible types in assignment (expression has type "TypeForm[str]", variable has type "TypeForm[int]") +[builtins fixtures/tuple.pyi] +[typing fixtures/typing-full.pyi] diff --git a/test-data/unit/check-union-or-syntax.test b/test-data/unit/check-union-or-syntax.test index 6250374ccbea..0b4dc4532fd0 100644 --- a/test-data/unit/check-union-or-syntax.test +++ b/test-data/unit/check-union-or-syntax.test @@ -108,8 +108,7 @@ b: X # E: Variable "__main__.X" is not valid as a type \ # flags: --python-version 3.9 from __future__ import annotations from typing import List -T = int | str # E: Invalid type alias: expression is not a valid type \ - # E: Unsupported left operand type for | ("Type[int]") +T = int | str # E: Invalid type alias: expression is not a valid type class C(List[int | str]): # E: Type expected within [...] \ # E: Invalid base class "List" pass diff --git a/test-data/unit/fixtures/dict.pyi b/test-data/unit/fixtures/dict.pyi index ed2287511161..b87255f5486a 100644 --- a/test-data/unit/fixtures/dict.pyi +++ b/test-data/unit/fixtures/dict.pyi @@ -19,7 +19,9 @@ class object: def __init__(self) -> None: pass def __eq__(self, other: object) -> bool: pass -class type: pass +class type: + # Real implementation returns UnionType + def __or__(self, value: object, /) -> object: pass class dict(Mapping[KT, VT]): @overload diff --git a/test-data/unit/fixtures/tuple.pyi b/test-data/unit/fixtures/tuple.pyi index d01cd0034d26..f5e21aa228c0 100644 --- a/test-data/unit/fixtures/tuple.pyi +++ b/test-data/unit/fixtures/tuple.pyi @@ -13,6 +13,8 @@ class object: class type: def __init__(self, *a: object) -> None: pass def __call__(self, *a: object) -> object: pass + # Real implementation returns UnionType + def __or__(self, value: object, /) -> object: pass class tuple(Sequence[_Tco], Generic[_Tco]): def __new__(cls: Type[_T], iterable: Iterable[_Tco] = ...) -> _T: ... def __iter__(self) -> Iterator[_Tco]: pass diff --git a/test-data/unit/fixtures/typing-full.pyi b/test-data/unit/fixtures/typing-full.pyi index 8e0116aab1c2..a081952d3236 100644 --- a/test-data/unit/fixtures/typing-full.pyi +++ b/test-data/unit/fixtures/typing-full.pyi @@ -30,6 +30,7 @@ Protocol = 0 Tuple = 0 _promote = 0 Type = 0 +TypeForm = 0 no_type_check = 0 ClassVar = 0 Final = 0 From 9dcfc23d13a615522b6db3153948fa1aadcf5ef0 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 16 Feb 2025 21:28:17 +0000 Subject: [PATCH 02/23] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- mypy/checker.py | 3 +-- mypy/checkexpr.py | 6 +++--- mypy/erasetype.py | 4 +++- mypy/meet.py | 9 ++------- mypy/nodes.py | 2 +- mypy/semanal.py | 17 ++++++++++------- mypy/type_visitor.py | 5 +---- mypy/typeanal.py | 8 +++++--- mypy/types.py | 16 ++++++++++++---- 9 files changed, 38 insertions(+), 32 deletions(-) diff --git a/mypy/checker.py b/mypy/checker.py index 9ca7d85f37a4..7c17cb20f999 100644 --- a/mypy/checker.py +++ b/mypy/checker.py @@ -7787,8 +7787,7 @@ def add_any_attribute_to_type(self, typ: Type, name: str) -> 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), - is_type_form=typ.is_type_form, + self.add_any_attribute_to_type(typ.item, name), is_type_form=typ.is_type_form ) if isinstance(typ, TypeVarType): return typ.copy_modified( diff --git a/mypy/checkexpr.py b/mypy/checkexpr.py index d6ab0142c59a..841359669d99 100644 --- a/mypy/checkexpr.py +++ b/mypy/checkexpr.py @@ -5947,9 +5947,9 @@ def accept( 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 is not None + isinstance(p_type_context, TypeType) + and p_type_context.is_type_form + and node.as_type is not None ): typ = TypeType.make_normalized( node.as_type, diff --git a/mypy/erasetype.py b/mypy/erasetype.py index 57d4e9f120a9..e4d083765a00 100644 --- a/mypy/erasetype.py +++ b/mypy/erasetype.py @@ -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, is_type_form=t.is_type_form) + 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") diff --git a/mypy/meet.py b/mypy/meet.py index 843451ba6584..b245f5ee9e4a 100644 --- a/mypy/meet.py +++ b/mypy/meet.py @@ -172,10 +172,7 @@ def narrow_declared_type(declared: Type, narrowed: Type) -> Type: # type object at least as narrow as Type[T] return narrow_declared_type( TypeType.make_normalized( - declared.item, - line=declared.line, - column=declared.column, - is_type_form=False, + declared.item, line=declared.line, column=declared.column, is_type_form=False ), original_narrowed, ) @@ -1090,9 +1087,7 @@ def visit_type_type(self, t: TypeType) -> ProperType: typ = self.meet(t.item, self.s.item) if not isinstance(typ, NoneType): typ = TypeType.make_normalized( - typ, - line=t.line, - is_type_form=self.s.is_type_form and t.is_type_form, + typ, line=t.line, is_type_form=self.s.is_type_form and t.is_type_form ) return typ elif isinstance(self.s, Instance) and self.s.type.fullname == "builtins.type": diff --git a/mypy/nodes.py b/mypy/nodes.py index 90c99152321b..47dac8c57158 100644 --- a/mypy/nodes.py +++ b/mypy/nodes.py @@ -205,7 +205,7 @@ class Expression(Node): # a different superclass with its own __slots__. A subclass in # Python is not allowed to have multiple superclasses that define # __slots__. - #__slots__ = ('as_type',) + # __slots__ = ('as_type',) # If this value expression can also be parsed as a valid type expression, # represents the type denoted by the type expression. diff --git a/mypy/semanal.py b/mypy/semanal.py index ebea25cdd10b..1940b61f8bf7 100644 --- a/mypy/semanal.py +++ b/mypy/semanal.py @@ -192,7 +192,7 @@ type_aliases_source_versions, typing_extensions_aliases, ) -from mypy.options import Options, TYPE_FORM +from mypy.options import TYPE_FORM, Options from mypy.patterns import ( AsPattern, ClassPattern, @@ -5799,9 +5799,7 @@ def visit_call_expr(self, expr: CallExpr) -> None: with self.allow_unbound_tvars_set(): for a in expr.args: a.accept(self) - elif refers_to_fullname( - expr.callee, ("typing.TypeForm", "typing_extensions.TypeForm") - ): + elif refers_to_fullname(expr.callee, ("typing.TypeForm", "typing_extensions.TypeForm")): # Special form TypeForm(...). if not self.check_fixed_args(expr, 1, "TypeForm"): return @@ -7620,15 +7618,19 @@ def visit_pass_stmt(self, o: PassStmt, /) -> None: def visit_singleton_pattern(self, o: SingletonPattern, /) -> None: return None - def try_parse_as_type_expression(self, maybe_type_expr: Expression) -> Type|None: + def try_parse_as_type_expression(self, maybe_type_expr: Expression) -> Type | None: """Try to parse maybe_type_expr as a type expression. If parsing fails return None and emit no errors.""" # Save SemanticAnalyzer state original_errors = self.errors # altered by fail() - original_num_incomplete_refs = self.num_incomplete_refs # altered by record_incomplete_ref() + original_num_incomplete_refs = ( + self.num_incomplete_refs + ) # altered by record_incomplete_ref() original_progress = self.progress # altered by defer() original_deferred = self.deferred # altered by defer() - original_deferral_debug_context_len = len(self.deferral_debug_context) # altered by defer() + original_deferral_debug_context_len = len( + self.deferral_debug_context + ) # altered by defer() self.errors = Errors(Options()) try: @@ -7649,6 +7651,7 @@ def try_parse_as_type_expression(self, maybe_type_expr: Expression) -> Type|None del self.deferral_debug_context[original_deferral_debug_context_len:] return t + def replace_implicit_first_type(sig: FunctionLike, new: Type) -> FunctionLike: if isinstance(sig, CallableType): if len(sig.arg_types) == 0: diff --git a/mypy/type_visitor.py b/mypy/type_visitor.py index 4d62b52df41a..ed4580140041 100644 --- a/mypy/type_visitor.py +++ b/mypy/type_visitor.py @@ -326,10 +326,7 @@ def visit_overloaded(self, t: Overloaded, /) -> Type: def visit_type_type(self, t: TypeType, /) -> Type: return TypeType.make_normalized( - t.item.accept(self), - line=t.line, - column=t.column, - is_type_form=t.is_type_form, + t.item.accept(self), line=t.line, column=t.column, is_type_form=t.is_type_form ) @abstractmethod diff --git a/mypy/typeanal.py b/mypy/typeanal.py index 48132d2988a6..d8554c5d003e 100644 --- a/mypy/typeanal.py +++ b/mypy/typeanal.py @@ -49,7 +49,7 @@ check_arg_names, get_nongen_builtins, ) -from mypy.options import INLINE_TYPEDDICT, Options, TYPE_FORM +from mypy.options import INLINE_TYPEDDICT, TYPE_FORM, Options from mypy.plugin import AnalyzeTypeContext, Plugin, TypeAnalyzerPluginInterface from mypy.semanal_shared import ( SemanticAnalyzerCoreInterface, @@ -1363,7 +1363,7 @@ def visit_typeddict_type(self, t: TypedDictType) -> Type: try: fallback = self.named_type("typing._TypedDict") except AssertionError as e: - if str(e) == 'typing._TypedDict': + if str(e) == "typing._TypedDict": # Can happen when running mypy tests, typing._TypedDict # is not defined by typing.pyi stubs, and # try_parse_as_type_expression() is called on an dict @@ -1453,7 +1453,9 @@ def visit_ellipsis_type(self, t: EllipsisType) -> Type: return AnyType(TypeOfAny.from_error) def visit_type_type(self, t: TypeType) -> Type: - return TypeType.make_normalized(self.anal_type(t.item), line=t.line, is_type_form=t.is_type_form) + return TypeType.make_normalized( + self.anal_type(t.item), line=t.line, is_type_form=t.is_type_form + ) def visit_placeholder_type(self, t: PlaceholderType) -> Type: n = ( diff --git a/mypy/types.py b/mypy/types.py index 77914a08b92a..bd9124cb4526 100644 --- a/mypy/types.py +++ b/mypy/types.py @@ -3089,7 +3089,7 @@ class TypeType(ProperType): assumption). """ - __slots__ = ("item", "is_type_form",) + __slots__ = ("item", "is_type_form") # This can't be everything, but it can be a class reference, # a generic class instance, a union, Any, a type variable... @@ -3115,7 +3115,9 @@ def __init__( self.is_type_form = is_type_form @staticmethod - def make_normalized(item: Type, *, line: int = -1, column: int = -1, is_type_form: bool = False) -> ProperType: + def make_normalized( + item: Type, *, line: int = -1, column: int = -1, is_type_form: bool = False + ) -> ProperType: item = get_proper_type(item) if is_type_form: # Don't convert TypeForm[X | Y] to (TypeForm[X] | TypeForm[Y]) @@ -3141,12 +3143,18 @@ def __eq__(self, other: object) -> bool: return self.item == other.item def serialize(self) -> JsonDict: - return {".class": "TypeType", "item": self.item.serialize(), "is_type_form": self.is_type_form} + return { + ".class": "TypeType", + "item": self.item.serialize(), + "is_type_form": self.is_type_form, + } @classmethod def deserialize(cls, data: JsonDict) -> Type: assert data[".class"] == "TypeType" - return TypeType.make_normalized(deserialize_type(data["item"]), is_type_form=data["is_type_form"]) + return TypeType.make_normalized( + deserialize_type(data["item"]), is_type_form=data["is_type_form"] + ) class PlaceholderType(ProperType): From 5cb1da59a2a1518d89defa0e2aa2f9abff6b48c2 Mon Sep 17 00:00:00 2001 From: David Foster Date: Wed, 19 Feb 2025 09:02:35 -0500 Subject: [PATCH 03/23] Eliminate use of Type Parameter Syntax, which only works on Python >= 3.12 --- INPUTS/required/import_typing_notrequired.py | 5 ++ INPUTS/required/import_typing_notrequired2.py | 5 ++ INPUTS/required/required.py | 85 +++++++++++++++++++ INPUTS/required/required_py3_11.py | 8 ++ INPUTS/test_typeform.py | 38 +++++++++ INPUTS/typeform_assign_to_alias.py | 5 ++ INPUTS/typeform_assignment.py | 9 ++ INPUTS/typeform_call.py | 5 ++ INPUTS/typeform_return.py | 5 ++ test-data/unit/check-typeform.test | 10 ++- 10 files changed, 171 insertions(+), 4 deletions(-) create mode 100644 INPUTS/required/import_typing_notrequired.py create mode 100644 INPUTS/required/import_typing_notrequired2.py create mode 100644 INPUTS/required/required.py create mode 100644 INPUTS/required/required_py3_11.py create mode 100644 INPUTS/test_typeform.py create mode 100644 INPUTS/typeform_assign_to_alias.py create mode 100644 INPUTS/typeform_assignment.py create mode 100644 INPUTS/typeform_call.py create mode 100644 INPUTS/typeform_return.py diff --git a/INPUTS/required/import_typing_notrequired.py b/INPUTS/required/import_typing_notrequired.py new file mode 100644 index 000000000000..852928f26ecb --- /dev/null +++ b/INPUTS/required/import_typing_notrequired.py @@ -0,0 +1,5 @@ +import typing +import typing_extensions + +assert typing.Required is typing_extensions.Required +assert typing.NotRequired is typing_extensions.NotRequired diff --git a/INPUTS/required/import_typing_notrequired2.py b/INPUTS/required/import_typing_notrequired2.py new file mode 100644 index 000000000000..852928f26ecb --- /dev/null +++ b/INPUTS/required/import_typing_notrequired2.py @@ -0,0 +1,5 @@ +import typing +import typing_extensions + +assert typing.Required is typing_extensions.Required +assert typing.NotRequired is typing_extensions.NotRequired diff --git a/INPUTS/required/required.py b/INPUTS/required/required.py new file mode 100644 index 000000000000..a2f83d39ebed --- /dev/null +++ b/INPUTS/required/required.py @@ -0,0 +1,85 @@ +from typing import TypedDict, Tuple, Union +from typing_extensions import NotRequired, Required + + +# --- Class Based TypedDict --- +class Movie(TypedDict, total=False): + title: Required[str] # 5 + year: int + + +m = Movie(title='The Matrix', year=1999) +# m = Movie() +print(m) + + +# --- Assignment Based TypedDict --- +Movie2 = TypedDict('Movie2', { + 'title': Required[str], + 'year': int, +}, total=False) + + +m2 = Movie2(title='The Matrix Reloaded', year=2003) +# m2 = Movie2() +print(m2) + + +# --- Required[] outside of TypedDict (error) --- +x: int = 5 +# x: Required[int] = 5 + + +# --- Required[] inside other Required[] (error) --- +''' +Movie3 = TypedDict('Movie3', { + 'title': Required[Union[ + Required[str], + bytes + ]], + 'year': int, +}, total=False) +''' + + +# --- Required[] used within TypedDict but not at top level (error) --- +''' +Movie4 = TypedDict('Movie4', { + 'title': Union[ + Required[str], + bytes + ], + 'year': int, +}, total=False) +Movie5 = TypedDict('Movie5', { + 'title': Tuple[ + Required[str], + bytes + ], + 'year': int, +}, total=False) +''' + + +# ============================================================================== +# --- Class Based TypedDict --- +class MovieN(TypedDict): + title: str + year: NotRequired[int] + + +m = MovieN(title='The Matrix', year=1999) +# m = MovieN() +print(m) + + +# --- Assignment Based TypedDict --- +MovieN2 = TypedDict('MovieN2', { + 'title': str, + 'year': NotRequired[int], +}) + + +m2 = MovieN2(title='The Matrix Reloaded', year=2003) +# m2 = MovieN2() +print(m2) \ No newline at end of file diff --git a/INPUTS/required/required_py3_11.py b/INPUTS/required/required_py3_11.py new file mode 100644 index 000000000000..7878158c750c --- /dev/null +++ b/INPUTS/required/required_py3_11.py @@ -0,0 +1,8 @@ +import typing + +class Movie(typing.TypedDict): + title: str + year: typing.NotRequired[int] + +m = Movie(title='The Matrix') +print(m) diff --git a/INPUTS/test_typeform.py b/INPUTS/test_typeform.py new file mode 100644 index 000000000000..b64ac9e55c0c --- /dev/null +++ b/INPUTS/test_typeform.py @@ -0,0 +1,38 @@ +from typing import Any, Callable, cast, Never, NoReturn, Optional, reveal_type, Type, TypeVar, TypedDict +from typing_extensions import TypeForm, TypeGuard + +dict_with_typx_keys: dict[TypeForm, int] = { + int | str: 1, + str | None: 2, +} +dict_with_typx_keys[int | str] += 1 + +#typx1: TypeForm[int | str] = 'int | str' # OK +#typx2: TypeForm[int] = 'str' # E: Incompatible types in assignment (expression has type "TypeForm[str]", variable has type "TypeForm[int]") + +''' +from typing import Any + +T = TypeVar('T') + +def as_typeform(typx: TypeForm[T]) -> TypeForm[T]: + return typx + +def as_type(typx: TypeForm[T]) -> Type[T] | None: + if isinstance(typx, type): + return typx + else: + return None + +def as_instance(typx: TypeForm[T]) -> T | None: + if isinstance(typx, type): + return typx() + else: + return None + +reveal_type(as_typeform(int | str)) # actual=TypeForm[Never], expect=TypeForm[int | str] +reveal_type(as_type(int | str)) +reveal_type(as_type(int)) +reveal_type(as_instance(int | str)) +reveal_type(as_instance(int)) +''' diff --git a/INPUTS/typeform_assign_to_alias.py b/INPUTS/typeform_assign_to_alias.py new file mode 100644 index 000000000000..6a52f987a4f8 --- /dev/null +++ b/INPUTS/typeform_assign_to_alias.py @@ -0,0 +1,5 @@ +from typing import TypeAlias, reveal_type + +alias: TypeAlias = int | None +reveal_type(alias) + diff --git a/INPUTS/typeform_assignment.py b/INPUTS/typeform_assignment.py new file mode 100644 index 000000000000..bf85195806a8 --- /dev/null +++ b/INPUTS/typeform_assignment.py @@ -0,0 +1,9 @@ +typ: type +typ = int + +# E: Incompatible types in assignment (expression has type "UnionType", variable has type "type") [assignment] +typ = str | None + +#from typing_extensions import TypeExpr as TypeForm +#typx: TypeForm +#typx = int | None diff --git a/INPUTS/typeform_call.py b/INPUTS/typeform_call.py new file mode 100644 index 000000000000..1bd756960ae5 --- /dev/null +++ b/INPUTS/typeform_call.py @@ -0,0 +1,5 @@ +def expect_type(typ: type) -> None: + pass + +# E: Argument 1 to "expect_type" has incompatible type "UnionType"; expected "type" [arg-type] +expect_type(str | None) diff --git a/INPUTS/typeform_return.py b/INPUTS/typeform_return.py new file mode 100644 index 000000000000..67690634b8e4 --- /dev/null +++ b/INPUTS/typeform_return.py @@ -0,0 +1,5 @@ +def return_type() -> type: + # E: Incompatible return value type (got "UnionType", expected "type") [return-value] + return str | None + +return_type() diff --git a/test-data/unit/check-typeform.test b/test-data/unit/check-typeform.test index f57f3832dd2f..eaef44137b3f 100644 --- a/test-data/unit/check-typeform.test +++ b/test-data/unit/check-typeform.test @@ -515,9 +515,10 @@ reveal_type(as_instance(int)) # N: Revealed type is "Union[builtins.int, None]" [case testLinkTypeFormToTypeIsWithTypeVariable] # flags: --python-version 3.14 --enable-incomplete-feature=TypeForm -from typing import TypeForm +from typing import TypeForm, TypeVar from typing_extensions import TypeIs -def isassignable[T](value: object, typx: TypeForm[T]) -> TypeIs[T]: +T = TypeVar('T') +def isassignable(value: object, typx: TypeForm[T]) -> TypeIs[T]: raise BaseException() count: int | str = 1 if isassignable(count, int): @@ -529,9 +530,10 @@ else: [case testLinkTypeFormToTypeGuardWithTypeVariable] # flags: --python-version 3.14 --enable-incomplete-feature=TypeForm -from typing import TypeForm +from typing import TypeForm, TypeVar from typing_extensions import TypeGuard -def isassignable[T](value: object, typx: TypeForm[T]) -> TypeGuard[T]: +T = TypeVar('T') +def isassignable(value: object, typx: TypeForm[T]) -> TypeGuard[T]: raise BaseException() count: int | str = 1 if isassignable(count, int): From 3ece2080ac31f02c10f8ab7ce693e6a52f1766c3 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 19 Feb 2025 14:05:18 +0000 Subject: [PATCH 04/23] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- INPUTS/required/required.py | 34 ++++++++++++++---------------- INPUTS/required/required_py3_11.py | 4 +++- INPUTS/test_typeform.py | 16 ++++++-------- INPUTS/typeform_assign_to_alias.py | 1 - INPUTS/typeform_assignment.py | 6 +++--- INPUTS/typeform_call.py | 1 + INPUTS/typeform_return.py | 1 + 7 files changed, 30 insertions(+), 33 deletions(-) diff --git a/INPUTS/required/required.py b/INPUTS/required/required.py index a2f83d39ebed..c7fdecd63c43 100644 --- a/INPUTS/required/required.py +++ b/INPUTS/required/required.py @@ -1,4 +1,4 @@ -from typing import TypedDict, Tuple, Union +from typing import TypedDict from typing_extensions import NotRequired, Required @@ -8,19 +8,18 @@ class Movie(TypedDict, total=False): year: int -m = Movie(title='The Matrix', year=1999) +m = Movie(title="The Matrix", year=1999) # m = Movie() print(m) # --- Assignment Based TypedDict --- -Movie2 = TypedDict('Movie2', { - 'title': Required[str], - 'year': int, -}, total=False) +class Movie2(TypedDict, total=False): + title: Required[str] + year: int -m2 = Movie2(title='The Matrix Reloaded', year=2003) +m2 = Movie2(title="The Matrix Reloaded", year=2003) # m2 = Movie2() print(m2) @@ -31,7 +30,7 @@ class Movie(TypedDict, total=False): # --- Required[] inside other Required[] (error) --- -''' +""" Movie3 = TypedDict('Movie3', { 'title': Required[Union[ Required[str], @@ -39,11 +38,11 @@ class Movie(TypedDict, total=False): ]], 'year': int, }, total=False) -''' +""" # --- Required[] used within TypedDict but not at top level (error) --- -''' +""" Movie4 = TypedDict('Movie4', { 'title': Union[ Required[str], @@ -58,7 +57,7 @@ class Movie(TypedDict, total=False): ], 'year': int, }, total=False) -''' +""" # ============================================================================== @@ -68,18 +67,17 @@ class MovieN(TypedDict): year: NotRequired[int] -m = MovieN(title='The Matrix', year=1999) +m = MovieN(title="The Matrix", year=1999) # m = MovieN() print(m) # --- Assignment Based TypedDict --- -MovieN2 = TypedDict('MovieN2', { - 'title': str, - 'year': NotRequired[int], -}) +class MovieN2(TypedDict): + title: str + year: NotRequired[int] -m2 = MovieN2(title='The Matrix Reloaded', year=2003) +m2 = MovieN2(title="The Matrix Reloaded", year=2003) # m2 = MovieN2() -print(m2) \ No newline at end of file +print(m2) diff --git a/INPUTS/required/required_py3_11.py b/INPUTS/required/required_py3_11.py index 7878158c750c..f8e52b1b83e6 100644 --- a/INPUTS/required/required_py3_11.py +++ b/INPUTS/required/required_py3_11.py @@ -1,8 +1,10 @@ import typing + class Movie(typing.TypedDict): title: str year: typing.NotRequired[int] -m = Movie(title='The Matrix') + +m = Movie(title="The Matrix") print(m) diff --git a/INPUTS/test_typeform.py b/INPUTS/test_typeform.py index b64ac9e55c0c..914b4a60f822 100644 --- a/INPUTS/test_typeform.py +++ b/INPUTS/test_typeform.py @@ -1,16 +1,12 @@ -from typing import Any, Callable, cast, Never, NoReturn, Optional, reveal_type, Type, TypeVar, TypedDict -from typing_extensions import TypeForm, TypeGuard +from typing_extensions import TypeForm -dict_with_typx_keys: dict[TypeForm, int] = { - int | str: 1, - str | None: 2, -} +dict_with_typx_keys: dict[TypeForm, int] = {int | str: 1, str | None: 2} dict_with_typx_keys[int | str] += 1 -#typx1: TypeForm[int | str] = 'int | str' # OK -#typx2: TypeForm[int] = 'str' # E: Incompatible types in assignment (expression has type "TypeForm[str]", variable has type "TypeForm[int]") +# typx1: TypeForm[int | str] = 'int | str' # OK +# typx2: TypeForm[int] = 'str' # E: Incompatible types in assignment (expression has type "TypeForm[str]", variable has type "TypeForm[int]") -''' +""" from typing import Any T = TypeVar('T') @@ -35,4 +31,4 @@ def as_instance(typx: TypeForm[T]) -> T | None: reveal_type(as_type(int)) reveal_type(as_instance(int | str)) reveal_type(as_instance(int)) -''' +""" diff --git a/INPUTS/typeform_assign_to_alias.py b/INPUTS/typeform_assign_to_alias.py index 6a52f987a4f8..e75ab938ca7a 100644 --- a/INPUTS/typeform_assign_to_alias.py +++ b/INPUTS/typeform_assign_to_alias.py @@ -2,4 +2,3 @@ alias: TypeAlias = int | None reveal_type(alias) - diff --git a/INPUTS/typeform_assignment.py b/INPUTS/typeform_assignment.py index bf85195806a8..746af6da6af7 100644 --- a/INPUTS/typeform_assignment.py +++ b/INPUTS/typeform_assignment.py @@ -4,6 +4,6 @@ # E: Incompatible types in assignment (expression has type "UnionType", variable has type "type") [assignment] typ = str | None -#from typing_extensions import TypeExpr as TypeForm -#typx: TypeForm -#typx = int | None +# from typing_extensions import TypeExpr as TypeForm +# typx: TypeForm +# typx = int | None diff --git a/INPUTS/typeform_call.py b/INPUTS/typeform_call.py index 1bd756960ae5..cac7de03b48b 100644 --- a/INPUTS/typeform_call.py +++ b/INPUTS/typeform_call.py @@ -1,5 +1,6 @@ def expect_type(typ: type) -> None: pass + # E: Argument 1 to "expect_type" has incompatible type "UnionType"; expected "type" [arg-type] expect_type(str | None) diff --git a/INPUTS/typeform_return.py b/INPUTS/typeform_return.py index 67690634b8e4..a542d266aee6 100644 --- a/INPUTS/typeform_return.py +++ b/INPUTS/typeform_return.py @@ -2,4 +2,5 @@ def return_type() -> type: # E: Incompatible return value type (got "UnionType", expected "type") [return-value] return str | None + return_type() From 799bc489cfafbc128efb2ed3b015696f3476c7d1 Mon Sep 17 00:00:00 2001 From: David Foster Date: Wed, 19 Feb 2025 09:07:14 -0500 Subject: [PATCH 05/23] Remove INPUTS directory --- INPUTS/required/import_typing_notrequired.py | 5 -- INPUTS/required/import_typing_notrequired2.py | 5 -- INPUTS/required/required.py | 83 ------------------- INPUTS/required/required_py3_11.py | 10 --- INPUTS/test_typeform.py | 34 -------- INPUTS/typeform_assign_to_alias.py | 4 - INPUTS/typeform_assignment.py | 9 -- INPUTS/typeform_call.py | 6 -- INPUTS/typeform_return.py | 6 -- 9 files changed, 162 deletions(-) delete mode 100644 INPUTS/required/import_typing_notrequired.py delete mode 100644 INPUTS/required/import_typing_notrequired2.py delete mode 100644 INPUTS/required/required.py delete mode 100644 INPUTS/required/required_py3_11.py delete mode 100644 INPUTS/test_typeform.py delete mode 100644 INPUTS/typeform_assign_to_alias.py delete mode 100644 INPUTS/typeform_assignment.py delete mode 100644 INPUTS/typeform_call.py delete mode 100644 INPUTS/typeform_return.py diff --git a/INPUTS/required/import_typing_notrequired.py b/INPUTS/required/import_typing_notrequired.py deleted file mode 100644 index 852928f26ecb..000000000000 --- a/INPUTS/required/import_typing_notrequired.py +++ /dev/null @@ -1,5 +0,0 @@ -import typing -import typing_extensions - -assert typing.Required is typing_extensions.Required -assert typing.NotRequired is typing_extensions.NotRequired diff --git a/INPUTS/required/import_typing_notrequired2.py b/INPUTS/required/import_typing_notrequired2.py deleted file mode 100644 index 852928f26ecb..000000000000 --- a/INPUTS/required/import_typing_notrequired2.py +++ /dev/null @@ -1,5 +0,0 @@ -import typing -import typing_extensions - -assert typing.Required is typing_extensions.Required -assert typing.NotRequired is typing_extensions.NotRequired diff --git a/INPUTS/required/required.py b/INPUTS/required/required.py deleted file mode 100644 index c7fdecd63c43..000000000000 --- a/INPUTS/required/required.py +++ /dev/null @@ -1,83 +0,0 @@ -from typing import TypedDict -from typing_extensions import NotRequired, Required - - -# --- Class Based TypedDict --- -class Movie(TypedDict, total=False): - title: Required[str] # 5 - year: int - - -m = Movie(title="The Matrix", year=1999) -# m = Movie() -print(m) - - -# --- Assignment Based TypedDict --- -class Movie2(TypedDict, total=False): - title: Required[str] - year: int - - -m2 = Movie2(title="The Matrix Reloaded", year=2003) -# m2 = Movie2() -print(m2) - - -# --- Required[] outside of TypedDict (error) --- -x: int = 5 -# x: Required[int] = 5 - - -# --- Required[] inside other Required[] (error) --- -""" -Movie3 = TypedDict('Movie3', { - 'title': Required[Union[ - Required[str], - bytes - ]], - 'year': int, -}, total=False) -""" - - -# --- Required[] used within TypedDict but not at top level (error) --- -""" -Movie4 = TypedDict('Movie4', { - 'title': Union[ - Required[str], - bytes - ], - 'year': int, -}, total=False) -Movie5 = TypedDict('Movie5', { - 'title': Tuple[ - Required[str], - bytes - ], - 'year': int, -}, total=False) -""" - - -# ============================================================================== -# --- Class Based TypedDict --- -class MovieN(TypedDict): - title: str - year: NotRequired[int] - - -m = MovieN(title="The Matrix", year=1999) -# m = MovieN() -print(m) - - -# --- Assignment Based TypedDict --- -class MovieN2(TypedDict): - title: str - year: NotRequired[int] - - -m2 = MovieN2(title="The Matrix Reloaded", year=2003) -# m2 = MovieN2() -print(m2) diff --git a/INPUTS/required/required_py3_11.py b/INPUTS/required/required_py3_11.py deleted file mode 100644 index f8e52b1b83e6..000000000000 --- a/INPUTS/required/required_py3_11.py +++ /dev/null @@ -1,10 +0,0 @@ -import typing - - -class Movie(typing.TypedDict): - title: str - year: typing.NotRequired[int] - - -m = Movie(title="The Matrix") -print(m) diff --git a/INPUTS/test_typeform.py b/INPUTS/test_typeform.py deleted file mode 100644 index 914b4a60f822..000000000000 --- a/INPUTS/test_typeform.py +++ /dev/null @@ -1,34 +0,0 @@ -from typing_extensions import TypeForm - -dict_with_typx_keys: dict[TypeForm, int] = {int | str: 1, str | None: 2} -dict_with_typx_keys[int | str] += 1 - -# typx1: TypeForm[int | str] = 'int | str' # OK -# typx2: TypeForm[int] = 'str' # E: Incompatible types in assignment (expression has type "TypeForm[str]", variable has type "TypeForm[int]") - -""" -from typing import Any - -T = TypeVar('T') - -def as_typeform(typx: TypeForm[T]) -> TypeForm[T]: - return typx - -def as_type(typx: TypeForm[T]) -> Type[T] | None: - if isinstance(typx, type): - return typx - else: - return None - -def as_instance(typx: TypeForm[T]) -> T | None: - if isinstance(typx, type): - return typx() - else: - return None - -reveal_type(as_typeform(int | str)) # actual=TypeForm[Never], expect=TypeForm[int | str] -reveal_type(as_type(int | str)) -reveal_type(as_type(int)) -reveal_type(as_instance(int | str)) -reveal_type(as_instance(int)) -""" diff --git a/INPUTS/typeform_assign_to_alias.py b/INPUTS/typeform_assign_to_alias.py deleted file mode 100644 index e75ab938ca7a..000000000000 --- a/INPUTS/typeform_assign_to_alias.py +++ /dev/null @@ -1,4 +0,0 @@ -from typing import TypeAlias, reveal_type - -alias: TypeAlias = int | None -reveal_type(alias) diff --git a/INPUTS/typeform_assignment.py b/INPUTS/typeform_assignment.py deleted file mode 100644 index 746af6da6af7..000000000000 --- a/INPUTS/typeform_assignment.py +++ /dev/null @@ -1,9 +0,0 @@ -typ: type -typ = int - -# E: Incompatible types in assignment (expression has type "UnionType", variable has type "type") [assignment] -typ = str | None - -# from typing_extensions import TypeExpr as TypeForm -# typx: TypeForm -# typx = int | None diff --git a/INPUTS/typeform_call.py b/INPUTS/typeform_call.py deleted file mode 100644 index cac7de03b48b..000000000000 --- a/INPUTS/typeform_call.py +++ /dev/null @@ -1,6 +0,0 @@ -def expect_type(typ: type) -> None: - pass - - -# E: Argument 1 to "expect_type" has incompatible type "UnionType"; expected "type" [arg-type] -expect_type(str | None) diff --git a/INPUTS/typeform_return.py b/INPUTS/typeform_return.py deleted file mode 100644 index a542d266aee6..000000000000 --- a/INPUTS/typeform_return.py +++ /dev/null @@ -1,6 +0,0 @@ -def return_type() -> type: - # E: Incompatible return value type (got "UnionType", expected "type") [return-value] - return str | None - - -return_type() From 6bda848fa51ed134187a6a0caa7cd644e007ed6e Mon Sep 17 00:00:00 2001 From: David Foster Date: Thu, 20 Feb 2025 09:42:35 -0500 Subject: [PATCH 06/23] WIP: Don't declare attributes on Expression to avoid confusing mypyc --- mypy/checkexpr.py | 2 ++ mypy/nodes.py | 25 ++++++++++++------------- mypy/semanal.py | 23 ++++++++++++++++------- mypy/typeanal.py | 13 +------------ 4 files changed, 31 insertions(+), 32 deletions(-) diff --git a/mypy/checkexpr.py b/mypy/checkexpr.py index 841359669d99..1df38cac6f7c 100644 --- a/mypy/checkexpr.py +++ b/mypy/checkexpr.py @@ -71,6 +71,7 @@ LambdaExpr, ListComprehension, ListExpr, + MaybeTypeExpression, MemberExpr, MypyFile, NamedTupleExpr, @@ -5949,6 +5950,7 @@ def accept( elif ( isinstance(p_type_context, TypeType) and p_type_context.is_type_form + and isinstance(node, MaybeTypeExpression) and node.as_type is not None ): typ = TypeType.make_normalized( diff --git a/mypy/nodes.py b/mypy/nodes.py index 47dac8c57158..7883759e7259 100644 --- a/mypy/nodes.py +++ b/mypy/nodes.py @@ -201,19 +201,7 @@ def accept(self, visitor: StatementVisitor[T]) -> T: class Expression(Node): """An expression node.""" - # NOTE: Cannot use __slots__ because some subclasses also inherit from - # a different superclass with its own __slots__. A subclass in - # Python is not allowed to have multiple superclasses that define - # __slots__. - # __slots__ = ('as_type',) - - # If this value expression can also be parsed as a valid type expression, - # represents the type denoted by the type expression. - as_type: mypy.types.Type | None - - def __init__(self, *args: Any, **kwargs: Any) -> None: - super().__init__(*args, **kwargs) - self.as_type = None + __slots__ = () def accept(self, visitor: ExpressionVisitor[T]) -> T: raise RuntimeError("Not implemented", type(self)) @@ -2110,6 +2098,7 @@ class OpExpr(Expression): "right_always", "right_unreachable", "analyzed", + "as_type", ) __match_args__ = ("left", "op", "right") @@ -2125,6 +2114,9 @@ class OpExpr(Expression): right_unreachable: bool # Used for expressions that represent a type "X | Y" in some contexts analyzed: TypeAliasExpr | None + # If this value expression can also be parsed as a valid type expression, + # represents the type denoted by the type expression. + as_type: mypy.types.Type | None def __init__( self, op: str, left: Expression, right: Expression, analyzed: TypeAliasExpr | None = None @@ -2137,11 +2129,18 @@ def __init__( self.right_always = False self.right_unreachable = False self.analyzed = analyzed + self.as_type = None def accept(self, visitor: ExpressionVisitor[T]) -> T: return visitor.visit_op_expr(self) +# Expression subtypes that could represent the root of a valid type expression. +# Always contains an "as_type" attribute. +# TODO: Make this into a Protocol if mypyc is OK with that. +MaybeTypeExpression = OpExpr + + class ComparisonExpr(Expression): """Comparison expression (e.g. a < b > c < d).""" diff --git a/mypy/semanal.py b/mypy/semanal.py index 1940b61f8bf7..cb7ee0a28df3 100644 --- a/mypy/semanal.py +++ b/mypy/semanal.py @@ -136,6 +136,7 @@ ListExpr, Lvalue, MatchStmt, + MaybeTypeExpression, MemberExpr, MypyFile, NamedTupleExpr, @@ -3541,7 +3542,7 @@ def analyze_lvalues(self, s: AssignmentStmt) -> None: def analyze_rvalue_as_type_form(self, s: AssignmentStmt) -> None: if TYPE_FORM in self.options.enable_incomplete_feature: - s.rvalue.as_type = self.try_parse_as_type_expression(s.rvalue) + self.try_parse_as_type_expression(s.rvalue) def apply_dynamic_class_hook(self, s: AssignmentStmt) -> None: if not isinstance(s.rvalue, CallExpr): @@ -5278,7 +5279,7 @@ def visit_return_stmt(self, s: ReturnStmt) -> None: if s.expr: s.expr.accept(self) if TYPE_FORM in self.options.enable_incomplete_feature: - s.expr.as_type = self.try_parse_as_type_expression(s.expr) + self.try_parse_as_type_expression(s.expr) def visit_raise_stmt(self, s: RaiseStmt) -> None: self.statement = s @@ -5823,7 +5824,7 @@ def visit_call_expr(self, expr: CallExpr) -> None: for a in expr.args: a.accept(self) if calculate_type_forms: - a.as_type = self.try_parse_as_type_expression(a) + self.try_parse_as_type_expression(a) if ( isinstance(expr.callee, MemberExpr) @@ -7618,9 +7619,16 @@ def visit_pass_stmt(self, o: PassStmt, /) -> None: def visit_singleton_pattern(self, o: SingletonPattern, /) -> None: return None - def try_parse_as_type_expression(self, maybe_type_expr: Expression) -> Type | None: - """Try to parse maybe_type_expr as a type expression. - If parsing fails return None and emit no errors.""" + def try_parse_as_type_expression(self, maybe_type_expr: Expression) -> None: + """Try to parse a value Expression as a type expression. + If success then annotate the Expression with the type that it spells. + If fails then emit no errors and take no further action. + + A value expression that is parsable as a type expression may be used + where a TypeForm is expected to represent the spelled type.""" + if not isinstance(maybe_type_expr, MaybeTypeExpression): + return + # Save SemanticAnalyzer state original_errors = self.errors # altered by fail() original_num_incomplete_refs = ( @@ -7649,7 +7657,8 @@ def try_parse_as_type_expression(self, maybe_type_expr: Expression) -> Type | No self.progress = original_progress self.deferred = original_deferred del self.deferral_debug_context[original_deferral_debug_context_len:] - return t + + maybe_type_expr.as_type = t def replace_implicit_first_type(sig: FunctionLike, new: Type) -> FunctionLike: diff --git a/mypy/typeanal.py b/mypy/typeanal.py index d8554c5d003e..616c58213c7f 100644 --- a/mypy/typeanal.py +++ b/mypy/typeanal.py @@ -1360,18 +1360,7 @@ def visit_typeddict_type(self, t: TypedDictType) -> Type: t, ) required_keys = req_keys - try: - fallback = self.named_type("typing._TypedDict") - except AssertionError as e: - if str(e) == "typing._TypedDict": - # Can happen when running mypy tests, typing._TypedDict - # is not defined by typing.pyi stubs, and - # try_parse_as_type_expression() is called on an dict - # expression that looks like an inline TypedDict type. - self.fail("Internal error: typing._TypedDict not found", t) - fallback = self.named_type("builtins.object") - else: - raise + fallback = self.named_type("typing._TypedDict") for typ in t.extra_items_from: analyzed = self.analyze_type(typ) p_analyzed = get_proper_type(analyzed) From 9df0e6b9165f54c21d7e703a9d0c42883e4547d4 Mon Sep 17 00:00:00 2001 From: David Foster Date: Thu, 20 Feb 2025 20:52:16 -0500 Subject: [PATCH 07/23] SQ -> WIP: Don't declare attributes on Expression to avoid confusing mypyc Recognize the remaining subtypes of MaybeTypeExpression. --- mypy/nodes.py | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/mypy/nodes.py b/mypy/nodes.py index 7883759e7259..b0a1ec527fd8 100644 --- a/mypy/nodes.py +++ b/mypy/nodes.py @@ -1716,15 +1716,19 @@ def accept(self, visitor: ExpressionVisitor[T]) -> T: class StrExpr(Expression): """String literal""" - __slots__ = ("value",) + __slots__ = ("value", "as_type") __match_args__ = ("value",) value: str # '' by default + # If this value expression can also be parsed as a valid type expression, + # represents the type denoted by the type expression. + as_type: mypy.types.Type | None def __init__(self, value: str) -> None: super().__init__() self.value = value + self.as_type = None def accept(self, visitor: ExpressionVisitor[T]) -> T: return visitor.visit_str_expr(self) @@ -1875,15 +1879,20 @@ class NameExpr(RefExpr): This refers to a local name, global name or a module. """ - __slots__ = ("name", "is_special_form") + __slots__ = ("name", "is_special_form", "as_type") __match_args__ = ("name", "node") + # If this value expression can also be parsed as a valid type expression, + # represents the type denoted by the type expression. + as_type: mypy.types.Type | None + def __init__(self, name: str) -> None: super().__init__() self.name = name # Name referred to # Is this a l.h.s. of a special form assignment like typed dict or type variable? self.is_special_form = False + self.as_type = None def accept(self, visitor: ExpressionVisitor[T]) -> T: return visitor.visit_name_expr(self) @@ -2023,7 +2032,7 @@ class IndexExpr(Expression): Also wraps type application such as List[int] as a special form. """ - __slots__ = ("base", "index", "method_type", "analyzed") + __slots__ = ("base", "index", "method_type", "analyzed", "as_type") __match_args__ = ("base", "index") @@ -2034,6 +2043,9 @@ class IndexExpr(Expression): # If not None, this is actually semantically a type application # Class[type, ...] or a type alias initializer. analyzed: TypeApplication | TypeAliasExpr | None + # If this value expression can also be parsed as a valid type expression, + # represents the type denoted by the type expression. + as_type: mypy.types.Type | None def __init__(self, base: Expression, index: Expression) -> None: super().__init__() @@ -2041,6 +2053,7 @@ def __init__(self, base: Expression, index: Expression) -> None: self.index = index self.method_type = None self.analyzed = None + self.as_type = None def accept(self, visitor: ExpressionVisitor[T]) -> T: return visitor.visit_index_expr(self) @@ -2138,7 +2151,7 @@ def accept(self, visitor: ExpressionVisitor[T]) -> T: # Expression subtypes that could represent the root of a valid type expression. # Always contains an "as_type" attribute. # TODO: Make this into a Protocol if mypyc is OK with that. -MaybeTypeExpression = OpExpr +MaybeTypeExpression = Union[IndexExpr, NameExpr, OpExpr, StrExpr] class ComparisonExpr(Expression): From a2c318b45ae42713cbcef55321870dd246cf38c2 Mon Sep 17 00:00:00 2001 From: David Foster Date: Thu, 20 Feb 2025 21:01:39 -0500 Subject: [PATCH 08/23] SQ -> WIP: Don't declare attributes on Expression to avoid confusing mypyc Fix: Workaround mypy thinking incorrectly that isinstance(X, Union[...]) does not work at runtime. --- mypy/nodes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mypy/nodes.py b/mypy/nodes.py index b0a1ec527fd8..23a19d882686 100644 --- a/mypy/nodes.py +++ b/mypy/nodes.py @@ -2151,7 +2151,7 @@ def accept(self, visitor: ExpressionVisitor[T]) -> T: # Expression subtypes that could represent the root of a valid type expression. # Always contains an "as_type" attribute. # TODO: Make this into a Protocol if mypyc is OK with that. -MaybeTypeExpression = Union[IndexExpr, NameExpr, OpExpr, StrExpr] +MaybeTypeExpression = (IndexExpr, NameExpr, OpExpr, StrExpr) class ComparisonExpr(Expression): From f3983744fda9c4a33f577f2d8f7583efa1c5b2fa Mon Sep 17 00:00:00 2001 From: David Foster Date: Mon, 24 Feb 2025 21:03:38 -0500 Subject: [PATCH 09/23] Recognize non-string type expressions everywhere lazily. Optimize eager parsing to bail earlier. Also: * No longer add "as_type" attribute to NameExpr and MemberExpr. Retain that attribute on StrExpr, IndexExpr, and OpExpr. * Recognize dotted type expressions like "typing.List". --- mypy/checker.py | 107 +++++++++++++++++++++++- mypy/checkexpr.py | 85 +++++++++++++++++-- mypy/nodes.py | 33 ++++---- mypy/semanal.py | 81 ++++++++++++++++-- mypy/traverser.py | 33 ++++++++ test-data/unit/check-typeform.test | 128 +++++++++++++++++++++++++++-- 6 files changed, 431 insertions(+), 36 deletions(-) diff --git a/mypy/checker.py b/mypy/checker.py index 7c17cb20f999..70ae285f9d20 100644 --- a/mypy/checker.py +++ b/mypy/checker.py @@ -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 ( @@ -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 @@ -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 = [] @@ -2556,7 +2560,9 @@ 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(): @@ -2624,6 +2630,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. @@ -7923,6 +7938,96 @@ 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.""" diff --git a/mypy/checkexpr.py b/mypy/checkexpr.py index 1df38cac6f7c..96f1e019d0dd 100644 --- a/mypy/checkexpr.py +++ b/mypy/checkexpr.py @@ -30,6 +30,7 @@ freshen_all_functions_type_vars, freshen_function_type_vars, ) +from mypy.exprtotype import expr_to_unanalyzed_type, TypeTranslationError 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 @@ -66,6 +67,7 @@ FloatExpr, FuncDef, GeneratorExpr, + get_member_expr_fullname, IndexExpr, IntExpr, LambdaExpr, @@ -91,6 +93,7 @@ StrExpr, SuperExpr, SymbolNode, + SymbolTableNode, TempNode, TupleExpr, TypeAlias, @@ -102,6 +105,7 @@ TypeVarExpr, TypeVarTupleExpr, UnaryExpr, + UNBOUND_IMPORTED, Var, YieldExpr, YieldFromExpr, @@ -123,7 +127,8 @@ 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 ( check_for_explicit_any, fix_instance, @@ -131,6 +136,7 @@ instantiate_type_alias, make_optional_type, set_any_tvars, + TypeAnalyser, validate_instance, ) from mypy.typeops import ( @@ -5950,13 +5956,12 @@ def accept( elif ( isinstance(p_type_context, TypeType) and p_type_context.is_type_form - and isinstance(node, MaybeTypeExpression) - and node.as_type is not None + 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, + node_as_type, + line=node_as_type.line, + column=node_as_type.column, is_type_form=True, ) else: @@ -6313,6 +6318,74 @@ 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 is not Ellipsis): + 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. ' + 'Try assigning the TypeForm to a variable and use the variable here instead.', + maybe_type_expr, + ) + 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""" diff --git a/mypy/nodes.py b/mypy/nodes.py index 23a19d882686..a92db9278680 100644 --- a/mypy/nodes.py +++ b/mypy/nodes.py @@ -4,6 +4,7 @@ import os from abc import abstractmethod +import builtins from collections import defaultdict from collections.abc import Iterator, Sequence from enum import Enum, unique @@ -20,6 +21,9 @@ if TYPE_CHECKING: from mypy.patterns import Pattern + EllipsisType = builtins.ellipsis +else: + EllipsisType = Any class Context: """Base type for objects that are valid as error message locations.""" @@ -1723,12 +1727,13 @@ class StrExpr(Expression): value: str # '' by default # If this value expression can also be parsed as a valid type expression, # represents the type denoted by the type expression. - as_type: mypy.types.Type | None + # Ellipsis means "not parsed" and None means "is not a type expression". + as_type: EllipsisType | mypy.types.Type | None def __init__(self, value: str) -> None: super().__init__() self.value = value - self.as_type = None + self.as_type = Ellipsis def accept(self, visitor: ExpressionVisitor[T]) -> T: return visitor.visit_str_expr(self) @@ -1879,20 +1884,15 @@ class NameExpr(RefExpr): This refers to a local name, global name or a module. """ - __slots__ = ("name", "is_special_form", "as_type") + __slots__ = ("name", "is_special_form") __match_args__ = ("name", "node") - # If this value expression can also be parsed as a valid type expression, - # represents the type denoted by the type expression. - as_type: mypy.types.Type | None - def __init__(self, name: str) -> None: super().__init__() self.name = name # Name referred to # Is this a l.h.s. of a special form assignment like typed dict or type variable? self.is_special_form = False - self.as_type = None def accept(self, visitor: ExpressionVisitor[T]) -> T: return visitor.visit_name_expr(self) @@ -2045,7 +2045,8 @@ class IndexExpr(Expression): analyzed: TypeApplication | TypeAliasExpr | None # If this value expression can also be parsed as a valid type expression, # represents the type denoted by the type expression. - as_type: mypy.types.Type | None + # Ellipsis means "not parsed" and None means "is not a type expression". + as_type: EllipsisType | mypy.types.Type | None def __init__(self, base: Expression, index: Expression) -> None: super().__init__() @@ -2053,7 +2054,7 @@ def __init__(self, base: Expression, index: Expression) -> None: self.index = index self.method_type = None self.analyzed = None - self.as_type = None + self.as_type = Ellipsis def accept(self, visitor: ExpressionVisitor[T]) -> T: return visitor.visit_index_expr(self) @@ -2129,7 +2130,8 @@ class OpExpr(Expression): analyzed: TypeAliasExpr | None # If this value expression can also be parsed as a valid type expression, # represents the type denoted by the type expression. - as_type: mypy.types.Type | None + # Ellipsis means "not parsed" and None means "is not a type expression". + as_type: EllipsisType | mypy.types.Type | None def __init__( self, op: str, left: Expression, right: Expression, analyzed: TypeAliasExpr | None = None @@ -2142,16 +2144,17 @@ def __init__( self.right_always = False self.right_unreachable = False self.analyzed = analyzed - self.as_type = None + self.as_type = Ellipsis def accept(self, visitor: ExpressionVisitor[T]) -> T: return visitor.visit_op_expr(self) # Expression subtypes that could represent the root of a valid type expression. -# Always contains an "as_type" attribute. -# TODO: Make this into a Protocol if mypyc is OK with that. -MaybeTypeExpression = (IndexExpr, NameExpr, OpExpr, StrExpr) +# +# May have an "as_type" attribute to hold the type for a type expression parsed +# during the SemanticAnalyzer pass. +MaybeTypeExpression = (IndexExpr, MemberExpr, NameExpr, OpExpr, StrExpr) class ComparisonExpr(Expression): diff --git a/mypy/semanal.py b/mypy/semanal.py index cb7ee0a28df3..6cbddd9d3b72 100644 --- a/mypy/semanal.py +++ b/mypy/semanal.py @@ -308,6 +308,8 @@ from mypy.typevars import fill_typevars from mypy.util import correct_relative_import, is_dunder, module_prefix, unmangle, unnamed_function from mypy.visitor import NodeVisitor +import re +from typing_extensions import assert_never T = TypeVar("T") @@ -343,6 +345,15 @@ Tag: _TypeAlias = int +# Matches two words separated by whitespace, where each word lacks +# any symbols which have special meaning in a type expression. +# +# Any string literal matching this common pattern cannot be a valid +# type expression and can be ignored quickly when attempting to parse a +# string literal as a type expression. +_MULTIPLE_WORDS_NONTYPE_RE = re.compile(r'\s*[^\s.\'"|\[]+\s+[^\s.\'"|\[]') + + class SemanticAnalyzer( NodeVisitor[None], SemanticAnalyzerInterface, SemanticAnalyzerPluginInterface ): @@ -7625,9 +7636,71 @@ def try_parse_as_type_expression(self, maybe_type_expr: Expression) -> None: If fails then emit no errors and take no further action. A value expression that is parsable as a type expression may be used - where a TypeForm is expected to represent the spelled type.""" + where a TypeForm is expected to represent the spelled type. + + Unlike ExpressionChecker.try_parse_as_type_expression() + (used in the later TypeChecker pass), this function can recognize + ALL kinds of type expressions, including type expressions containing + string annotations. + + If the provided Expression will be parsable later in + ExpressionChecker.try_parse_as_type_expression(), this function will + skip parsing the Expression to improve performance, because the later + function is called many fewer times (i.e. only lazily in a rare TypeForm + type context) than this function is called (i.e. eagerly for EVERY + expression in certain syntactic positions). + """ + + # Bail ASAP if the Expression matches a common pattern that cannot possibly + # be a valid type expression, because this function is called very frequently if not isinstance(maybe_type_expr, MaybeTypeExpression): return + # Check types in order from most common to least common, for best performance + if isinstance(maybe_type_expr, (NameExpr, MemberExpr)): + # Defer parsing to the later TypeChecker pass, + # and only lazily in contexts where a TypeForm is expected + return + elif isinstance(maybe_type_expr, StrExpr): + # Filter out string literals with common patterns that could not + # possibly be in a type expression + if _MULTIPLE_WORDS_NONTYPE_RE.match(maybe_type_expr.value): + # A common pattern in string literals containing a sentence. + # But cannot be a type expression. + maybe_type_expr.as_type = None + return + elif isinstance(maybe_type_expr, IndexExpr): + if isinstance(maybe_type_expr.base, NameExpr): + if isinstance(maybe_type_expr.base.node, Var): + # Leftmost part of IndexExpr refers to a Var. Not a valid type. + maybe_type_expr.as_type = None + return + elif isinstance(maybe_type_expr.base, MemberExpr): + next_leftmost = maybe_type_expr.base + while True: + leftmost = next_leftmost.expr + if not isinstance(leftmost, MemberExpr): + break + next_leftmost = leftmost + if isinstance(leftmost, NameExpr): + if isinstance(leftmost.node, Var): + # Leftmost part of IndexExpr refers to a Var. Not a valid type. + maybe_type_expr.as_type = None + return + else: + # Leftmost part of IndexExpr is not a NameExpr. Not a valid type. + maybe_type_expr.as_type = None + return + else: + # IndexExpr base is neither a NameExpr nor MemberExpr. Not a valid type. + maybe_type_expr.as_type = None + return + elif isinstance(maybe_type_expr, OpExpr): + if maybe_type_expr.op != '|': + # Binary operators other than '|' never spell a valid type + maybe_type_expr.as_type = None + return + else: + assert_never(maybe_type_expr) # Save SemanticAnalyzer state original_errors = self.errors # altered by fail() @@ -7644,11 +7717,9 @@ def try_parse_as_type_expression(self, maybe_type_expr: Expression) -> None: try: t = self.expr_to_analyzed_type(maybe_type_expr) if self.errors.is_errors(): - raise TypeTranslationError - if isinstance(t, (UnboundType, PlaceholderType)): # type: ignore[misc] - raise TypeTranslationError + t = None except TypeTranslationError: - # Not a type expression. It must be a value expression. + # Not a type expression t = None finally: # Restore SemanticAnalyzer state diff --git a/mypy/traverser.py b/mypy/traverser.py index 54325b740e95..18bb0c4ce4c2 100644 --- a/mypy/traverser.py +++ b/mypy/traverser.py @@ -944,6 +944,39 @@ def has_return_statement(fdef: FuncBase) -> bool: return seeker.found +class NameAndMemberCollector(TraverserVisitor): + def __init__(self) -> None: + super().__init__() + self.name_exprs: list[NameExpr] = [] + self.member_exprs: list[MemberExpr] = [] + + def visit_name_expr(self, o: NameExpr, /) -> None: + self.name_exprs.append(o) + + def visit_member_expr(self, o: MemberExpr, /) -> None: + self.member_exprs.append(o) + + +def all_name_and_member_expressions(node: Expression) -> tuple[list[NameExpr], list[MemberExpr]]: + v = NameAndMemberCollector() + node.accept(v) + return (v.name_exprs, v.member_exprs) + + +class StringSeeker(TraverserVisitor): + def __init__(self) -> None: + self.found = False + + def visit_str_expr(self, o: StrExpr, /) -> None: + self.found = True + + +def has_str_expression(node: Expression) -> bool: + v = StringSeeker() + node.accept(v) + return v.found + + class FuncCollectorBase(TraverserVisitor): def __init__(self) -> None: self.inside_func = False diff --git a/test-data/unit/check-typeform.test b/test-data/unit/check-typeform.test index eaef44137b3f..a4fdcec764e1 100644 --- a/test-data/unit/check-typeform.test +++ b/test-data/unit/check-typeform.test @@ -19,6 +19,13 @@ reveal_type(typx) # N: Revealed type is "TypeForm[builtins.str]" -- Type Expression Location: Assignment +[case testCanAssignTypeExpressionToTypeFormVariable] +# flags: --python-version 3.14 --enable-incomplete-feature=TypeForm +from typing import TypeForm +typx: TypeForm[str] = str +[builtins fixtures/tuple.pyi] +[typing fixtures/typing-full.pyi] + [case testCanAssignTypeExpressionToUnionTypeFormVariable] # flags: --python-version 3.14 --enable-incomplete-feature=TypeForm from typing import TypeForm @@ -77,6 +84,13 @@ typx = int | None [builtins fixtures/tuple.pyi] [typing fixtures/typing-full.pyi] +[case testCanAssignTypeExpressionWithStringAnnotationToTypeFormVariable] +# flags: --python-version 3.14 --enable-incomplete-feature=TypeForm +from typing import TypeForm +typx: TypeForm[str | None] = 'str | None' +[builtins fixtures/tuple.pyi] +[typing fixtures/typing-full.pyi] + -- Type Expression Location: Function Parameter @@ -152,6 +166,15 @@ def sum_ints(x: int | None, y: int) -> tuple[int, int]: [builtins fixtures/tuple.pyi] [typing fixtures/typing-full.pyi] +[case testCanPassTypeExpressionWithStringAnnotationToTypeFormParameter] +# flags: --python-version 3.14 --enable-incomplete-feature=TypeForm +from typing import TypeForm +def is_type(typx: TypeForm) -> bool: + return isinstance(typx, type) +is_type('int | None') +[builtins fixtures/tuple.pyi] +[typing fixtures/typing-full.pyi] + -- Type Expression Location: Return Statement @@ -164,21 +187,83 @@ reveal_type(maybe_int_type()) # N: Revealed type is "TypeForm[Any]" [builtins fixtures/tuple.pyi] [typing fixtures/typing-full.pyi] +[case testCanReturnTypeExpressionWithStringAnnotationInFunctionWithTypeFormReturnType] +# flags: --python-version 3.14 --enable-incomplete-feature=TypeForm +from typing import TypeForm +def maybe_int_type() -> TypeForm: + return 'int | None' +reveal_type(maybe_int_type()) # N: Revealed type is "TypeForm[Any]" +[builtins fixtures/tuple.pyi] +[typing fixtures/typing-full.pyi] + -- Type Expression Location: Other -[case testTypeExpressionNotCurrentlyRecognizedInAllPossibleSyntacticLocations] -# ...but may be recognized in these or other locations in the future +-- In particular ensure that ExpressionChecker.try_parse_as_type_expression() in +-- the TypeChecker pass is able to parse types correctly even though it doesn't +-- have the same rich context as SemanticAnalyzer.try_parse_as_type_expression(). + +[case testTypeExpressionWithoutStringAnnotationRecognizedInOtherSyntacticLocations] # flags: --python-version 3.14 --enable-incomplete-feature=TypeForm -from typing import Dict, TypeForm +from typing import Dict, List, TypeForm +list_of_typx: List[TypeForm] = [int | str] dict_with_typx_keys: Dict[TypeForm, int] = { - int | str: 1, # E: Dict entry 0 has incompatible type "object": "int"; expected "TypeForm[Any]": "int" - str | None: 2, # E: Dict entry 1 has incompatible type "object": "int"; expected "TypeForm[Any]": "int" + int | str: 1, + str | None: 2, } -dict_with_typx_keys[int | str] += 1 # E: Invalid index type "object" for "Dict[TypeForm[Any], int]"; expected type "TypeForm[Any]" +dict_with_typx_keys[int | str] += 1 [builtins fixtures/dict.pyi] [typing fixtures/typing-full.pyi] +[case testTypeExpressionWithStringAnnotationNotRecognizedInOtherSyntacticLocations] +# flags: --python-version 3.14 --enable-incomplete-feature=TypeForm +from typing import Dict, List, TypeForm +list_of_typx: List[TypeForm] = ['int | str'] # N: TypeForm containing a string annotation cannot be recognized here. Try assigning the TypeForm to a variable and use the variable here instead. \ + # E: List item 0 has incompatible type "str"; expected "TypeForm[Any]" +dict_with_typx_keys: Dict[TypeForm, int] = { + 'int | str': 1, # N: TypeForm containing a string annotation cannot be recognized here. Try assigning the TypeForm to a variable and use the variable here instead. \ + # E: Dict entry 0 has incompatible type "str": "int"; expected "TypeForm[Any]": "int" + 'str | None': 2, # N: TypeForm containing a string annotation cannot be recognized here. Try assigning the TypeForm to a variable and use the variable here instead. \ + # E: Dict entry 1 has incompatible type "str": "int"; expected "TypeForm[Any]": "int" +} +dict_with_typx_keys['int | str'] += 1 # N: TypeForm containing a string annotation cannot be recognized here. Try assigning the TypeForm to a variable and use the variable here instead. \ + # E: Invalid index type "str" for "Dict[TypeForm[Any], int]"; expected type "TypeForm[Any]" +[builtins fixtures/dict.pyi] +[typing fixtures/typing-full.pyi] + +[case testSelfRecognizedInOtherSyntacticLocations] +# flags: --python-version 3.14 --enable-incomplete-feature=TypeForm +from typing import List, Self, TypeForm +class C: + def foo(self) -> None: + list_of_typx1: List[TypeForm] = [Self] + typx1: TypeForm = Self + typx2: TypeForm = 'Self' +list_of_typx2: List[TypeForm] = [Self] # E: List item 0 has incompatible type "int"; expected "TypeForm[Any]" +typx3: TypeForm = Self # E: Incompatible types in assignment (expression has type "int", variable has type "TypeForm[Any]") +typx4: TypeForm = 'Self' # E: Incompatible types in assignment (expression has type "str", variable has type "TypeForm[Any]") +[builtins fixtures/tuple.pyi] +[typing fixtures/typing-full.pyi] + +[case testNameOrDottedNameRecognizedInOtherSyntacticLocations] +# flags: --python-version 3.14 --enable-incomplete-feature=TypeForm +import typing +from typing import List, TypeForm +list_of_typx: List[TypeForm] = [List | typing.Optional[str]] +typx: TypeForm = List | typing.Optional[str] +[builtins fixtures/tuple.pyi] +[typing fixtures/typing-full.pyi] + +[case testInvalidNameOrDottedNameRecognizedInOtherSyntacticLocations] +# flags: --python-version 3.14 --enable-incomplete-feature=TypeForm +from typing import List, TypeForm +list_of_typx1: List[TypeForm] = [NoSuchType] # E: Name "NoSuchType" is not defined +list_of_typx2: List[TypeForm] = [no_such_module.NoSuchType] # E: Name "no_such_module" is not defined +typx1: TypeForm = NoSuchType # E: Name "NoSuchType" is not defined +typx2: TypeForm = no_such_module.NoSuchType # E: Name "no_such_module" is not defined +[builtins fixtures/tuple.pyi] +[typing fixtures/typing-full.pyi] + -- Assignability (is_subtype) @@ -570,10 +655,35 @@ class BaseException: pass class float: pass [typing fixtures/typing-full.pyi] -[case testQuotedTypeFormsAreRecognized] +[case testDottedTypeFormsAreRecognized] # flags: --python-version 3.14 --enable-incomplete-feature=TypeForm from typing import TypeForm -typx1: TypeForm[int | str] = 'int | str' # OK -typx2: TypeForm[int] = 'str' # E: Incompatible types in assignment (expression has type "TypeForm[str]", variable has type "TypeForm[int]") +import typing +class C1: + class C2: + pass +typx1: TypeForm[C1.C2] = C1.C2 # OK +typx2: TypeForm[typing.Any] = typing.Any # OK +[builtins fixtures/tuple.pyi] +[typing fixtures/typing-full.pyi] + +-- mypy already refused to recognize TypeVars in value expressions before +-- the TypeForm feature was introduced. +[case testTypeVarTypeFormsAreOnlyRecognizedInStringAnnotation] +# flags: --python-version 3.14 --enable-incomplete-feature=TypeForm +from typing import Generic, List, TypeForm, TypeVar +E = TypeVar('E') +class Box(Generic[E]): + def foo(self, e: E) -> None: + list_of_typx: List[TypeForm] = [E] # E: "E" is a type variable and only valid in type context + typx1: TypeForm = E # E: "E" is a type variable and only valid in type context + typx2: TypeForm = 'E' +[builtins fixtures/tuple.pyi] +[typing fixtures/typing-full.pyi] + +[case testIncompleteTypeFormsAreNotRecognized] +# flags: --python-version 3.14 --enable-incomplete-feature=TypeForm +from typing import Optional, TypeForm +typx: TypeForm = Optional # E: Incompatible types in assignment (expression has type "int", variable has type "TypeForm[Any]") [builtins fixtures/tuple.pyi] [typing fixtures/typing-full.pyi] From bd5911f8f0f74e5848efbfa69c172b94dbae3b6d Mon Sep 17 00:00:00 2001 From: David Foster Date: Mon, 3 Mar 2025 20:25:52 -0500 Subject: [PATCH 10/23] NOMERGE: Disable test that is already failing on master branch --- test-data/unit/stubgen.test | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/test-data/unit/stubgen.test b/test-data/unit/stubgen.test index bf17c34b99a7..76c8cd55f653 100644 --- a/test-data/unit/stubgen.test +++ b/test-data/unit/stubgen.test @@ -3110,19 +3110,8 @@ from m import __about__ as __about__, __author__ as __author__, __version__ as _ __all__ = ['__about__', '__author__', '__version__'] -[case testAttrsClass_semanal] -import attrs - -@attrs.define -class C: - x: int = attrs.field() - -[out] -import attrs - -@attrs.define -class C: - x: int = attrs.field() +-- (FIXME: Removed test "testAttrsClass_semanal" that fails on master branch so +-- that CI detects valid regressions on this feature branch.) [case testNamedTupleInClass] from collections import namedtuple From 3801bcc8b19a3bbecc9a1f89b567af087c920601 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 4 Mar 2025 01:43:02 +0000 Subject: [PATCH 11/23] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- mypy/checker.py | 11 +++++++---- mypy/checkexpr.py | 35 ++++++++++++++++++----------------- mypy/nodes.py | 3 ++- mypy/semanal.py | 7 +++---- 4 files changed, 30 insertions(+), 26 deletions(-) diff --git a/mypy/checker.py b/mypy/checker.py index 70ae285f9d20..2d51ceb9c4f9 100644 --- a/mypy/checker.py +++ b/mypy/checker.py @@ -2560,9 +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), \ - self.enter_class(defn.info): + 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(): @@ -7946,6 +7948,7 @@ class TypeCheckerAsSemanticAnalyzer(SemanticAnalyzerCoreInterface): See ExpressionChecker.try_parse_as_type_expression() to understand how this class is used. """ + _chk: TypeChecker _names: dict[str, SymbolTableNode] did_fail: bool @@ -7991,7 +7994,7 @@ def note(self, msg: str, ctx: Context, *, code: ErrorCode | None = None) -> None def incomplete_feature_enabled(self, feature: str, ctx: Context) -> bool: if feature not in self._chk.options.enable_incomplete_feature: - self.fail('__ignored__', ctx) + self.fail("__ignored__", ctx) return False return True diff --git a/mypy/checkexpr.py b/mypy/checkexpr.py index 96f1e019d0dd..de951036bb4d 100644 --- a/mypy/checkexpr.py +++ b/mypy/checkexpr.py @@ -30,7 +30,7 @@ freshen_all_functions_type_vars, freshen_function_type_vars, ) -from mypy.exprtotype import expr_to_unanalyzed_type, TypeTranslationError +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 @@ -47,6 +47,7 @@ LITERAL_TYPE, REVEAL_LOCALS, REVEAL_TYPE, + UNBOUND_IMPORTED, ArgKind, AssertTypeExpr, AssignmentExpr, @@ -67,7 +68,6 @@ FloatExpr, FuncDef, GeneratorExpr, - get_member_expr_fullname, IndexExpr, IntExpr, LambdaExpr, @@ -105,10 +105,10 @@ TypeVarExpr, TypeVarTupleExpr, UnaryExpr, - UNBOUND_IMPORTED, Var, YieldExpr, YieldFromExpr, + get_member_expr_fullname, ) from mypy.options import PRECISE_TUPLE_TYPES from mypy.plugin import ( @@ -127,16 +127,20 @@ is_subtype, non_method_protocol_members, ) -from mypy.traverser import all_name_and_member_expressions, has_await_expression, has_str_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, instantiate_type_alias, make_optional_type, set_any_tvars, - TypeAnalyser, validate_instance, ) from mypy.typeops import ( @@ -6335,8 +6339,10 @@ def try_parse_as_type_expression(self, maybe_type_expr: Expression) -> Type | No # 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 is not Ellipsis): + if ( + isinstance(maybe_type_expr, (StrExpr, IndexExpr, OpExpr)) + and maybe_type_expr.as_type is not Ellipsis + ): return maybe_type_expr.as_type # If is potentially a type expression containing a string annotation, @@ -6344,8 +6350,8 @@ def try_parse_as_type_expression(self, maybe_type_expr: Expression) -> Type | No # 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. ' - 'Try assigning the TypeForm to a variable and use the variable here instead.', + "TypeForm containing a string annotation cannot be recognized here. " + "Try assigning the TypeForm to a variable and use the variable here instead.", maybe_type_expr, ) return None @@ -6354,13 +6360,8 @@ def try_parse_as_type_expression(self, maybe_type_expr: Expression) -> Type | No # 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) + 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 } @@ -6377,7 +6378,7 @@ def try_parse_as_type_expression(self, maybe_type_expr: Expression) -> Type | No try: typ1 = expr_to_unanalyzed_type( - maybe_type_expr, self.chk.options, self.chk.is_typeshed_stub, + maybe_type_expr, self.chk.options, self.chk.is_typeshed_stub ) typ2 = typ1.accept(tpan) if chk_sem.did_fail: diff --git a/mypy/nodes.py b/mypy/nodes.py index a92db9278680..29203ab9ba08 100644 --- a/mypy/nodes.py +++ b/mypy/nodes.py @@ -2,9 +2,9 @@ from __future__ import annotations +import builtins import os from abc import abstractmethod -import builtins from collections import defaultdict from collections.abc import Iterator, Sequence from enum import Enum, unique @@ -25,6 +25,7 @@ else: EllipsisType = Any + class Context: """Base type for objects that are valid as error message locations.""" diff --git a/mypy/semanal.py b/mypy/semanal.py index 6cbddd9d3b72..4f3682c63997 100644 --- a/mypy/semanal.py +++ b/mypy/semanal.py @@ -50,10 +50,11 @@ from __future__ import annotations +import re from collections.abc import Collection, Iterable, Iterator from contextlib import contextmanager from typing import Any, Callable, Final, TypeVar, cast -from typing_extensions import TypeAlias as _TypeAlias, TypeGuard +from typing_extensions import TypeAlias as _TypeAlias, TypeGuard, assert_never from mypy import errorcodes as codes, message_registry from mypy.constant_fold import constant_fold_expr @@ -308,8 +309,6 @@ from mypy.typevars import fill_typevars from mypy.util import correct_relative_import, is_dunder, module_prefix, unmangle, unnamed_function from mypy.visitor import NodeVisitor -import re -from typing_extensions import assert_never T = TypeVar("T") @@ -7695,7 +7694,7 @@ def try_parse_as_type_expression(self, maybe_type_expr: Expression) -> None: maybe_type_expr.as_type = None return elif isinstance(maybe_type_expr, OpExpr): - if maybe_type_expr.op != '|': + if maybe_type_expr.op != "|": # Binary operators other than '|' never spell a valid type maybe_type_expr.as_type = None return From c4db784bb1895e9035368adc5a1ec0648be5bb30 Mon Sep 17 00:00:00 2001 From: David Foster Date: Tue, 4 Mar 2025 08:44:36 -0500 Subject: [PATCH 12/23] Fix mypyc errors: Replace EllipsisType with NotParsed type --- mypy/checkexpr.py | 3 ++- mypy/nodes.py | 25 +++++++++++++------------ 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/mypy/checkexpr.py b/mypy/checkexpr.py index de951036bb4d..9d53f503ae4b 100644 --- a/mypy/checkexpr.py +++ b/mypy/checkexpr.py @@ -79,6 +79,7 @@ NamedTupleExpr, NameExpr, NewTypeExpr, + NotParsed, OpExpr, OverloadedFuncDef, ParamSpecExpr, @@ -6341,7 +6342,7 @@ def try_parse_as_type_expression(self, maybe_type_expr: Expression) -> Type | No # perhaps containing a string annotation if ( isinstance(maybe_type_expr, (StrExpr, IndexExpr, OpExpr)) - and maybe_type_expr.as_type is not Ellipsis + and maybe_type_expr.as_type != NotParsed.VALUE ): return maybe_type_expr.as_type diff --git a/mypy/nodes.py b/mypy/nodes.py index 29203ab9ba08..82fe8b3870f1 100644 --- a/mypy/nodes.py +++ b/mypy/nodes.py @@ -21,9 +21,10 @@ if TYPE_CHECKING: from mypy.patterns import Pattern - EllipsisType = builtins.ellipsis -else: - EllipsisType = Any + +@unique +class NotParsed(Enum): + VALUE = 'NotParsed' class Context: @@ -1728,13 +1729,13 @@ class StrExpr(Expression): value: str # '' by default # If this value expression can also be parsed as a valid type expression, # represents the type denoted by the type expression. - # Ellipsis means "not parsed" and None means "is not a type expression". - as_type: EllipsisType | mypy.types.Type | None + # None means "is not a type expression". + as_type: NotParsed | mypy.types.Type | None def __init__(self, value: str) -> None: super().__init__() self.value = value - self.as_type = Ellipsis + self.as_type = NotParsed.VALUE def accept(self, visitor: ExpressionVisitor[T]) -> T: return visitor.visit_str_expr(self) @@ -2046,8 +2047,8 @@ class IndexExpr(Expression): analyzed: TypeApplication | TypeAliasExpr | None # If this value expression can also be parsed as a valid type expression, # represents the type denoted by the type expression. - # Ellipsis means "not parsed" and None means "is not a type expression". - as_type: EllipsisType | mypy.types.Type | None + # None means "is not a type expression". + as_type: NotParsed | mypy.types.Type | None def __init__(self, base: Expression, index: Expression) -> None: super().__init__() @@ -2055,7 +2056,7 @@ def __init__(self, base: Expression, index: Expression) -> None: self.index = index self.method_type = None self.analyzed = None - self.as_type = Ellipsis + self.as_type = NotParsed.VALUE def accept(self, visitor: ExpressionVisitor[T]) -> T: return visitor.visit_index_expr(self) @@ -2131,8 +2132,8 @@ class OpExpr(Expression): analyzed: TypeAliasExpr | None # If this value expression can also be parsed as a valid type expression, # represents the type denoted by the type expression. - # Ellipsis means "not parsed" and None means "is not a type expression". - as_type: EllipsisType | mypy.types.Type | None + # None means "is not a type expression". + as_type: NotParsed | mypy.types.Type | None def __init__( self, op: str, left: Expression, right: Expression, analyzed: TypeAliasExpr | None = None @@ -2145,7 +2146,7 @@ def __init__( self.right_always = False self.right_unreachable = False self.analyzed = analyzed - self.as_type = Ellipsis + self.as_type = NotParsed.VALUE def accept(self, visitor: ExpressionVisitor[T]) -> T: return visitor.visit_op_expr(self) From 29fe65afd3598ebb386b65df110193a92975d48e Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 4 Mar 2025 13:51:20 +0000 Subject: [PATCH 13/23] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- mypy/nodes.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/mypy/nodes.py b/mypy/nodes.py index 82fe8b3870f1..014f9847764c 100644 --- a/mypy/nodes.py +++ b/mypy/nodes.py @@ -2,7 +2,6 @@ from __future__ import annotations -import builtins import os from abc import abstractmethod from collections import defaultdict @@ -24,7 +23,7 @@ @unique class NotParsed(Enum): - VALUE = 'NotParsed' + VALUE = "NotParsed" class Context: From 41342501be534e67eb895c5390d4fac9bf54630c Mon Sep 17 00:00:00 2001 From: David Foster Date: Wed, 5 Mar 2025 09:43:11 -0500 Subject: [PATCH 14/23] mypy_primer: Enable TypeForm feature when checking effects on open source code --- .github/workflows/mypy_primer.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/mypy_primer.yml b/.github/workflows/mypy_primer.yml index ee868484751e..61f31cc302cb 100644 --- a/.github/workflows/mypy_primer.yml +++ b/.github/workflows/mypy_primer.yml @@ -65,7 +65,7 @@ jobs: --new $GITHUB_SHA --old base_commit \ --num-shards 5 --shard-index ${{ matrix.shard-index }} \ --debug \ - --additional-flags="--debug-serialize" \ + --additional-flags="--debug-serialize --enable-incomplete-feature=TypeForm" \ --output concise \ | tee diff_${{ matrix.shard-index }}.txt ) || [ $? -eq 1 ] From 5fb5bd82bd3bfd00641ef06b3a65e9d184949255 Mon Sep 17 00:00:00 2001 From: David Foster Date: Sat, 8 Mar 2025 13:40:33 -0500 Subject: [PATCH 15/23] Revert "mypy_primer: Enable TypeForm feature when checking effects on open source code" This reverts commit 41342501be534e67eb895c5390d4fac9bf54630c. --- .github/workflows/mypy_primer.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/mypy_primer.yml b/.github/workflows/mypy_primer.yml index 61f31cc302cb..ee868484751e 100644 --- a/.github/workflows/mypy_primer.yml +++ b/.github/workflows/mypy_primer.yml @@ -65,7 +65,7 @@ jobs: --new $GITHUB_SHA --old base_commit \ --num-shards 5 --shard-index ${{ matrix.shard-index }} \ --debug \ - --additional-flags="--debug-serialize --enable-incomplete-feature=TypeForm" \ + --additional-flags="--debug-serialize" \ --output concise \ | tee diff_${{ matrix.shard-index }}.txt ) || [ $? -eq 1 ] From 54cd64d8efd9df55850549b278b2b0e74cc478e4 Mon Sep 17 00:00:00 2001 From: David Foster Date: Sat, 8 Mar 2025 13:48:07 -0500 Subject: [PATCH 16/23] NOMERGE: mypy_primer: Enable --enable-incomplete-feature=TypeForm when checking open source code --- mypy/options.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/mypy/options.py b/mypy/options.py index 5bfc9b591834..29c8119bd9da 100644 --- a/mypy/options.py +++ b/mypy/options.py @@ -329,7 +329,10 @@ def __init__(self) -> None: self.dump_type_stats = False self.dump_inference_stats = False self.dump_build_stats = False - self.enable_incomplete_feature: list[str] = [] + # FIXME: Temporarily TypeForm support by default so that mypy_primer + # can check how enabling it by default would affect typechecker + # for projects that are already trying to use TypeForm. + self.enable_incomplete_feature: list[str] = [TYPE_FORM] self.timing_stats: str | None = None self.line_checking_stats: str | None = None From 075980baee51a8ac7716c64b8f1266558d842b1c Mon Sep 17 00:00:00 2001 From: David Foster Date: Thu, 13 Mar 2025 21:11:14 -0400 Subject: [PATCH 17/23] Ignore warnings like: :1: SyntaxWarning: invalid escape sequence '\(' --- mypy/semanal.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/mypy/semanal.py b/mypy/semanal.py index 4f3682c63997..9813ed1b829c 100644 --- a/mypy/semanal.py +++ b/mypy/semanal.py @@ -309,6 +309,7 @@ from mypy.typevars import fill_typevars from mypy.util import correct_relative_import, is_dunder, module_prefix, unmangle, unnamed_function from mypy.visitor import NodeVisitor +import warnings T = TypeVar("T") @@ -7714,7 +7715,11 @@ def try_parse_as_type_expression(self, maybe_type_expr: Expression) -> None: self.errors = Errors(Options()) try: - t = self.expr_to_analyzed_type(maybe_type_expr) + # Ignore warnings that look like: + # :1: SyntaxWarning: invalid escape sequence '\(' + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=SyntaxWarning) + t = self.expr_to_analyzed_type(maybe_type_expr) if self.errors.is_errors(): t = None except TypeTranslationError: From 6797cace65588f5199253979617338287a38ffbe Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 14 Mar 2025 01:33:07 +0000 Subject: [PATCH 18/23] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- mypy/semanal.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mypy/semanal.py b/mypy/semanal.py index 9813ed1b829c..1dafa35026fb 100644 --- a/mypy/semanal.py +++ b/mypy/semanal.py @@ -51,6 +51,7 @@ from __future__ import annotations import re +import warnings from collections.abc import Collection, Iterable, Iterator from contextlib import contextmanager from typing import Any, Callable, Final, TypeVar, cast @@ -309,7 +310,6 @@ from mypy.typevars import fill_typevars from mypy.util import correct_relative_import, is_dunder, module_prefix, unmangle, unnamed_function from mypy.visitor import NodeVisitor -import warnings T = TypeVar("T") From 4162a356fc404b3d16daf414059e5ec1db095866 Mon Sep 17 00:00:00 2001 From: David Foster Date: Mon, 17 Mar 2025 09:57:43 -0400 Subject: [PATCH 19/23] Rerun CI From d1aafcd0a123b46254575ec135a8f0cc3d3f6bc7 Mon Sep 17 00:00:00 2001 From: David Foster Date: Tue, 18 Mar 2025 10:05:38 -0400 Subject: [PATCH 20/23] Improve warning message when string annotation used in TypeForm context --- mypy/checkexpr.py | 3 ++- mypy/errorcodes.py | 5 +++++ test-data/unit/check-typeform.test | 21 +++++++++++++++++---- 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/mypy/checkexpr.py b/mypy/checkexpr.py index 9d53f503ae4b..8cdc315cbaa7 100644 --- a/mypy/checkexpr.py +++ b/mypy/checkexpr.py @@ -6352,8 +6352,9 @@ def try_parse_as_type_expression(self, maybe_type_expr: Expression) -> Type | No if has_str_expression(maybe_type_expr): self.chk.note( "TypeForm containing a string annotation cannot be recognized here. " - "Try assigning the TypeForm to a variable and use the variable here instead.", + "Surround with TypeForm(...) to recognize.", maybe_type_expr, + code=codes.MAYBE_UNRECOGNIZED_STR_TYPEFORM, ) return None diff --git a/mypy/errorcodes.py b/mypy/errorcodes.py index 8f650aa30605..6eed7e2aeba2 100644 --- a/mypy/errorcodes.py +++ b/mypy/errorcodes.py @@ -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") diff --git a/test-data/unit/check-typeform.test b/test-data/unit/check-typeform.test index a4fdcec764e1..425aa2687586 100644 --- a/test-data/unit/check-typeform.test +++ b/test-data/unit/check-typeform.test @@ -218,19 +218,32 @@ dict_with_typx_keys[int | str] += 1 [case testTypeExpressionWithStringAnnotationNotRecognizedInOtherSyntacticLocations] # flags: --python-version 3.14 --enable-incomplete-feature=TypeForm from typing import Dict, List, TypeForm -list_of_typx: List[TypeForm] = ['int | str'] # N: TypeForm containing a string annotation cannot be recognized here. Try assigning the TypeForm to a variable and use the variable here instead. \ +list_of_typx: List[TypeForm] = ['int | str'] # N: TypeForm containing a string annotation cannot be recognized here. Surround with TypeForm(...) to recognize. \ # E: List item 0 has incompatible type "str"; expected "TypeForm[Any]" dict_with_typx_keys: Dict[TypeForm, int] = { - 'int | str': 1, # N: TypeForm containing a string annotation cannot be recognized here. Try assigning the TypeForm to a variable and use the variable here instead. \ + 'int | str': 1, # N: TypeForm containing a string annotation cannot be recognized here. Surround with TypeForm(...) to recognize. \ # E: Dict entry 0 has incompatible type "str": "int"; expected "TypeForm[Any]": "int" - 'str | None': 2, # N: TypeForm containing a string annotation cannot be recognized here. Try assigning the TypeForm to a variable and use the variable here instead. \ + 'str | None': 2, # N: TypeForm containing a string annotation cannot be recognized here. Surround with TypeForm(...) to recognize. \ # E: Dict entry 1 has incompatible type "str": "int"; expected "TypeForm[Any]": "int" } -dict_with_typx_keys['int | str'] += 1 # N: TypeForm containing a string annotation cannot be recognized here. Try assigning the TypeForm to a variable and use the variable here instead. \ +dict_with_typx_keys['int | str'] += 1 # N: TypeForm containing a string annotation cannot be recognized here. Surround with TypeForm(...) to recognize. \ # E: Invalid index type "str" for "Dict[TypeForm[Any], int]"; expected type "TypeForm[Any]" [builtins fixtures/dict.pyi] [typing fixtures/typing-full.pyi] +[case testValueExpressionWithStringInTypeFormContextEmitsConservativeWarning] +from typing import Any, Dict, List, TypeForm +types: Dict[str, TypeForm] = {'any': Any} +# Ensure warning can be ignored if does not apply. +list_of_typx1: List[TypeForm] = [types['any']] # N: TypeForm containing a string annotation cannot be recognized here. Surround with TypeForm(...) to recognize. +list_of_typx2: List[TypeForm] = [types['any']] # type: ignore[maybe-unrecognized-str-typeform] +# Ensure warning can be fixed using the suggested fix in the warning message. +list_of_typx3: List[TypeForm] = ['Any'] # N: TypeForm containing a string annotation cannot be recognized here. Surround with TypeForm(...) to recognize. \ + # E: List item 0 has incompatible type "str"; expected "TypeForm[Any]" +list_of_typx4: List[TypeForm] = [TypeForm('Any')] +[builtins fixtures/dict.pyi] +[typing fixtures/typing-full.pyi] + [case testSelfRecognizedInOtherSyntacticLocations] # flags: --python-version 3.14 --enable-incomplete-feature=TypeForm from typing import List, Self, TypeForm From 1d9620db23dd2e7dbbdfc47f44dcde96c233401c Mon Sep 17 00:00:00 2001 From: David Foster Date: Tue, 25 Mar 2025 20:26:09 -0400 Subject: [PATCH 21/23] Print error code for the MAYBE_UNRECOGNIZED_STR_TYPEFORM note --- mypy/errors.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/mypy/errors.py b/mypy/errors.py index 58ef17b69e96..e114e05312cc 100644 --- a/mypy/errors.py +++ b/mypy/errors.py @@ -20,7 +20,11 @@ # Show error codes for some note-level messages (these usually appear alone # and not as a comment for a previous error-level message). -SHOW_NOTE_CODES: Final = {codes.ANNOTATION_UNCHECKED, codes.DEPRECATED} +SHOW_NOTE_CODES: Final = { + codes.ANNOTATION_UNCHECKED, + codes.DEPRECATED, + codes.MAYBE_UNRECOGNIZED_STR_TYPEFORM, +} # Do not add notes with links to error code docs to errors with these codes. # We can tweak this set as we get more experience about what is helpful and what is not. From 76cae61b2d88952af0f6f305593ecbfa5c14de29 Mon Sep 17 00:00:00 2001 From: David Foster Date: Tue, 25 Mar 2025 21:05:30 -0400 Subject: [PATCH 22/23] Document the [maybe-unrecognized-str-typeform] error code --- docs/source/error_code_list.rst | 59 +++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/docs/source/error_code_list.rst b/docs/source/error_code_list.rst index 49cb8a0c06c1..c287b29ec9d3 100644 --- a/docs/source/error_code_list.rst +++ b/docs/source/error_code_list.rst @@ -1257,6 +1257,65 @@ type must be a subtype of the original type:: def g(x: object) -> TypeIs[str]: # OK ... +.. _code-maybe-unrecognized-str-typeform: + +String appears in a context which expects a TypeForm [maybe-unrecognized-str-typeform] +------------------------------------------------------------------------------------- + +TypeForm literals may contain string annotations: + +.. code-block:: python + + typx1: TypeForm = str | None + typx2: TypeForm = 'str | None' # OK + typx3: TypeForm = 'str' | None # OK + +However TypeForm literals containing a string annotation can only be recognized +by mypy in the following locations: + +.. code-block:: python + + typx_var: TypeForm = 'str | None' # assignment r-value + + def func(typx_param: TypeForm) -> TypeForm: + return 'str | None' # returned expression + + func('str | None') # callable's argument + +If you try to use a string annotation in some other location +which expects a TypeForm, the string value will always be treated as a ``str`` +even if a ``TypeForm`` would be more appropriate and this note code +will be generated: + +.. code-block:: python + + # Note: TypeForm containing a string annotation cannot be recognized here. Surround with TypeForm(...) to recognize. [maybe-unrecognized-str-typeform] + # Error: List item 0 has incompatible type "str"; expected "TypeForm[Any]" [list-item] + list_of_typx: list[TypeForm] = ['str | None', float] + +Fix the note by surrounding the entire type with ``TypeForm(...)``: + +.. code-block:: python + + list_of_typx: list[TypeForm] = [TypeForm('str | None'), float] # OK + +Similarly, if you try to use a string literal in a location which expects a +TypeForm, this note code will be generated: + +.. code-block:: python + + dict_of_typx = {'str_or_none': TypeForm(str | None)} + # Note: TypeForm containing a string annotation cannot be recognized here. Surround with TypeForm(...) to recognize. [maybe-unrecognized-str-typeform] + list_of_typx: list[TypeForm] = [dict_of_typx['str_or_none']] + +Fix the note by adding ``# type: ignore[maybe-unrecognized-str-typeform]`` +to the line with the string literal: + +.. code-block:: python + + dict_of_typx = {'str_or_none': TypeForm(str | None)} + list_of_typx: list[TypeForm] = [dict_of_typx['str_or_none']] # type: ignore[maybe-unrecognized-str-typeform] + .. _code-misc: Miscellaneous checks [misc] From 170a5e70dfb03425c98ba53b5ecb9b7def6e3f65 Mon Sep 17 00:00:00 2001 From: David Foster Date: Tue, 25 Mar 2025 21:07:42 -0400 Subject: [PATCH 23/23] Fix doc generation warning --- docs/source/error_code_list.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/error_code_list.rst b/docs/source/error_code_list.rst index c287b29ec9d3..4d9e84777236 100644 --- a/docs/source/error_code_list.rst +++ b/docs/source/error_code_list.rst @@ -1260,7 +1260,7 @@ type must be a subtype of the original type:: .. _code-maybe-unrecognized-str-typeform: String appears in a context which expects a TypeForm [maybe-unrecognized-str-typeform] -------------------------------------------------------------------------------------- +-------------------------------------------------------------------------------------- TypeForm literals may contain string annotations: