From ebcfaf36c877ba0e2f7a1a72225d09a816002eb2 Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Wed, 30 Jul 2025 22:31:02 +0100 Subject: [PATCH 1/4] Keep trivial instances and aliases during expansion --- mypy/expandtype.py | 5 +++-- mypy/semanal.py | 8 ++++---- mypy/types.py | 44 ++++++++++++++++++-------------------------- 3 files changed, 25 insertions(+), 32 deletions(-) diff --git a/mypy/expandtype.py b/mypy/expandtype.py index f704df3b010e..8433708eda44 100644 --- a/mypy/expandtype.py +++ b/mypy/expandtype.py @@ -210,8 +210,7 @@ def visit_erased_type(self, t: ErasedType) -> Type: def visit_instance(self, t: Instance) -> Type: if len(t.args) == 0: - # TODO: Why do we need to create a copy here? - return t.copy_modified() + return t args = self.expand_type_tuple_with_unpack(t.args) @@ -525,6 +524,8 @@ def visit_type_type(self, t: TypeType) -> Type: def visit_type_alias_type(self, t: TypeAliasType) -> Type: # Target of the type alias cannot contain type variables (not bound by the type # alias itself), so we just expand the arguments. + if len(t.args) == 0: + return t args = self.expand_type_list_with_unpack(t.args) # TODO: normalize if target is Tuple, and args are [*tuple[X, ...]]? return t.copy_modified(args=args) diff --git a/mypy/semanal.py b/mypy/semanal.py index 1840e606af37..7cca406b661b 100644 --- a/mypy/semanal.py +++ b/mypy/semanal.py @@ -1046,12 +1046,12 @@ def remove_unpack_kwargs(self, defn: FuncDef, typ: CallableType) -> CallableType last_type = typ.arg_types[-1] if not isinstance(last_type, UnpackType): return typ - last_type = get_proper_type(last_type.type) - if not isinstance(last_type, TypedDictType): + p_last_type = get_proper_type(last_type.type) + if not isinstance(p_last_type, TypedDictType): self.fail("Unpack item in ** argument must be a TypedDict", last_type) new_arg_types = typ.arg_types[:-1] + [AnyType(TypeOfAny.from_error)] return typ.copy_modified(arg_types=new_arg_types) - overlap = set(typ.arg_names) & set(last_type.items) + overlap = set(typ.arg_names) & set(p_last_type.items) # It is OK for TypedDict to have a key named 'kwargs'. overlap.discard(typ.arg_names[-1]) if overlap: @@ -1060,7 +1060,7 @@ def remove_unpack_kwargs(self, defn: FuncDef, typ: CallableType) -> CallableType new_arg_types = typ.arg_types[:-1] + [AnyType(TypeOfAny.from_error)] return typ.copy_modified(arg_types=new_arg_types) # OK, everything looks right now, mark the callable type as using unpack. - new_arg_types = typ.arg_types[:-1] + [last_type] + new_arg_types = typ.arg_types[:-1] + [p_last_type] return typ.copy_modified(arg_types=new_arg_types, unpack_kwargs=True) def prepare_method_signature(self, func: FuncDef, info: TypeInfo, has_self_type: bool) -> None: diff --git a/mypy/types.py b/mypy/types.py index e9d299dbc8fc..6dc28b2c9dde 100644 --- a/mypy/types.py +++ b/mypy/types.py @@ -355,11 +355,7 @@ def _expand_once(self) -> Type: ): mapping[tvar.id] = sub - new_tp = self.alias.target.accept(InstantiateAliasVisitor(mapping)) - new_tp.accept(LocationSetter(self.line, self.column)) - new_tp.line = self.line - new_tp.column = self.column - return new_tp + return self.alias.target.accept(InstantiateAliasVisitor(mapping)) def _partial_expansion(self, nothing_args: bool = False) -> tuple[ProperType, bool]: # Private method mostly for debugging and testing. @@ -3260,7 +3256,6 @@ def get_proper_types( TypeTranslator as TypeTranslator, TypeVisitor as TypeVisitor, ) -from mypy.typetraverser import TypeTraverserVisitor class TypeStrVisitor(SyntheticTypeVisitor[str]): @@ -3598,23 +3593,6 @@ def is_named_instance(t: Type, fullnames: str | tuple[str, ...]) -> TypeGuard[In return isinstance(t, Instance) and t.type.fullname in fullnames -class LocationSetter(TypeTraverserVisitor): - # TODO: Should we update locations of other Type subclasses? - def __init__(self, line: int, column: int) -> None: - self.line = line - self.column = column - - def visit_instance(self, typ: Instance) -> None: - typ.line = self.line - typ.column = self.column - super().visit_instance(typ) - - def visit_type_alias_type(self, typ: TypeAliasType) -> None: - typ.line = self.line - typ.column = self.column - super().visit_type_alias_type(typ) - - class HasTypeVars(BoolTypeQuery): """Visitor for querying whether a type has a type variable component.""" @@ -3709,8 +3687,8 @@ def flatten_nested_unions( flat_items: list[Type] = [] for t in typelist: - if handle_type_alias_type: - if not handle_recursive and isinstance(t, TypeAliasType) and t.is_recursive: + if handle_type_alias_type and isinstance(t, TypeAliasType): + if not handle_recursive and t.is_recursive: tp: Type = t else: tp = get_proper_type(t) @@ -3757,7 +3735,21 @@ def flatten_nested_tuples(types: Iterable[Type]) -> list[Type]: if not isinstance(p_type, TupleType): res.append(typ) continue - res.extend(flatten_nested_tuples(p_type.items)) + if isinstance(typ.type, TypeAliasType): + items = [] + for item in p_type.items: + if ( + isinstance(item, ProperType) + and isinstance(item, Instance) + or isinstance(item, TypeAliasType) + ): + if len(item.args) == 0: + item = item.copy_modified() + item.set_line(typ) + items.append(item) + else: + items = p_type.items + res.extend(flatten_nested_tuples(items)) return res From 54b5c72f554ed10ae77c0a1277378eef2a523b17 Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Thu, 31 Jul 2025 19:06:33 +0100 Subject: [PATCH 2/4] Restore single-shot TypeGuard behavior in a principled way --- mypy/binder.py | 6 +++++- mypy/meet.py | 4 ++-- mypy/plugins/proper_plugin.py | 1 + mypy/types.py | 7 +++---- 4 files changed, 11 insertions(+), 7 deletions(-) diff --git a/mypy/binder.py b/mypy/binder.py index 2ae58dad1fe0..cc85eb489bfd 100644 --- a/mypy/binder.py +++ b/mypy/binder.py @@ -20,6 +20,7 @@ ProperType, TupleType, Type, + TypeGuardedType, TypeOfAny, TypeType, TypeVarType, @@ -277,7 +278,10 @@ def update_from_options(self, frames: list[Frame]) -> bool: possible_types = [] for t in resulting_values: assert t is not None - possible_types.append(t.type) + if isinstance(t.type, TypeGuardedType): + possible_types.append(t.type.type_guard) + else: + possible_types.append(t.type) if len(possible_types) == 1: # This is to avoid calling get_proper_type() unless needed, as this may # interfere with our (hacky) TypeGuard support. diff --git a/mypy/meet.py b/mypy/meet.py index 2e238be7765e..b1c22c392b8b 100644 --- a/mypy/meet.py +++ b/mypy/meet.py @@ -116,7 +116,7 @@ def meet_types(s: Type, t: Type) -> ProperType: def narrow_declared_type(declared: Type, narrowed: Type) -> Type: """Return the declared type narrowed down to another type.""" # TODO: check infinite recursion for aliases here. - if isinstance(narrowed, TypeGuardedType): # type: ignore[misc] + if isinstance(narrowed, TypeGuardedType): # A type guard forces the new type even if it doesn't overlap the old. return narrowed.type_guard @@ -308,7 +308,7 @@ def is_overlapping_types( positives), for example: None only overlaps with explicitly optional types, Any doesn't overlap with anything except object, we don't ignore positional argument names. """ - if isinstance(left, TypeGuardedType) or isinstance( # type: ignore[misc] + if isinstance(left, TypeGuardedType) or isinstance( right, TypeGuardedType ): # A type guard forces the new type even if it doesn't overlap the old. diff --git a/mypy/plugins/proper_plugin.py b/mypy/plugins/proper_plugin.py index f51685c80afa..0189bfbd22fc 100644 --- a/mypy/plugins/proper_plugin.py +++ b/mypy/plugins/proper_plugin.py @@ -107,6 +107,7 @@ def is_special_target(right: ProperType) -> bool: "mypy.types.DeletedType", "mypy.types.RequiredType", "mypy.types.ReadOnlyType", + "mypy.types.TypeGuardedType", ): # Special case: these are not valid targets for a type alias and thus safe. # TODO: introduce a SyntheticType base to simplify this? diff --git a/mypy/types.py b/mypy/types.py index 6dc28b2c9dde..4b5ef332ccf9 100644 --- a/mypy/types.py +++ b/mypy/types.py @@ -3210,7 +3210,8 @@ def get_proper_type(typ: Type | None) -> ProperType | None: """ if typ is None: return None - if isinstance(typ, TypeGuardedType): # type: ignore[misc] + # TODO: this is an ugly hack, remove. + if isinstance(typ, TypeGuardedType): typ = typ.type_guard while isinstance(typ, TypeAliasType): typ = typ._expand_once() @@ -3234,9 +3235,7 @@ def get_proper_types( if isinstance(types, list): typelist = types # Optimize for the common case so that we don't need to allocate anything - if not any( - isinstance(t, (TypeAliasType, TypeGuardedType)) for t in typelist # type: ignore[misc] - ): + if not any(isinstance(t, (TypeAliasType, TypeGuardedType)) for t in typelist): return cast("list[ProperType]", typelist) return [get_proper_type(t) for t in typelist] else: From 410ce41ef8af5726a4fe38f698b675d6b40844cb Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 31 Jul 2025 18:08:14 +0000 Subject: [PATCH 3/4] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- mypy/meet.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/mypy/meet.py b/mypy/meet.py index b1c22c392b8b..39c80e392a1e 100644 --- a/mypy/meet.py +++ b/mypy/meet.py @@ -308,9 +308,7 @@ def is_overlapping_types( positives), for example: None only overlaps with explicitly optional types, Any doesn't overlap with anything except object, we don't ignore positional argument names. """ - if isinstance(left, TypeGuardedType) or isinstance( - right, TypeGuardedType - ): + if isinstance(left, TypeGuardedType) or isinstance(right, TypeGuardedType): # A type guard forces the new type even if it doesn't overlap the old. return True From 2a53146d55e15dbc6d732d38cdf3022085f4a25e Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Thu, 31 Jul 2025 22:08:57 +0100 Subject: [PATCH 4/4] Try another approach to TypeGuard --- mypy/binder.py | 6 +----- mypy/meet.py | 5 ++++- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/mypy/binder.py b/mypy/binder.py index cc85eb489bfd..2ae58dad1fe0 100644 --- a/mypy/binder.py +++ b/mypy/binder.py @@ -20,7 +20,6 @@ ProperType, TupleType, Type, - TypeGuardedType, TypeOfAny, TypeType, TypeVarType, @@ -278,10 +277,7 @@ def update_from_options(self, frames: list[Frame]) -> bool: possible_types = [] for t in resulting_values: assert t is not None - if isinstance(t.type, TypeGuardedType): - possible_types.append(t.type.type_guard) - else: - possible_types.append(t.type) + possible_types.append(t.type) if len(possible_types) == 1: # This is to avoid calling get_proper_type() unless needed, as this may # interfere with our (hacky) TypeGuard support. diff --git a/mypy/meet.py b/mypy/meet.py index 39c80e392a1e..fb35bce438ab 100644 --- a/mypy/meet.py +++ b/mypy/meet.py @@ -117,7 +117,10 @@ def narrow_declared_type(declared: Type, narrowed: Type) -> Type: """Return the declared type narrowed down to another type.""" # TODO: check infinite recursion for aliases here. if isinstance(narrowed, TypeGuardedType): - # A type guard forces the new type even if it doesn't overlap the old. + # A type guard forces the new type even if it doesn't overlap the old... + if is_proper_subtype(declared, narrowed.type_guard, ignore_promotions=True): + # ...unless it is a proper supertype of declared type. + return declared return narrowed.type_guard original_declared = declared